For the complete documentation index, see llms.txt. This page is also available as Markdown.

Roster Synchronization Tool: CSV/JSON/Database

Automatically provision members without an IdP using a ready-made roster synchronization tool that accepts CSV, JSON, or database exports and uses SCIM to create, update, and deactivate organization m

Use this tool when a customer does not have an IdP such as Okta or Entra ID that automatically pushes user data, and their personnel roster is stored in an HR system, Excel, or their own database. The tool synchronizes the roster with MaiAgent organization members: it creates or updates people on the roster and automatically deactivates those who have disappeared from it. It uses the SCIM Provisioning API under the hood, so you do not need to handle the API details yourself.

Each run performs one synchronization. Schedule it to run daily or trigger it whenever personnel changes occur.

1. Prepare the Roster

The roster has only four fields. id is the immutable personnel identifier in your system, such as an employee number or HR system ID, and the tool uses it to identify each person. Enter MaiAgent role names in groups. Simply remove former employees from the roster, and the tool will deactivate them.

In roster.csv, separate multiple roles in groups with ;:

id,email,name,groups
emp-10023,alice@acme.com,Alice Chen,Support
emp-10024,bob@acme.com,Bob Lin,Support;Manager
emp-10031,carol@acme.com,Carol Wu,

In roster.json, groups is an array:

[
  { "id": "emp-10023", "email": "alice@acme.com", "name": "Alice Chen", "groups": ["Support"] },
  { "id": "emp-10024", "email": "bob@acme.com",   "name": "Bob Lin",    "groups": ["Support", "Manager"] },
  { "id": "emp-10031", "email": "carol@acme.com", "name": "Carol Wu",   "groups": [] }
]

If the roster is in a database, use a single SQL query to export it as the JSON shown above, and then pass it to the tool. Any database type is supported. The following example uses PostgreSQL. For MySQL, replace the driver with pymysql; the same approach applies to the SQL:

"""export_roster.py: Export the active employee roster from the HR database to roster.json."""
import json
import os

import psycopg  # pip install "psycopg[binary]"

SQL = """
SELECT e.employee_no                    AS id,
       e.email                          AS email,
       e.full_name                      AS name,
       COALESCE(array_agg(r.role_name) FILTER (WHERE r.role_name IS NOT NULL), '{}') AS groups
FROM   employees e
LEFT   JOIN employee_roles r ON r.employee_no = e.employee_no
WHERE  e.status = 'active'
GROUP  BY e.employee_no, e.email, e.full_name
"""

with psycopg.connect(os.environ["HR_DATABASE_URL"]) as conn, conn.cursor() as cur:
    cur.execute(SQL)
    rows = [{"id": i, "email": e, "name": n, "groups": list(g)} for i, e, n, g in cur.fetchall()]

with open("roster.json", "w", encoding="utf-8") as f:
    json.dump(rows, f, ensure_ascii=False, indent=2)
print(f"Exported {len(rows)} people")

Chain the two steps when scheduling the job: python export_roster.py && python maiagent_scim_sync.py roster.json. Export only active employees (status = 'active'), and the tool will deactivate former employees.

2. Run the Tool

1

Configure the Connection

In the admin console, go to Organization Settings → Identity Synchronization, select SCIM, and copy the Endpoint URL and Token from the Connection Information tab. See Enable SCIM and Obtain a Token for instructions and permissions. Set them as environment variables. The token grants the same personnel management permissions as an organization administrator, so store it only on the server that runs this tool:

export MAIAGENT_SCIM_URL="https://api.maiagent.ai/api/v1/scim/<organization ID>"
export MAIAGENT_SCIM_TOKEN="<SCIM Token>"
pip install requests
2

Preview the Actions

python maiagent_scim_sync.py roster.csv --dry-run     # or roster.json

This prints the plan without changing any data:

[group]      Create role Manager (permissions are empty; configure role permissions in the admin console)
[create]     emp-10023 alice@acme.com  groups=Support
[update]     emp-10024 bob@acme.com    groups=Support;Manager
[email]      emp-10031 carol.new@acme.com  groups=  ⚠ Email cannot be changed through SCIM (MaiAgent still uses carol@acme.com). Change it in the admin console, or deactivate the old account and create one with the new email
[deactivate] emp-10009 dave@acme.com   (not on the roster)
[skip]       owner@acme.com            (not created by this tool; skipped)
Plan: create 1, update 1, deactivate 1, unchanged 0, skip 1, manual action required 1
3

Run It and Schedule It

Run the command without --dry-run. After it finishes, these people should appear under Member Management in the admin console. Run it again, and every entry should be [unchanged]. This demonstrates that the operation is idempotent, so you can safely schedule it, for example, every morning:

0 7 * * * cd /opt/maiagent-sync && python maiagent_scim_sync.py roster.csv >> sync.log 2>&1

3. The Tool

maiagent_scim_sync.py requires Python 3.10+ and only depends on requests. It accepts rosters with either a .csv or .json extension. It has been tested in the MaiAgent dev environment for creating members, assigning existing admin-console roles, automatically creating roles, producing no changes on a repeated run, deactivating removed members, and warning about email changes.

"""Synchronize a roster (CSV or JSON) with MaiAgent organization members through SCIM. Usage: python maiagent_scim_sync.py roster.csv|roster.json [--dry-run]"""
import csv
import json
import os
import sys
import time
import urllib.parse

import requests

SCIM_URL = os.environ["MAIAGENT_SCIM_URL"].rstrip("/")
HEADERS = {
    "Authorization": f"Bearer {os.environ['MAIAGENT_SCIM_TOKEN']}",
    "Content-Type": "application/scim+json",
    "Accept": "application/scim+json",
}
USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"


def scim(method, path, **kwargs):
    """Call a SCIM endpoint; wait and retry after hitting the per-minute rate limit (429)."""
    for attempt in range(5):
        res = requests.request(method, f"{SCIM_URL}{path}", headers=HEADERS, timeout=30, **kwargs)
        if res.status_code != 429:
            return res
        time.sleep(2 ** attempt)
    res.raise_for_status()


def read_roster(path):
    """Read CSV (groups separated by ;) or JSON (groups as an array), returning [{id, email, name, groups}]."""
    if path.lower().endswith(".json"):
        with open(path, encoding="utf-8") as f:
            rows = json.load(f)
    else:
        with open(path, newline="", encoding="utf-8-sig") as f:
            rows = list(csv.DictReader(f))
    roster = []
    for row in rows:
        groups = row.get("groups") or []
        if isinstance(groups, str):
            groups = [g.strip() for g in groups.split(";") if g.strip()]
        roster.append({"id": str(row["id"]).strip(), "email": row["email"].strip(), "name": (row.get("name") or "").strip(), "groups": groups})
    return roster


def list_all_members():
    members, start = [], 1
    while True:
        page = scim("GET", f"/Users?startIndex={start}&count=100").json()
        resources = page.get("Resources", [])
        members.extend(resources)
        if not resources or start + len(resources) > page.get("totalResults", 0):
            return members
        start += len(resources)


def find_by_email(email):
    query = urllib.parse.quote(f'userName eq "{email}"')
    resources = scim("GET", f"/Users?filter={query}").json().get("Resources", [])
    return resources[0] if resources else None


def ensure_groups(names, dry_run):
    """Ensure that roles used by the roster exist and are mapped to SCIM:
    - Missing role: POST /Groups creates it (with no permissions; configure them in the admin console)
    - Existing role (for example, one configured in the admin console): PATCH the same name once to create its SCIM mapping, so it can then be assigned through a User groups[] field
    """
    for name in sorted(names):
        query = urllib.parse.quote(f'displayName eq "{name}"')
        found = scim("GET", f"/Groups?filter={query}").json().get("Resources")
        if found:
            if not dry_run:
                scim("PATCH", f"/Groups/{found[0]['id']}", json={"schemas": [PATCH_SCHEMA], "Operations": [{"op": "replace", "path": "displayName", "value": name}]}).raise_for_status()
            continue
        print(f"[group]      Create role {name} (permissions are empty; configure role permissions in the admin console)")
        if not dry_run:
            scim("POST", "/Groups", json={"schemas": [GROUP_SCHEMA], "displayName": name}).raise_for_status()


def to_payload(person):
    return {
        "schemas": [USER_SCHEMA],
        "userName": person["email"],
        "externalId": person["id"],
        "displayName": person["name"],
        "active": True,
        "groups": [{"display": g} for g in person["groups"]],
    }


def same_as(member, person):
    current_groups = sorted(g["display"] for g in member.get("groups", []))
    return (
        member.get("userName", "").lower() == person["email"].lower()
        and member.get("displayName", "") == person["name"]
        and member.get("active") is True
        and current_groups == sorted(person["groups"])
    )


def main(roster_path, dry_run):
    roster = read_roster(roster_path)
    ensure_groups({g for person in roster for g in person["groups"]}, dry_run)
    existing = list_all_members()
    by_external_id = {m["externalId"]: m for m in existing if m.get("externalId")}
    counts = {"create": 0, "update": 0, "deactivate": 0, "unchanged": 0, "skip": 0, "warn": 0}

    for person in roster:
        member = by_external_id.get(person["id"]) or find_by_email(person["email"])
        label = f"{person['id']} {person['email']}  groups={';'.join(person['groups'])}"
        if member is None:
            counts["create"] += 1
            print(f"[create]     {label}")
            if not dry_run:
                scim("POST", "/Users", json=to_payload(person)).raise_for_status()
        elif member.get("userName", "").lower() != person["email"].lower() and member.get("externalId") == person["id"]:
            counts["warn"] += 1
            print(f"[email]      {label}  ⚠ Email cannot be changed through SCIM (MaiAgent still uses {member['userName']}). Change it in the admin console, or deactivate the old account and create one with the new email")
        elif same_as(member, person) and member.get("externalId") == person["id"]:
            counts["unchanged"] += 1
            print(f"[unchanged]  {label}")
        else:
            counts["update"] += 1
            print(f"[update]     {label}")
            if not dry_run:
                scim("PUT", f"/Users/{member['id']}", json=to_payload(person)).raise_for_status()

    wanted = {person["id"] for person in roster}
    for member in existing:
        if not member.get("externalId"):
            counts["skip"] += 1
            print(f"[skip]       {member.get('userName')}  (not created by this tool; skipped)")
        elif member["externalId"] not in wanted and member.get("active"):
            counts["deactivate"] += 1
            print(f"[deactivate] {member['externalId']} {member.get('userName')}  (not on the roster)")
            if not dry_run:
                scim(
                    "PATCH",
                    f"/Users/{member['id']}",
                    json={"schemas": [PATCH_SCHEMA], "Operations": [{"op": "replace", "path": "active", "value": False}]},
                ).raise_for_status()

    mode = "Plan" if dry_run else "Complete"
    print(f"{mode}: create {counts['create']}, update {counts['update']}, deactivate {counts['deactivate']}, unchanged {counts['unchanged']}, skip {counts['skip']}, manual action required {counts['warn']}")


if __name__ == "__main__":
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    if len(args) != 1:
        sys.exit("Usage: python maiagent_scim_sync.py roster.csv|roster.json [--dry-run]")
    main(args[0], dry_run="--dry-run" in sys.argv)

4. How It Works and Why It Is Safe

  • It only modifies people it created: The tool uses externalId—your personnel identifier—as the key. Members who were manually invited through the admin console and have no externalId are always marked [skip] and are never deactivated accidentally. If a member with the same email already exists, such as one previously added by invitation, the tool adds an externalId and takes over management instead of creating a duplicate.

  • Deactivation is not deletion: Former employees' conversations and credit records are retained, but they can no longer use the system and their API keys are revoked. Adding them back to the roster reactivates them.

  • Roles are verified and mapped first: For a role created in the admin console, the tool sends one PATCH with the same name to create its SCIM mapping before assigning it. Only missing roles are created with POST /Groups, and new roles have no permissions. Members are assigned only to roles listed in the roster, not to default roles. To use MaiGPT, a role must have MaiGPT access.

  • Email cannot be changed through SCIM: If someone's email changes in the roster, the tool prints an [email] warning and skips the entry. Change the email in the admin console, or remove the old person from the roster to deactivate the old account and add the new email as a new person.

  • Email is the login account: Accounts created through SCIM have no password. Employees need SSO to sign in to the MaiAgent website. SSO is not required if you only use member identity embedding.

  • You are not limited to Python: Each action is simply a set of HTTP calls (GET /Users?filter=, POST /Users, PUT/PATCH /Users/{id}, and GET/POST /Groups). You can reproduce the sequence in any language. See the SCIM Provisioning API for API details.

5. Common Errors

Response
Cause
Resolution

401

The token is incorrect or has been regenerated, or the organization ID does not match the token

In Connection Information in the admin console, confirm that the Endpoint URL and Token belong to the same organization

403/page not found

The organization's plan does not include identity synchronization

Contact your sales or integration representative

400 userName or emails is required

An email is missing from the roster

Add the email field

409 already exists

A member with the same email already belongs to the organization

The tool handles this by using PUT to take over management. If calling the API yourself, find the member with filter=userName eq "…", then use PUT or PATCH

404 on /Users/{id}

id is not the MaiAgent member ID, often because externalId was used by mistake

First use filter=externalId eq "…" to obtain the member ID

429

Limit of 100 requests per organization per minute

The tool automatically backs off. When calling the API yourself, limit batches to approximately one request per second

415

Content-Type is not application/scim+json (application/json is also accepted)

Change the header

Role not assigned

The role does not exist, or a role created in the admin console has not yet been mapped to SCIM (groups[] on a User only recognizes mapped roles)

First use POST /Groups, or send PATCH with the same displayName for an existing role. The tool handles this automatically

Member exists but cannot use anything

The member has no role, or the role has no permissions

Configure role permissions in the admin console, including MaiGPT access. We recommend creating roles before synchronization

Email change did not take effect

SCIM does not allow changing userName

Change it in the admin console, or deactivate the old account and create one with the new email

Member was created but cannot sign in

Accounts created through SCIM have no password

Configure SSO, or only use member identity embedding without signing in

Last updated

Was this helpful?