Skip to content

Users & tokens API

Users are identity records within a tenant. Each user has a role and can hold multiple auth tokens. Tokens are the credentials used to authenticate inference requests.

Base URL: https://<your-gateway-host>/admin/v1


Endpoints

Users

Method Path Description
GET /users List tenant-less platform-level users only (tenant_id IS NULL; platform admin only)
GET /users/search Find users in your own tenant by exact email (any member — powers project/group/agent member pickers)
GET /user-types List the available chat-persona roles
GET /tenants/{id}/users List users for a tenant
GET /users/{id} Get a single user (tenant admin only)
POST /tenants/{id}/users Create a user (sends an invitation email)
POST /users/{id}/resend-invite Resend the invitation email
PATCH /users/{id} Update a user. A role change applies on the user's next admin request, open sessions included (no re-login). An email change is guarded and signs the user out (see Updating a user)
DELETE /users/{id} Soft-delete a user (sets deleted_at; locks out the account)
POST /users/{id}/restore Restore a soft-deleted user (admin path; no self-service)
POST /users/{id}/disable Disable a user: block login and end every session/API token permanently on every device (re-enabling restores login but does not revive them)
POST /users/{id}/enable Re-enable a disabled user (login restored; the sessions and API tokens that existed before the disable stay ended — the user signs in afresh)
PUT /users/{id}/static-otp Set or clear a fixed OTP code for a user (platform admin only)
DELETE /users/{id}/budget Reset all token spend counters for a user

Tokens

Method Path Description
GET /gateways/{id}/tokens List a gateway's tokens (the gateway's own internal service credentials are excluded — see Internal service credentials)
POST /gateways/{id}/tokens Create a gateway-scoped token
PATCH /gateways/{id}/tokens/{tid} Edit an existing token's rate_limit / budget_usd / expires_at (an internal service credential → 403)
DELETE /gateways/{id}/tokens/{tid} Revoke a token (an internal service credential → 403)
GET /users/{id}/tokens List tokens belonging to a user (tenant admin only; token hashes are never returned)
POST /users/{id}/tokens Create a user-scoped token

Self-service (my tokens)

Method Path Description
GET /me/tokens List the caller's own tokens
POST /me/tokens Create a token for the caller
DELETE /me/tokens/{tid} Revoke one of the caller's own tokens

Users

Listing users

curl https://<your-gateway-host>/admin/v1/tenants/{tenant_id}/users

Response: same row shape as listing all users below, including assignable_roles:

[
  {
    "id": "usr_abc123",
    "tenant_id": "ten_xyz",
    "email": "alice@example.com",
    "name": "Alice",
    "role": "member",
    "created_at": 1742551200,
    "assignable_roles": []
  }
]

Listing all users (platform-level)

Returns only tenant-less, platform-level users — those with tenant_id === null (the query is WHERE u.tenant_id IS NULL). It does not return tenant members: a user affiliated with a tenant (including every tenant_admin) has a non-null tenant_id and is served exclusively by GET /tenants/{id}/users. Required role: platform admin only. To list a specific tenant's members (all roles, admins included), use GET /tenants/{id}/users; to enumerate every user across every tenant, merge the per-tenant listings with this platform-level list.

curl "https://<your-gateway-host>/admin/v1/users?sort=created_at&dir=desc"
Parameter Type Default Description
sort string email Sort column. One of email, name, role, tenant, last_login_at, created_at. Unknown values fall back to email.
dir string asc Sort direction. asc or desc (any other value is treated as asc).
include_deleted string — Set to 1 or true to also return soft-deleted users. Omit to return active users only.

Response: array of user rows.

Field Type Description
id string User UUID.
tenant_id string | null Tenant UUID (null for tenant-less platform users).
email string Email address.
name string | null Display name.
role string One of admin, tenant_admin, ki_manager, finance, member, viewer, or demouser.
tenant_slug string | null Slug of the user's tenant.
created_at integer Creation time as a Unix timestamp (seconds).
last_login_at integer | null Last successful login as a Unix timestamp (seconds); null if never.
deleted_at integer | null Soft-delete time as a Unix timestamp (seconds); null if active.
deleted_by_id string | null UUID of the admin who deleted the user; null if active.
disabled_at integer | null Disable (suspension) time as a Unix timestamp (seconds); null if not disabled. Independent of deleted_at.
processing_restricted integer 1 when the user is under a GDPR Art. 18 processing hold, 0 otherwise (see Restricting processing). Returned on the list and get endpoints; set via PATCH.
assignable_roles string[] What THIS ROW's own role tier may assign to others — server-derived from the same ASSIGNABLE_ROLES authority every actual role-assignment re-checks (never client-computed). Powers the admin UI's "Can assign" column. [] for a role with no assignable roles (ki_manager, finance, member, viewer, demouser) or an absent/unmapped role. Present on this endpoint, GET /tenants/{id}/users, and GET /users/{id} only — not on the PATCH/disable/enable responses.
[
  {
    "id": "usr_abc123",
    "tenant_id": "ten_xyz",
    "email": "alice@example.com",
    "name": "Alice",
    "role": "member",
    "tenant_slug": "myapp",
    "created_at": 1742551200,
    "last_login_at": 1742637600,
    "deleted_at": null,
    "deleted_by_id": null,
    "disabled_at": null,
    "assignable_roles": []
  }
]

Searching users by email

Looks up users in the caller's own tenant by exact email match (up to 5 results). Available to any authenticated admin user that belongs to a tenant.

curl "https://<your-gateway-host>/admin/v1/users/search?email=alice@example.com"
Parameter Type Required Description
email string Yes Exact email address to match.

Returns 400 { "error": "email parameter required" } if email is missing or empty, and 400 { "error": "user has no tenant" } if the caller has no tenant.

Response: array of matching users (max 5), each with id, email, and name.

[
  { "id": "usr_abc123", "email": "alice@example.com", "name": "Alice" }
]

Getting a user

curl https://<your-gateway-host>/admin/v1/users/{id}

Returns the single user record, including assignable_roles (see the field table above). The caller must have access to the user (require_user_access); otherwise the request is rejected. Returns 404 { "error": "user not found" } if no such user exists.

Creating a user

curl -X POST https://<your-gateway-host>/admin/v1/tenants/{tenant_id}/users \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alice@example.com",
    "name": "Alice",
    "role": "member"
  }'
Field Type Required Description
email string Yes Email address of the user. Must be globally unique.
name string No Display name.
role string No One of tenant_admin, ki_manager, finance, member, viewer, or demouser. Defaults to member. Only platform admin users can create other admin accounts. The finance role is a billing-scoped role assignable by admin and tenant_admin; see the Finance API.
inviter_locale string No The inviting admin's interface language ("en" or "de"; a region tag such as "de-DE" is folded). Used only to seed the tenant's default e-mail language (default_locale) the first time a member of the same tenant invites — and only as a fallback when the inviter's own stored locale is unset (the stored value wins). Never overwrites an existing default; never seeds when the actor is a platform operator from outside the tenant, or when the invite itself is refused (403/409/404). Accepted: a string that names a supported language. Ignored (never a 400): absent, empty, an unsupported language, a non-string — nothing is written and the invitation goes out in the tenant's current default (English when none). The seed is audited (tenant.default_locale_seeded).

Response: { "id": "usr_abc123", "email": "alice@example.com" }

The invitation e-mail is sent in the tenant's default e-mail language (see above), else English; the resend uses the member's own locale when set, then the tenant default, then English.

Updating a user

Required role: tenant_admin (or admin). Accepts any subset of email, name, role, tenant_id (platform admin only), processing_restricted, reactivation_opted_out (see Reactivation e-mails), and conversation_retention_days (an integer 30–3650, or null/0 to clear the per-user override; any other value returns 400). Unknown fields are ignored. A non-platform-admin editing their own record cannot change their own role or tenant_id (both return 403; see Editing your own record).

curl -X PATCH https://<your-gateway-host>/admin/v1/users/{id} \
  -H "Content-Type: application/json" \
  -d '{"role": "viewer"}'

Changing a user's email is an account-identity change and is guarded. Because email one-time-passcode sign-in resolves by the stored address, moving a user's email would otherwise redirect their next login code — an account-takeover vector. When (and only when) the request actually changes the email:

  • A malformed address is rejected with 400 ({"error":"email must be a valid address"}) — never coerced or truncated. The accepted shape is local@domain.tld: a single @ with non-empty sides, a dotted domain, no whitespace or control bytes, 3–254 bytes. A null/omitted email (or one equal to the current value) is a no-op and is not validated.
  • The change to a different user requires the caller to out-rank the target — the same role hierarchy that governs role assignment and the processing hold. A caller may change an email only on a user whose role they could also assign, so a delegate holding USERS_MANAGE via a custom role but whose base role is member cannot rewrite a tenant_admin's (or any peer's) email — the request returns 403. Editing your own email is always allowed (self-service). A name-only edit by that same delegate still succeeds (200) — the delegation feature is not affected.
  • A successful email change signs the target out of every existing session. It stamps the per-user session-revocation floor (sessions_valid_after), so an admin cookie issued before the change is rejected with 401 on its next /admin/ request — the user (and anyone riding a stale session) must sign in afresh at the new address. The user's /v1 API tokens are not revoked (unlike a disable).
  • An address already in use by another account (a live account, or a soft-deleted one that still reserves the address) returns 409 {"error":"email already in use"}, never a raw 500. Reclaiming a departed user's address requires erasing the old record first.

Editing your own record

A non-platform-admin cannot change their own role or tenant_id. When the caller is not a platform admin and the target user is themselves ({id} == the caller's own id):

  • A role change is rejected with 403 ({"error":"you cannot change your own role"}) — regardless of how many other administrators exist. A tenant admin cannot demote themselves out of the admin set (a self-inflicted lockout: a member cannot manage users and a tenant_admin cannot re-grant the tenant_admin role), and no self-service role change on one's own account serves any other purpose. Absent vs malformed: an omitted role (or one equal to the current value) is a no-op and is allowed; a non-string role is rejected with 400 — neither degrades into a permissive write.
  • A tenant_id change is rejected with 403 ({"error":"only platform admins may change a user's tenant"}) — moving yourself to another tenant is a platform-level operation, never self-service (this applies to any non-platform-admin, self or not).
  • Other self-edits are unaffected: changing your own name or email (self-service, subject to the email guard above) still returns 200.

A platform admin editing any record (including their own) is exempt. A tenant admin editing a different user's role is unaffected (subject to the role-assignment hierarchy). The client is never the authorization boundary — the UI hides these fields on your own row, and the server enforces the rejection.

Demoting the last administrator is refused. A role change that would move the tenant's sole active administrator (admin or tenant_admin) out of the admin set is rejected with 409 {"error": …, "code": "last_admin_blocked"} — the same guard (and code) the delete and disable paths use. Because a non-platform-admin's own role change is already refused by the self-edit guard above, this 409 governs demoting another administrator (for example a platform admin demoting the only other tenant admin): a demotion keeps the user but strips the tenant's last admin, leaving nobody who can manage the organisation and no in-product way back. Promote another administrator first, then retry. A role change that keeps the user an administrator, or that targets a non-administrator, is unaffected.

The same rank check guards conversation_retention_days on a different user (RBAC-2): it arms irreversible automated deletion of that user's conversations, so a delegate may set it only on a user they out-rank (else 403); setting your own retention is self-service.

Changes to email, name, role, or tenant_id are recorded in the audit log as a user.rectified event (GDPR Art. 16). The event stores only the names of the changed fields — never the old or new values — so that a later Art. 17 erasure cannot leak personal data through the retained audit trail.

Restricting processing (GDPR Art. 18)

processing_restricted is a boolean. When true, the user is placed under a reversible legal hold: every inference request they make (raw /v1, /easy/playground, and any agent the user owns) is rejected with 403 processing_restricted and no model is ever called. The hold is independent of the viewer role and of soft/hard deletion. Send false to lift it.

curl -X PATCH https://<your-gateway-host>/admin/v1/users/{id} \
  -H "Content-Type: application/json" \
  -d '{"processing_restricted": true}'

Only a real JSON boolean is accepted — any other type (1, "true", 0) is rejected with 400, never silently dropped, so an admin sending 0/"false" to lift a hold can never receive a 200 with the hold still in place. A caller may only toggle the restriction on a user whose role they could also assign: a tenant_admin cannot restrict a platform admin or a peer tenant_admin (the request returns 403), mirroring the role-change hierarchy. A toggle to the value the account already holds is a no-op (no write, no audit). A real change emits a user.processing_restricted / user.processing_unrestricted audit event (actor + timestamp, no values).

Reactivation e-mails: opt-out and status

The administrative door to a member's reactivation-mail opt-out — the way to honour an objection received by phone or e-mail; the member's own door is the signed link in every mail.

curl -X PATCH https://<your-gateway-host>/admin/v1/users/{id} \
  -H "Content-Type: application/json" \
  -d '{"reactivation_opted_out": true}'

reactivation_opted_out is a boolean: true stamps reactivation_opted_out_at (an existing stamp is kept), false clears it (opt-in). Same rules as processing_restricted: any other type (1, "true") → 400, never coerced; the caller must out-rank the target (else 403); a toggle to the value already held is a no-op; a real change emits user.reactivation_opted_out / user.reactivation_opted_in (actor, IP, timestamp). An administrative opt-in does not invalidate the member's own link — a renewed objection wins.

curl https://<your-gateway-host>/admin/v1/users/{id}/reactivation-status

GET /admin/v1/users/{id}/reactivation-status answers "why did this member (not) get mail X". Required permissions: USERS_MANAGE and REQUEST_LOGS_VIEW (the answer is the member's activity timeline, which a plain USERS_MANAGE delegate may not see), plus access to the user's tenant; a member reading their own status is 403, and so is a tenant admin naming a user of another tenant (require_user_access fences the tenant first). 404 for an unknown or soft-deleted user. Response fields: enabled_effective (0|1 — the tenant's resolved switch), excluded_reason (disabled | processing_restricted | opted_out | no_email | test_email, or absent), ever_active, last_activity_at, state (never_activated | lapsed | dormant | active), anchor_at, next_step, next_due_at, due_now, blocked (pending_in_flight | gap | not_yet_due), sequence_complete, and events[] — every send-log row (sequence, step, anchor_at, sent_at, outcome, attempts, reactivated_at). A database fault is 503.

Deleting a user

Deletes the user record and immediately disables all tokens associated with that user. In-flight requests that have already passed the auth phase complete normally.

curl -X DELETE https://<your-gateway-host>/admin/v1/users/{id}

Last-administrator guard. Deleting an administrator (admin or tenant_admin) is guarded server-side so a tenant cannot be left with nobody able to manage it. The check runs regardless of the client:

Situation Result
Not the last administrator (e.g. a member, or another admin remains) 200, deleted normally.
Sole administrator while other members remain 409 { "error": …, "code": "last_admin_blocked" }. Deleting would orphan those members. Not overridable — promote another administrator first.
Sole administrator who is the only member 409 { "error": …, "code": "last_admin_confirm_required" }. The organisation would be left with zero administrators. Legitimate when winding an organisation down, so it is overridable by re-issuing the request with ?confirm_last_admin=1.

The override flag is checked for exact equality with the string 1 — a bare ?confirm_last_admin, a duplicated key, or any other value (e.g. 0) fails closed and the delete is still refused. A DB fault reading the administrator counts also fails closed (500, nothing deleted). The admin console turns the last_admin_confirm_required code into a warning naming the organisation and retries with the flag on confirmation; last_admin_blocked is shown as a remediation message.

Resetting user token budgets

Clears the accumulated spend for every token belonging to the user.

curl -X DELETE https://<your-gateway-host>/admin/v1/users/{id}/budget

Resending the invitation email

Required role: tenant_admin (or admin). The endpoint sends the same invitation template that was sent on user creation. The response is {"ok": true}.

curl -X POST https://<your-gateway-host>/admin/v1/users/{id}/resend-invite

Restoring a soft-deleted user

Required role: tenant_admin (or admin). Restoring an already-active user returns {"ok": true, "already_active": true}. There is no self-service restore — a deleted user cannot reactivate their own account.

Seat-capped. A restore re-activates a seat, so it obeys the same per-plan seat cap as an invite (plan_config.max_seats, resolved via the effective-seat-cap resolver). If the tenant is already at its cap, the restore is refused 403 seat_limit_reached and the user stays soft-deleted — free a seat (delete or hard-delete another active user, or raise the plan) and retry. Other outcomes: the address was reclaimed by a live account → 409; a transient DB conflict or a failure resolving the cap → 503 (retry). Uncapped tenants (enterprise with no seats override) are never blocked.

curl -X POST https://<your-gateway-host>/admin/v1/users/{id}/restore

Disabling and re-enabling a user

Required role: tenant_admin (or admin). The caller must be allowed to manage the target's role (same role hierarchy as updating a user) and must be able to reach the target (same tenant; a tenant_admin cannot act on a platform admin). Both endpoints take an empty body.

Disabling sets disabled_at (a unix-seconds timestamp). A disabled account is blocked, fail-closed, from every authentication and run path — admin session, inference token, email OTP / SSO / SAML / OIDC / Google login, the Mattermost bridge, scheduled and saved-agent runs, and workflow email delivery. Active admin sessions and inference tokens are rejected on their next request (session state is stateless, re-validated against the live account on every request), so access is revoked promptly. The revocation is also permanent and per-device: disabling stamps a per-user revocation floor (sessions_valid_after) that an older admin cookie can never pass, and revokes the user's /v1 API tokens (a subsequent call gets a typed token_revoked 401). Re-enabling restores login but does not clear the floor or restore the tokens — the user signs in afresh and mints new tokens.

Rejected requests (fail-closed at the trust boundary, server-side):

  • disabling your own account → 409;
  • disabling the last active administrator (admin or tenant_admin) of a tenant while other users remain → 409 (promote another administrator first);
  • disabling/enabling a soft-deleted account → 409 (restore it first);
  • a role you may not manage, or a cross-tenant / platform-admin target → 403.

Disabling an already-disabled account (or enabling an already-enabled one) is an idempotent no-op that returns 200. Re-enabling clears disabled_at but not the revocation floor: the sessions and API tokens that were open at the disable stay dead on every device, and the user re-authenticates. This mirrors soft-delete → restore, which behaves the same way.

# Disable (block login; ends every session + API token permanently on every device)
curl -X POST https://<your-gateway-host>/admin/v1/users/{id}/disable

# Re-enable (login restored)
curl -X POST https://<your-gateway-host>/admin/v1/users/{id}/enable

The user's disabled state is exposed as disabled_at on the user object returned by the list and get endpoints, and in the users CSV export Status column (active, disabled, or deleted).

Setting or clearing a static OTP

Required role: platform admin only. Sets a fixed OTP code that the user enters during sign-in instead of the email-delivered OTP. Useful for App Store reviewer accounts and similar fixed credentials. The code length must be 4–32 characters; the value is stored as a SHA-256 hash. To clear the static OTP and revert to email OTP, send {"code": null}.

# Set
curl -X PUT https://<your-gateway-host>/admin/v1/users/{id}/static-otp \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'

# Clear
curl -X PUT https://<your-gateway-host>/admin/v1/users/{id}/static-otp \
  -H "Content-Type: application/json" \
  -d '{"code": null}'

Tokens

Token fields

Field Type Default Description
label string — Human-readable name shown in the admin UI and logs. Accepted: a valid UTF-8 string of at most 255 bytes, or null; a non-string, an oversize value, or invalid UTF-8 bytes is rejected with HTTP 400.
user_id string | null null Associates the token with a user for audit trail and per-user budget tracking (By-User analytics). Accepted: a valid UTF-8 string id of at most 36 bytes naming a user in the gateway's own tenant, or null; a non-string (object, number, boolean), an oversize value, or invalid UTF-8 is rejected with HTTP 400 (user_id must be a string id). The id is scoped to the gateway's tenant, not merely well-formed — a well-formed id that is not a user of this gateway's tenant (whether it does not exist, or belongs to another tenant) is the same HTTP 400 unknown user_id, and no token is created. The two answer identically so the route is not a cross-tenant existence oracle (a caller cannot confirm a UUID is a real user in another tenant). A tenant admin also cannot bind a token to a higher-ranked user even in its own tenant (HTTP 403). The FK is the last-line authority (a user hard-deleted mid-mint → 400 unknown user_id). A transient fault while resolving the id (a DB blip in the tenant-scope lookup) fails closed with HTTP 503 user lookup temporarily unavailable — no token is created, retry — kept distinct from the deterministic 400 so a caller can tell "unknown" from "try again".
agent_id string | null null mints a PER-AGENT token bound to one saved agent (create-only). Accepted: a valid UTF-8 string id of at most 36 bytes naming an agent that exists on this gateway — a non-string / oversize / invalid-UTF-8 value is rejected HTTP 400 agent_id must be a string id, and an id that is not an agent of this gateway is HTTP 400 unknown agent_id for this gateway (the client is never the authz boundary). null/absent = a normal token. See Per-agent tokens below.
scopes array [] Capability scopes (opt-in least-privilege). Recognized capabilities: inference, tools, admin. A token that declares one or more recognized capabilities is enforced fail-closed — e.g. one whose scopes exclude inference is refused at the inference endpoint with HTTP 403. A token with an empty array or only legacy/unknown strings (e.g. ["playground"], ["read","write"]) is unrestricted. Today inference is enforced at the request boundary; see Authentication → Token capability scopes. Accepted shape: absent, null, or a JSON array of at most 50 strings, each 1–64 characters from A-Z a-z 0-9 . _ : / -; absent / null / an empty array or empty object are all stored as []. Rejected with HTTP 400: a string, number, or boolean ("inference", 0, true); an object with keys ({"inference": true} — it would read as no scopes and mint an unrestricted token); an element that is not a string, is empty, contains whitespace or any other character outside the alphabet ("inference ", "'; DROP…", an emoji), or exceeds 64 characters; more than 50 elements. Previously a non-array scopes answered an opaque 500.
expires_at integer | null null Unix expiry timestamp (seconds since epoch). null means the token never expires — the recommended default for a service/scheduler token, which cannot then die silently. Must be a whole number in the future and within 100 years; a non-integer (e.g. an ISO-8601 string), past, or implausibly far-future value (e.g. a milliseconds timestamp) is rejected with HTTP 400. When an expiry is set, the owner is warned in-app and by email 7 days before it lapses (a no-owner service token warns the tenant-admins) — see Agents → Proactive token pre-expiry warning. A scheduler-service token cannot be given an expiry on create: for the agent scheduler's per-gateway service token — a user-less token (user_id null) with no scopes whose label ends -scheduler-service — a finite expires_at on create is rejected with HTTP 400. Such a token gates a customer-facing feature (scheduled agents); a lapse would 401 every scheduled agent on the gateway, so it must never expire. On the edit route the token is now a protected internal service credential: any PATCH on it (including clearing its expiry) answers HTTP 403, and it is hidden from the token list entirely.
rate_limit object | null null Per-token sliding-window limit: {"requests": N, "window_sec": S}. Applied independently of the gateway-level rate limit — a request can be blocked by either. Accepted shape: absent or null (no per-token limit), or a JSON object whose only keys are requests (required, a whole number 1–1 000 000 000) and window_sec (optional, a whole number 1–31 536 000; when absent or null the default 60 is stored, so a token always reads back with both keys). Rejected with HTTP 400: a scalar or string; an array ([], ["x"]); an empty object or any object without requests (a limit with no count was previously stored and silently applied no limit); requests: null (what a form emits for a non-numeric input); any other key (e.g. window, rpm); a leaf below 1, above its cap, fractional, non-numeric, or non-finite (inf/nan) — a window_sec of 0 would divide by zero in the rate-limit hot path. The shape itself is the one shared with the gateway rate_limit: at request time a stored value the gateway cannot use (not an object, or an unusable present leaf) makes the token's requests fail with 500 configuration_error rather than run unlimited — extra keys in an older stored row are ignored, an empty stored column is treated as unset — and the token list then shows rate_limit: null plus rate_limit_malformed naming the reason, so such a token is not mistaken for an unlimited one.
budget_usd number | null null Per-token spend cap in USD. null means no cap. Must be a JSON number that is finite and non-negative; a negative, non-finite (inf — e.g. JSON 1e999 — or nan), or non-numeric value is rejected with HTTP 400 — including a numeric string such as "5" (unlike expires_at, which also accepts a decimal-digit string, a budget is only ever a number).

💡 Note: Tokens are hashed with SHA-256 before storage. The plaintext token value is returned once in the creation response and cannot be retrieved later. If a token is lost, revoke it and create a new one. Beyond the configurable fields above, the list endpoint returns id, user_id, token_hash (not the plaintext value), and created_at (Unix seconds) for each token.

Per-agent tokens

Passing agent_id on token create mints a per-agent token — a token cryptographically bound to one saved agent (auth_token.agent_id), so a scheduled agent's actions are attributable to a distinct verifiable principal instead of a shared human/service token. A per-agent token:

  • may invoke ONLY its bound agent via POST /v1/{tenant}/{gateway}/agents/{slug}/invoke. Invoking a different agent, or driving the agent-less scheduled-task endpoints (/agents/scheduled-task/{id}/...), is rejected 404 AGENT_NOT_FOUND (enumeration-safe, fail-closed);
  • may NOT drive raw inference. A raw inference call on a real provider — POST /v1/{tenant}/{gateway}/{provider}/... where {provider} is anthropic / openai / compat / a model provider (not the agents pseudo-provider) — is rejected 403 FORBIDDEN at the request boundary, before any routing or model call. The token is confined to the agents pseudo-provider (its /invoke and, for the scheduler, its own /deliver-email seam); within that plane the fences above decide (own agent → allowed, a different agent or the scheduled-task plane → 404). Enforced server-side from the token→agent binding, independent of the token's scopes (the client is never the authz boundary);
  • is attributed on every request as request_log.token_agent_id (the bound principal — distinct from agent_id, the invoked agent);
  • dies with its agent — the FK is ON DELETE CASCADE, so hard-deleting the agent removes the token (no orphan credential).

Least-privilege confinement. A per-agent token is confined to its own agent's operational plane — it is not a full inference credential (unlike the shared service token it replaces): it cannot make a raw inference call on any real provider. The confinement derives from the token→agent binding, not from a capability scope: a per-agent token still lists inference in its scopes (scopes and agent-binding are orthogonal axes), but that inference scope is overridden by the binding — the token cannot make a raw inference call regardless of what its scopes display. The scheduler continues to drive its agent's deliver-email with the per-agent token (same-agent, recipient-pinned, PII-gated), so per-agent attribution is preserved on delivery. On an auth_required: false gateway (where an anonymous, token-less request may already infer) the confinement is not applied — a known-but-confined token is never held stricter than presenting no token at all.

Scheduler adoption. The agent scheduler prefers a per-agent token file (~/.config/myra-dev/agent-token-<agent_id>) when an operator provisions one — mint a per-agent token here, then drop it in that file (the same operator model as the per-gateway service token). Absent → the per-gateway service token is used unchanged (zero behaviour change). If a provisioned per-agent token is rejected (401 — token_expired for a lapsed one, token_revoked for one an administrator deleted; see Error codes), the scheduler falls back once to the gateway service token (with a warning) so a bad per-agent credential can't silently kill the agent. The agent Try-it tab and the create-modal Preview show a localized message for each of these codes instead of the raw server text.

Listing gateway tokens

curl https://<your-gateway-host>/admin/v1/gateways/{gateway_id}/tokens

The response is always a JSON array (an empty gateway returns []). The gateway's own internal service credentials are excluded from this list — see below.

Internal service credentials

Some auth_token rows are the gateway's OWN internal operational credentials, not customer-manageable tokens. They are minted user-less (user_id null) under a reserved label convention:

  • the agent scheduler's per-gateway service token — label ends -scheduler-service (empty scopes, no expiry, a spend cap);
  • the chat-bridge loopback invoke token — label starts chat-bridge: (empty scopes, no expiry).

These credentials are hidden from GET /gateways/{id}/tokens, and PATCH or DELETE on one answers 403 { "error": "this token is an internal service credential and cannot be … " } before any change is made. Revoking one would 401 every scheduled agent / the chat-bridge invoke path — a self-inflicted outage — so the tenant admin API refuses. A malformed user_id, or empty/rotated scopes, does not weaken the protection (the check fails closed — it matches on the user-less shape plus the reserved label and deliberately ignores scopes). A DELETE that cannot read the row's identity because of a transient database fault answers 503 rather than deleting anyway. (Internal teardown paths — e.g. removing a chat-bridge connection — revoke these tokens through the storage layer directly, not this HTTP route, so they are unaffected.)

Creating a gateway token

curl -X POST https://<your-gateway-host>/admin/v1/gateways/{gateway_id}/tokens \
  -H "Content-Type: application/json" \
  -d '{
    "label": "CI bot",
    "scopes": ["inference"],
    "expires_at": null,
    "rate_limit": null,
    "budget_usd": null
  }'

expires_at follows the same rule as the other token routes: an optional whole unix-seconds timestamp in the future and within 100 years; a non-integer, past, or milliseconds-magnitude value is rejected with HTTP 400. Every other field is validated by the one shared token-body boundary — the same module the edit route, the user token route and the personal token route use, so no route can drift: label, user_id and agent_id per the Token fields table, scopes as an array of alphabet strings, rate_limit as an object with a required requests, and budget_usd as a finite non-negative number. Every malformed field answers HTTP 400 with an error that names the field, and nothing is persisted; a well-formed body is unchanged. A gateway that was deleted while the mint was in flight answers 404 { "error": "not found" } (the insert waits behind the delete's row lock and is refused once the gateway is gone — never a token on a deleted gateway, and never a misleading unknown user_id); the same mapping applies to the user token, personal token and playground token routes — one shared classifier decides from the constraint the database named. The user-side counterpart differs per route because each route knows what the user was on its wire: on this route user_id is a body field, so an unknown one is 400 unknown user_id; on /users/{user_id}/tokens the user is the path resource, so a user hard-deleted while the mint was in flight is that route's 404 { "error": "user not found" }; on /me/tokens and /playground/token the user is the session itself, so the same race is the masked 500 (logged) — never a user_id message about a field the caller did not send. Should the insert still fail for a reason the validators cannot foresee, the response is 500 {"error":"internal error"} — the raw database message is logged server-side, never returned.

Response:

{
  "id": "tok_def456",
  "token": "myra_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "gateway_id": "gw_xyz789"
}

Creating a gateway token with rate limit and budget

curl -X POST https://<your-gateway-host>/admin/v1/gateways/{gateway_id}/tokens \
  -H "Content-Type: application/json" \
  -d '{
    "label": "production-client",
    "user_id": "usr_abc123",
    "scopes": ["inference"],
    "expires_at": 1798761600,
    "rate_limit": {"requests": 60, "window_sec": 60},
    "budget_usd": 100.00
  }'

Creating a user token

User tokens work identically to gateway tokens but are listed under the user and can be managed via the user endpoint. gateway_id is required — it specifies which gateway the token grants access to.

curl -X POST https://<your-gateway-host>/admin/v1/users/{user_id}/tokens \
  -H "Content-Type: application/json" \
  -d '{
    "gateway_id": "gw_xyz789",
    "label": "personal dev token",
    "scopes": ["inference"],
    "budget_usd": 10.00
  }'

Editing a token

The three operational controls of an existing gateway token — rate_limit, budget_usd (spend cap), and expires_at — can be changed in place, without revoking and re-minting the token (so the plaintext secret already deployed to a client keeps working). The token's identity fields (token_hash, scopes, user_id, label) are set at mint time and are not editable here.

curl -X PATCH https://<your-gateway-host>/admin/v1/gateways/{gateway_id}/tokens/{token_id} \
  -H "Content-Type: application/json" \
  -d '{
    "budget_usd": 250.00,
    "rate_limit": {"requests": 120, "window_sec": 60},
    "expires_at": 1798761600
  }'

Authorization: same as creating or revoking a token — the caller must be a tenant_admin of the gateway's tenant (or platform admin). A member / cross-tenant caller is rejected; the SPA hides the control from non-admins, but the backend is the authoritative boundary.

Partial semantics. Only the fields present in the body are changed; an omitted field is left untouched. A field sent as JSON null clears that control — "budget_usd": null removes the spend cap, "expires_at": null makes the token non-expiring, "rate_limit": null removes the per-token limit. At least one of rate_limit / budget_usd / expires_at must be present.

Accepted / rejected (validated at the trust boundary — fail closed, nothing persisted on rejection):

Input Result
budget_usd a finite non-negative number, or null Accepted (sets / clears the cap)
budget_usd negative, non-numeric, or non-finite (inf — e.g. JSON 1e999 — / nan) 400
expires_at a whole future unix-seconds timestamp within 100 years, or null Accepted (same rule as create)
expires_at a non-integer (e.g. ISO-8601), past, or milliseconds-magnitude value 400
any PATCH on an internal service credential (user-less -scheduler-service / chat-bridge: token) 403 — the credential is gateway-internal and un-editable; even expires_at: null is refused
rate_limit a JSON object with a required whole-number requests (1–1 000 000 000) and an optional whole-number window_sec (1–31 536 000, default 60), or null Accepted — stored as {"requests": N, "window_sec": S} with the default filled in
rate_limit a scalar / string, an array, an empty object or one without requests, requests: null, any key other than requests / window_sec, or a leaf below 1, above its cap, fractional, non-numeric, or non-finite 400
any field other than rate_limit / budget_usd / expires_at 400 { "error": "unknown field: ..." }
empty body (no updatable field) 400
a token_id that is not a token of this gateway 404 { "error": "token not found" }

Response: 200 { "ok": true }. The updated controls take effect immediately — the cached token authorization is invalidated on update, so a lowered spend cap or rate limit applies to the very next request rather than after a cache-expiry delay.

Reading the controls back. To confirm a change, re-list the gateway's tokens with GET /admin/v1/gateways/{gateway_id}/tokens. In that response each token's rate_limit is a JSON object { "requests": …, "window_sec": … } (or null when the token has no per-token limit) — the same object shape you send on create/edit; budget_usd and expires_at come back as numbers. A stored rate_limit that cannot be parsed or used reads back as null rather than erroring the listing, with a rate_limit_malformed string naming the reason (such a token refuses every request until it is corrected).

Revoking a token

curl -X DELETE https://<your-gateway-host>/admin/v1/gateways/{gateway_id}/tokens/{token_id}

Response: 200 { "ok": true }; 404 { "error": "token not found" } for a token_id that is not (or no longer) a token of this gateway — a second DELETE of an already-revoked id is a 404, not a silent success. An internal service credential answers 403 and is never revoked; a transient database fault while checking that answers 503 (never a delete-anyway).

The token row is deleted and a revocation tombstone (its hash, its gateway, the time) is written in the same transaction, so any subsequent inference request using the revoked token on this gateway returns 401 token_revoked with the revocation date — never the generic "verify the {gateway} segment" hint. Only an explicit revocation leaves a tombstone: a token that disappears together with its agent, user or gateway answers the generic 401 unauthorized. On an auth_required: false gateway a revoked token is silently dropped and the request proceeds anonymously, consistent with the rule that a known-but-refused token is never held stricter than presenting no token at all.

How immediate. On the site that processed the revocation the cached authorization is dropped right after the commit, so the next request there is refused. A request that read the live row just before the commit, or the other production site (active-active, per-site cache), can still be served for at most one authorization-cache TTL (5 minutes) — see Token security model.


Self-service tokens (/me/tokens)

These endpoints let users create and manage tokens for themselves without needing an admin to act on their behalf. Listing (GET) and revoking (DELETE) own tokens are available to any authenticated user regardless of role. Creating a token (POST /me/tokens), however, requires an authoring role (member, ki_manager, tenant_admin, or admin); a viewer or demouser gets 403, because such a user is read-only and blocked from inference, so a minted token would be inert.

💡 Note: Creating a token via /me/tokens defaults scopes to ["inference"] when the scopes field is absent or null. An explicit [] is stored as [] (unrestricted) — it is not silently narrowed. Every field of this body is validated by the same shared boundary as the gateway token route: a malformed scopes, label, rate_limit, budget_usd or expires_at answers HTTP 400 naming the field and mints nothing.

Listing own tokens

curl https://<your-gateway-host>/admin/v1/me/tokens \
  -H "Cookie: aig_admin=<session>"

Response: array of token objects.

Creating own token

curl -X POST https://<your-gateway-host>/admin/v1/me/tokens \
  -H "Cookie: aig_admin=<session>" \
  -H "Content-Type: application/json" \
  -d '{
    "gateway_id": "gw_xyz789",
    "label": "my laptop",
    "expires_at": null,
    "budget_usd": null,
    "rate_limit": null
  }'
Field Type Required Description
gateway_id string Yes Gateway the token grants access to. Must be accessible to the tenant of the caller.
label string No Human-readable name — a valid UTF-8 string of at most 255 bytes; anything else is HTTP 400.
scopes array No Capability scopes, same rule as the Token fields table (array of alphabet strings, ≤ 50). Absent or null → ["inference"]; a string / number / object-with-keys is HTTP 400.
expires_at integer | null No Unix expiry timestamp (seconds since epoch). null = never. Must be a whole future value within 100 years; a non-integer (e.g. an ISO-8601 string), past, or milliseconds-magnitude value is rejected with HTTP 400.
budget_usd number | null No Per-token spend cap in USD — a finite non-negative JSON number; a string (even "5"), object, or negative value is HTTP 400.
rate_limit object | null No {"requests": N, "window_sec": S} — requests required, window_sec optional (default 60); a scalar, array, empty object, requests: null, or unknown key is HTTP 400.

Response:

{
  "id": "tok_abc123",
  "token": "myra_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "gateway_id": "gw_xyz789"
}

Revoking own token

curl -X DELETE https://<your-gateway-host>/admin/v1/me/tokens/{token_id} \
  -H "Cookie: aig_admin=<session>"

Response: 200 { "ok": true }. Returns 403 if the token does not belong to the caller — including an id the caller already revoked (it is no longer in the caller's list, so the ownership check refuses it before anything else); 404 { "error": "token not found" } is possible only if the token vanishes between that check and the delete. The revocation itself works exactly like the gateway route above: row deleted and tombstone written in one transaction, the token's next use on its gateway answers 401 token_revoked, and the cache guarantee is the same.


User types

Listing user types

Returns the registry of available chat-persona roles ("user types"). These are the options for a tenant's default_user_type setting; they are static metadata, not tenant-scoped data. Available to any authenticated admin user.

curl "https://<your-gateway-host>/admin/v1/user-types"

Response: array of persona entries. Returns 401 { "error": "unauthenticated" } if the caller is not authenticated.

Field Type Description
slug string Persona identifier used as default_user_type (e.g. consumer, bundestag).
label_key string i18n key for the display label (e.g. userType.consumer).
[
  { "slug": "consumer",  "label_key": "userType.consumer" },
  { "slug": "bundestag", "label_key": "userType.bundestag" }
]

See also