> For the complete documentation index, see [llms.txt](https://docs.maiagent.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.maiagent.ai/tech/en/identity-and-access/member-provisioning/member-roster-sync.md).

# Roster Synchronization Tools: CSV / JSON / Database

Automatically provision members without an IdP: a ready-to-use roster sync tool that accepts CSV, JSON, or database exports to create, update, and deactivate organization members through SCIM, with su

Use this tool when you do not have an IdP such as Okta or Entra ID that automatically pushes user accounts, and your roster is stored in an HR system, Excel, or your own database. The tool synchronizes the roster with MaiAgent organization members: it creates or updates people on the roster and automatically deactivates those removed from it. It uses the [SCIM Provisioning API](/tech/en/identity-and-access/member-provisioning.md#scim), so you do not need to handle the API details yourself.

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

## 1. Prepare the Roster <a href="#roster" id="roster"></a>

The roster has just four fields. `id` is a personnel identifier in your system that **never changes** (an employee number or HR system ID); the tool uses it to identify people. Set `groups` to the MaiAgent role names. Simply **remove departing employees from the roster**, and the tool will deactivate them.

{% hint style="warning" %}
**Create roles and configure their permissions in the admin console first** (including MaiGPT access), then use the same names in the roster. The tool automatically creates roles that do not exist, but these roles **have no permissions**. Members will be created but unable to use any features until you add permissions in the admin console.
{% endhint %}

{% tabs %}
{% tab title="CSV" %}
`roster.csv`: separate multiple roles in `groups` with `;`:

```csv
id,email,name,groups
emp-10023,alice@acme.com,Alice Chen,客服
emp-10024,bob@acme.com,Bob Lin,客服;主管
emp-10031,carol@acme.com,Carol Wu,
```

{% endtab %}

{% tab title="JSON" %}
`roster.json`: `groups` is an array:

```json
[
  { "id": "emp-10023", "email": "alice@acme.com", "name": "Alice Chen", "groups": ["客服"] },
  { "id": "emp-10024", "email": "bob@acme.com",   "name": "Bob Lin",    "groups": ["客服", "主管"] },
  { "id": "emp-10031", "email": "carol@acme.com", "name": "Carol Wu",   "groups": [] }
]
```

{% endtab %}

{% tab title="Database" %}
If the roster is in a database, export it to the JSON format above with a SQL query, then pass it to the tool. Any database type works. Here is a PostgreSQL example (for MySQL, use `pymysql` and adapt the SQL accordingly):

```python
"""export_roster.py: Export active employees 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: `python export_roster.py && python maiagent_scim_sync.py roster.json`. Export only active employees (`status = 'active'`), and the tool will deactivate those who have left.
{% endtab %}
{% endtabs %}

## 2. Run the Tool <a href="#run" id="run"></a>

{% stepper %}
{% step %}

### Configure Connection Settings

In the admin console, go to “Organization Settings → Identity Sync” and select SCIM. Copy the Endpoint URL and Token from the “Connection Information” tab (see [Enable SCIM and Obtain a Token](/tech/en/identity-and-access/member-provisioning.md#scim-enable) for access requirements and instructions), then set them as environment variables. The Token grants personnel management privileges for the organization and must be stored only on the server running this tool:

```bash
export MAIAGENT_SCIM_URL="https://api.maiagent.ai/api/v1/scim/<組織 ID>"
export MAIAGENT_SCIM_TOKEN="<SCIM Token>"
pip install requests
```

{% endstep %}

{% step %}

### Preview the Planned Changes

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

This prints the plan without changing any data:

```
[group]      Create role 主管 (no permissions; configure role permissions in the admin console)
[create]     emp-10023 alice@acme.com  groups=客服
[update]     emp-10024 bob@acme.com    groups=客服;主管
[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; left untouched)
Plan: create 1, update 1, deactivate 1, unchanged 0, skip 1, manual action required 1
```

{% endstep %}

{% step %}

### Run the Sync, Then Schedule It

Run the command without `--dry-run`. After it finishes, these people should appear under “Member Management” in the admin console. **Running it again should report `[unchanged]` for everyone**. This is idempotency, 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
```

{% endstep %}
{% endstepper %}

## 3. Tool Source Code <a href="#tool" id="tool"></a>

`maiagent_scim_sync.py` requires Python 3.10+ and only depends on `requests`. It accepts roster files with either a `.csv` or `.json` extension. It has been tested in the MaiAgent dev environment for member creation, assignment of existing admin-console roles and automatically created roles, unchanged results on repeated runs, deactivation after removal, and Email change warnings.

```python
"""Sync a roster (CSV or JSON) to MaiAgent organization members (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 if the per-minute rate limit is hit (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 in the roster exist and are mapped to SCIM:
    - Missing → create with POST /Groups (no permissions; add role permissions in the admin console)
    - Existing (e.g., created in the admin console with permissions configured) → PATCH with the same name once to establish the SCIM mapping so that User groups[] can be assigned
    """
    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} (no permissions; 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; left untouched)")
        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 "Done"
    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 <a href="#behavior" id="behavior"></a>

* **Only manages members it has created**: uses `externalId` (your personnel identifier) as the key. Members manually invited through the admin console without an `externalId` are always marked `[skip]`, preventing accidental deactivation. If the same Email already belongs to a member (for example, someone previously invited), the tool adds `externalId` and takes over management without creating a duplicate.
* **Deactivation does not delete members**: departing employees retain their conversations and credit records, but lose access and their API Keys are revoked. Adding them back to the roster reactivates them.
* **Ensures roles exist and are mapped before assignment**: for roles already created in the admin console, the tool sends a `PATCH` with the same name once to establish a SCIM mapping before assigning members. It uses `POST /Groups` only for missing roles (with no permissions). Members are assigned only to roles specified in the roster, not default roles. To use MaiGPT, the role must have [MaiGPT Access](https://docs.maiagent.ai/org/roles/maigpt-role-permissions).
* **Email cannot be changed through SCIM**: if someone’s Email changes in the roster, the tool prints an `[email]` warning and skips that person. Change it in the admin console, or remove the old person from the roster (deactivating them) and add a new person with the new Email.
* **Email is the login account**: accounts created through SCIM have no password. Configure SSO separately if employees need to log in to the MaiAgent website. Login is not required when using only [Member Identity Embedding](/tech/en/work/maigpt-system-integration-guide/maigpt-integration.md#member-embed).
* **Works in other languages too**: each action is just a few HTTP calls (`GET /Users?filter=`, `POST /Users`, `PUT` / `PATCH /Users/{id}`, `GET` / `POST /Groups`). Follow the same sequence to implement it in any language. See the [SCIM Provisioning API](/tech/en/identity-and-access/member-provisioning.md#scim) for API details.

## 5. Common Errors <a href="#errors" id="errors"></a>

| Response                                      | Cause                                                                                                                                    | Resolution                                                                                                                                                           |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`                                         | Incorrect or regenerated Token, or an organization ID that does not match the Token                                                      | In the admin console’s “Connection Information” tab, confirm that the Endpoint URL and Token belong to the same organization                                         |
| `403` / page not found                        | The organization plan does not include identity sync                                                                                     | Contact sales or your integration contact                                                                                                                            |
| `400` `userName or emails is required`        | Email is missing from the roster                                                                                                         | Add the `email` field                                                                                                                                                |
| `409` `already exists`                        | The same Email already belongs to a member of this organization                                                                          | The tool handles this by using `PUT` to take over management. When calling the API yourself, find the member with `filter=userName eq "…"`, then use `PUT` / `PATCH` |
| `404` on `/Users/{id}`                        | `id` is not a MaiAgent member ID (often caused by using `externalId` instead)                                                            | First use `filter=externalId eq "…"` to obtain the member ID                                                                                                         |
| `429`                                         | 100 requests per organization per minute                                                                                                 | The tool automatically backs off. When calling the API yourself, limit batch requests to about one 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 been mapped to SCIM (User `groups[]` resolves only mapped roles) | First use `POST /Groups`, or `PATCH` an existing role with the same `displayName`; the tool includes this behavior                                                   |
| Member created but unable to use any features | No role assigned, or the role has no permissions                                                                                         | Configure role permissions in the admin console (including MaiGPT access); create roles before synchronizing                                                         |
| Email change has no effect                    | SCIM does not allow changes to `userName`                                                                                                | Change it in the admin console, or deactivate the old account and create one with the new Email                                                                      |
| Member created but unable to log in           | Accounts created through SCIM have no password                                                                                           | Configure SSO, or use only member identity embedding without logging in                                                                                              |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.maiagent.ai/tech/en/identity-and-access/member-provisioning/member-roster-sync.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
