Platform API
This page documents the account layer of agent-engine-api: signing in, managing users, organizations, projects and their members, invitations, break-glass, API keys, service accounts, quotas, the audit log, search, and the platform model key. Set $CAI_API first — see the API overview.
Conventions
- Errors use the standard envelope
{"error": "<message>", "request_id": "<id>"}. Request bodies are JSON and most are capped at 8 KiB. - Pagination on every list:
page_size(default 50, max 200;limitaccepted as an alias) andpage_token; responses carrynext_page_token. - The 404 rule: any request against a project you hold no grant on returns 404
not found— never 403 — so project existence is not discoverable. - Two axes of authority (the platform's IAM model): authority over the project object (rename, delete, membership — the metadata axis) is separate from authority over its contents (agents, secrets — the resource axis). A platform admin holds metadata authority everywhere and resource authority nowhere: provider staff cannot read tenant data. The only way in is a visible, expiring, audited break-glass grant. See Projects and access.
- Auth gates used below: platform admin (runs the whole platform), org admin (administers one organization; inherits
adminon every project in it, resolved live per request), metadata admin (admin on the project object), resource admin (admin over project contents), member (any project grant), visible (any grant on either axis), session (any signed-in principal). - A user who still owes a first-login password change gets 403 —
password change required before using this API— on every management route exceptGET /v1/auth/meandPOST /v1/auth/change-password.
Authentication and session
| Method | Path | Auth |
|---|---|---|
| POST | /v1/auth/login | none (rate-limited) |
| GET | /v1/auth/me | session (works pre-password-change) |
| POST | /v1/auth/change-password | session (works pre-password-change) |
| GET | /healthz | none |
POST /v1/auth/login
| Field | Type | Required |
|---|---|---|
email | string | yes |
password | string | yes |
Response: 200
{"token": "...", "email": "...", "role": "admin", "must_change_password": false, "expires_at": 1765480000}
role is admin or user (the platform-level role). expires_at is Unix seconds. Token lifetime: 12 hours, HMAC-signed, stateless, and not revocable before expiry.
Errors:
- 400 — invalid JSON
- 401 —
invalid email or password(one message for both "no such user" and "wrong password" — no account-existence oracle) - 429 —
too many sign-in attempts; try again in <duration>with aRetry-Afterheader (throttle: 10 attempts per IP and 50 per account, per 15 minutes) - 503 —
user management is not initialized
GET /v1/auth/me
Response: 200 — {"email", "role", "must_change_password", "expires_at"}. The role is the effective role re-read from the store, not the token's claim — so a demotion shows up immediately.
POST /v1/auth/change-password
Body: {"current_password", "new_password"}.
Response: 200 — same shape as login. A fresh token is minted (the old must-change token would keep locking you out).
Errors:
- 401 —
current password is incorrect - 400 —
password must be at least 12 characters - 400 —
new password must differ from the current one
Password rule everywhere: minimum 12 characters.
Users (platform administration)
| Method | Path | Auth |
|---|---|---|
| GET | /v1/users | platform admin |
| POST | /v1/users | platform admin, org admin, or project admin (their own org only) |
| PATCH | /v1/users/{email} | platform admin |
| DELETE | /v1/users/{email} | platform admin |
| POST | /v1/users/{email}/reset-password | platform admin |
GET /v1/users
Response: 200 — {"users": [...], "unowned_agents": [...], "next_page_token": "..."}
Each user:
| Field | Type | Meaning |
|---|---|---|
id | UUID | Account ID. |
email | string | Sign-in address. |
role | string | admin | user (platform role). |
must_change_password | bool | True until the first-login password change. |
org_id | UUID | Home organization; absent for external users. |
external | bool | true = no home org (joined by invitation). |
created_at | string | RFC3339. |
projects | string[] | Project slugs. |
agents | string[] | Agent names — scoped to the caller's resource grants; a platform admin sees accounts, not tenant workload names. |
agent_count | int | Count of the above. |
unowned_agents lists agents deployed before ownership existed or by the automation token.
POST /v1/users
| Field | Type | Required | Default |
|---|---|---|---|
email | string | yes | — |
role | string | no | user (admin | user) |
password | string | no | a temporary password is generated |
org_id | UUID | no | the creator's own org |
Response: 201 — {"user": {...}, "temporary_password": "..." (only when password was omitted), "note": "shown once - the user must change it at first login"}. Admin-created accounts always start with must_change_password: true.
Org placement: an explicit org_id requires being a platform admin, an admin of that org, or (for a project admin) it must be their own home org.
Errors:
- 409 —
an account already exists for that email address. Do not create a second one: add the existing account to the project instead (POST /v1/projects/{projectID}/members with that email) - 403 —
only a platform admin can create a platform admin - create the account with "role":"user" and ask a platform admin to promote it - 403 —
creating an account in that organization requires administering it - 403 —
creating an account requires administering an organization or a project - 400 —
org_id is not a valid id; bad email; weak password;role must be "admin" or "user"
PATCH /v1/users/{email}
Body: {"role": "admin"} or {"role": "user"}. Role changes are effective immediately — authorization re-reads the store on every request.
Errors:
- 400 —
nothing to update: send {"role":"admin"} or {"role":"user"} (to change a password use POST /v1/users/{email}/reset-password)(sent for an empty body) - 400 —
cannot remove your own admin role - ask another admin to do it - 409 —
cannot demote the last admin - promote another account first - 404 —
user not found
DELETE /v1/users/{email}
Response: 200 — {"deleted": "<email>", "note": "project grants were removed with the account; any agents they deployed keep running"}
Errors: 400 — cannot delete the account you are signed in as; 409 — last-admin guard (as above).
POST /v1/users/{email}/reset-password
Response: 200 — {"email", "temporary_password", "note": "shown once - the user must change it at next login"}
Organizations and org members
All org routes are platform admin only.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/orgs | List organizations |
| POST | /v1/orgs | Create |
| DELETE | /v1/orgs/{orgID} | Delete an empty org |
| GET | /v1/orgs/{orgID}/members | List org members |
| POST | /v1/orgs/{orgID}/members | Add a member |
| PATCH | /v1/orgs/{orgID}/members/{userID} | Change a member's role |
| DELETE | /v1/orgs/{orgID}/members/{userID} | Remove a member |
GET /v1/orgs
Response: 200 — {"orgs": [{"id", "slug", "name", "created_at", "projects": 3, "users": 12}], "next_page_token": "..."}
POST /v1/orgs
Body: {"slug", "name"} (slug is lowercased and trimmed). Response: 201 — {"org": {...}}
DELETE /v1/orgs/{orgID}
Response: 200 — {"deleted": true, "org": "<slug>"}
Errors:
- 403 —
the default organization cannot be deleted(the default org's slug isdefault) - 409 —
organization still has projects - delete them first - 409 —
organization still has users - move or remove them first - 400 —
invalid orgID: must be a UUID
Org members
Member shape: {"user_id", "email", "role": "admin" | "member", "created_at"}.
POST /v1/orgs/{orgID}/members — body {"email", "role"} (role defaults to member). Adding is add-only:
- Already a member with the same role → 200 —
already a member of this organization with this role - Already a member with a different role → 409 —
<email> is already a member of this organization with role "<r>"; use PATCH /v1/orgs/{orgID}/members/{userID} to change it - New member → 201 —
{"member": {...}, "note": "org admins inherit the admin role on every project in this org, immediately"}(the note appears only for the admin role) - 404 —
no account with that email address
PATCH /v1/orgs/{orgID}/members/{userID} — body {"role": "admin" | "member"} → 200 — {"member": {...}, "note": "effective immediately - inherited project access is re-resolved on every request"}. 409 — this is the organization's only admin - appoint another before demoting them.
DELETE /v1/orgs/{orgID}/members/{userID} → 200 — {"deleted": true, "user_id", "note": "effective immediately ..."}. The last-admin guard also applies (...before removing them).
Org admins inherit the admin role on every project in their org. This is resolved live on each request — no membership rows are written.
Projects
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects | any session |
| POST | /v1/projects | org admin of the target org, or platform admin |
| GET | /v1/projects/{projectID} | visible (either axis) |
| PATCH | /v1/projects/{projectID} | metadata admin |
| DELETE | /v1/projects/{projectID} | metadata admin |
The project view (returned by every project response):
| Field | Type | Meaning |
|---|---|---|
id | UUID | Project ID. |
org_id | UUID | Owning organization. |
slug | string | Renameable handle. |
name | string | Display name. |
short | string | Immutable ID fragment used in the namespace and hostnames — survives renames. |
namespace | string | cai-p-<short>. Immutable. |
created_at | string | RFC3339. |
your_role | string | Your contents (resource) authority: admin | member | absent. |
your_metadata_role | string | Your object (metadata) authority: admin | member | absent. |
A platform admin gets your_metadata_role: "admin" and an empty your_role — the console hides content pages from that signal.
GET /v1/projects
Response: 200 — {"projects": [...], "next_page_token": "..."} — the union of: everything (platform admin), your direct grants, and org-admin-inherited projects. Sorted by slug.
POST /v1/projects
Body: {"slug", "name", "org_id"} (org_id optional; defaults to your home org).
Response: 201 — {"project": {...}}, sometimes with "warning" and/or "note". Creation provisions the namespace, quota, network policy, model credential, project record, and a Valkey ACL user — each step is non-fatal and surfaced via "warning" if it fails.
A platform admin creating a project outside their own org does not become a member and gets: "note": "you created this project as a platform admin, so you administer it but hold nothing inside it. Add an administrator from the owning organization (POST /v1/projects/<id>/members)."
Errors:
- 403 —
creating a project requires the org admin role - 403 —
you have no home organization - ask a platform admin to create the project, or to add you to one - 400 —
org_id is not a valid id - 409 — slug conflicts
PATCH /v1/projects/{projectID}
Body: {"slug"?, "name"?}.
Response: 200 — {"project": {...}, "note": "the project's short id and namespace are unchanged, so every resource path, hostname and running workload is unaffected"} (a no-op returns "note": "nothing changed").
Errors: 409 — that slug is reserved for the default project
Renaming never changes short or namespace — those are immutable.
DELETE /v1/projects/{projectID}
Response: 200 — {"deleted": true, "project": "<slug>"}
Errors:
- 403 —
the default project cannot be deleted - 409 —
project still has agents - delete them first (this is deliberate: deleting a project would destroy their code and logs)
Project members
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/members | visible |
| POST | /v1/projects/{projectID}/members | metadata admin |
| PATCH | /v1/projects/{projectID}/members/{userID} | metadata admin |
| DELETE | /v1/projects/{projectID}/members/{userID} | metadata admin |
| PATCH | /v1/projects/{projectID}/members/service-accounts/{saID} | metadata admin |
| DELETE | /v1/projects/{projectID}/members/service-accounts/{saID} | metadata admin |
Member view: {"user_id", "email", "role": "admin" | "member", "kind": "user" | "service_account", "service_account_id"?, "external": bool, "created_at"} — break-glass rows additionally carry "break_glass": true, "expires_at", "reason", "granted_by" and, after expiry, "expired": true (they stay listed as history).
POST /v1/projects/{projectID}/members
Body: {"email", "role"} (role defaults to member). The email is looked up first; the role is granted to the existing account.
Response: 201 — {"member": {...}, "note": "granted to the existing account for this address; no new account was created"}
Errors:
- 404 —
no account exists for <email> - invite them instead (POST /v1/projects/<id>/invitations), which lets them choose their own password when they accept - 400 —
a valid email address is required;role must be "admin" or "member"
PATCH /v1/projects/{projectID}/members/{userID}
Body: {"role"} → 200 — {"user_id", "role"}.
Errors: demoting or removing the last real admin → 409 — this is the project's only admin - promote someone else first. Break-glass, expired, and service-account admins do not count toward this guard.
Service-account member routes
PATCH .../members/service-accounts/{saID} — body {"role"} → 200 — {"service_account_id", "role"}. DELETE removes only the grant; the account and its keys live on.
Break-glass
POST /v1/projects/{projectID}/break-glass
Platform admin only, human credentials only (machine credentials are refused). A break-glass grant is a self-granted, reason-required, expiring membership row — visible to the project's members and recorded in its audit log. See Break-glass and audit.
| Field | Type | Required | Default | Rule |
|---|---|---|---|---|
reason | string | yes | — | ≥ 8 characters; shown verbatim to the project's members. |
role | string | no | admin | — |
minutes | int | no | 240 (4 hours) | Max 1440 (24 hours). |
Response: 201
{"grant": {"...": "...", "break_glass": true, "expires_at": "...", "reason": "...", "granted_by": "..."},
"note": "this grant is visible to the project's members and recorded in its audit log. It expires on its own; remove it sooner with DELETE /v1/projects/<id>/members/<userId>"}
Errors:
- 400 —
a reason is required and is shown to the project's members verbatim - say what you are fixing and, if there is one, name the ticket - 400 —
a break-glass grant may last at most 24h0m0s - take a fresh one, with a fresh reason, if the work outlives it - 403 —
break-glass access is for platform admins repairing a project they are not a member of; you already hold a grant here, or you hold none to escalate from
Invitations
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/invitations | visible |
| POST | /v1/projects/{projectID}/invitations | metadata admin |
| DELETE | /v1/projects/{projectID}/invitations/{invitationID} | metadata admin |
| GET | /v1/invitations/{token} | unauthenticated (the token is the credential) |
| POST | /v1/invitations/accept | unauthenticated, login-rate-limited |
POST /v1/projects/{projectID}/invitations
Body: {"email", "role"}.
If the address already has an account, the role is granted directly: 201 with a "member" object and "note": "an account already existed for this address, so the role was granted to it directly - no invitation was sent and no second account was created" — or 409 — that user is already a member of this project.
Otherwise: 201
{"invitation": {"id": "...", "email": "...", "role": "member", "state": "open",
"created_at": "...", "expires_at": "...",
"accept_url": "/invite/<token>", "token": "..."},
"note": "the token is shown once - send the accept link to the invitee now, it cannot be retrieved later"}
Invitation states: open | accepted | revoked | expired.
GET /v1/invitations/{token}
Response: 200 — {"project_name", "project_slug", "email", "role", "expires_at", "account_exists": bool}
Errors:
- 404 —
this invitation link is not valid - 410 —
this invitation has already been used, revoked, or expired
POST /v1/invitations/accept
Body: {"token", "password"} — password is required only when no account exists (≥ 12 characters, else 400 — choose a password of at least 12 characters to create your account). Accepting never changes an existing account's password.
Response: 200 — {"accepted": true, "project_id", "role", "email", "note": "sign in with this email to use the project"}
Invited outsiders get no home organization — their account shows external: true.
Personal API keys
Any signed-in user manages their own keys. Key format: cai_<keyid>_<secret> — only a hash is stored. See Service accounts and API keys.
Key view: {"id", "key_id", "display_name", "kind", "created_at", "expires_at"?, "last_used_at"?, "revoked_at"?, "live": bool} — there is structurally no field a secret could travel in. The full secret exists in exactly one response, at creation:
{"key": {"...": "..."}, "secret": "cai_...",
"note": "copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another."}
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/users/me/keys | List your keys — {"keys": [...]} |
| POST | /v1/users/me/keys | Create — body {"display_name", "expires_in_days"} → 201 with the one-time secret |
| DELETE | /v1/users/me/keys/{keyID} | Revoke — {"revoked": "<key_id>", "note": "effective immediately - the next request presenting this key is refused"} |
expires_in_days: 0 or absent = never expires; otherwise 1–3650 — else 400 — expires_in_days must be between 1 and 3650, or 0 for a key that does not expire.
Deleting someone else's key ID returns 404 (anti-enumeration). A personal key acts as you, re-resolved per request: lose a grant and the key loses it on its next call.
Service accounts and keys
A service account is a machine principal that belongs to one project, with the derived, immutable email <name>@<project-short>.cai.local. Listing requires project membership; create, key management, and delete require the project resource admin role.
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/service-accounts | member |
| POST | /v1/projects/{projectID}/service-accounts | resource admin |
| DELETE | /v1/projects/{projectID}/service-accounts/{sa} | resource admin |
| GET | /v1/projects/{projectID}/service-accounts/{sa}/keys | member |
| POST | /v1/projects/{projectID}/service-accounts/{sa}/keys | resource admin |
| DELETE | /v1/projects/{projectID}/service-accounts/{sa}/keys/{keyID} | resource admin |
GET /v1/projects/{projectID}/service-accounts
Response: 200 — {"service_accounts": [{"id", "project_id", "name", "email", "display_name", "role"?, "disabled": bool, "created_at"}], "next_page_token"?}
POST /v1/projects/{projectID}/service-accounts
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | — | DNS-1035 label. Reserved permanently, even after deletion. |
display_name | string | no | — | description accepted as an alias; display_name wins. |
role | string | no | member | Project role the account holds. |
Response: 201 — {"service_account": {...}, "note": "create a key for it at POST <path>/<name>/keys"}
Errors: 409 — that name is taken. Service account names are reserved permanently, including after deletion, so that a new principal can never inherit an old one's grants and audit history
DELETE /v1/projects/{projectID}/service-accounts/{sa}
Soft delete. Response: 200 — {"deleted": true, "service_account": "<email>", "note": "every key it held was revoked in the same transaction; the name stays reserved"}
Service-account keys
Same body and one-time-secret response as personal keys. Keys list newest first.
Errors: 409 — this service account is disabled - a key issued for it would not authenticate. A key belonging to a different service account returns 404.
Role management lives on the members surface: PATCH /v1/projects/{projectID}/members/service-accounts/{saID} with {"role"}.
A service-account key can never create or manage credentials, accounts, or org/project membership. It gets 403 — a service account cannot create or manage credentials. Sign in as a user (or use your own API key) to issue keys - otherwise a leaked key could mint replacements and revoking it would achieve nothing. Its authority is its own project's, on the resource axis only.
There is also an internal workload key kind (cai_wl_..., minted automatically at deploy): its only allowed call is minting a read token for its own project's secrets. On any management route it gets 403 — this credential is a deployed workload's key; it can only mint a read token for its own project's secrets, not use management endpoints.
Quota
GET /v1/projects/{projectID}/quota
Any project member. Read live from the cluster's own enforcement — this API is read-only; no endpoint raises a cap.
Response: 200
{"project": "<slug>", "namespace": "cai-p-<short>",
"quotas": [{"resource": "pods", "used": "3", "hard": "50", "used_pct": 6,
"display": "Running instances: 3 / 50 (6%)"}],
"note": "Live usage for this project's limits. The percentage is how much of each limit is in use; a limit of 0 means that resource is unlimited."}
quotas is [], never null. used_pct is 0 when hard is 0 or lower (unlimited). Friendly labels: pods → "Running instances", services → "Services", requests.cpu → "CPU (reserved)", requests.memory → "Memory (reserved)", limits.cpu → "CPU (max)", limits.memory → "Memory (max)", count/deployments.apps → "Workloads", persistentvolumeclaims → "Storage volumes".
Defaults written at project creation are listed in Platform limits. See also Quotas and audit.
Audit log
GET /v1/projects/{projectID}/audit
Readable by any project member. Append-only, newest first, cursor-paged.
Response: 200 — {"entries": [{"ts", "actor", "action", "target", "detail"}], "next_page_token"?}. An empty actor means the platform itself acted.
Every action value the platform emits:
agent.config, agent.delete, agent.deploy, agent.embed.delete, agent.embed.rotate-key, agent.embed.update, agent.file.delete, agent.file.write, agent.redeploy, agent.set-traffic, apikey.create, apikey.revoke, invitation.accept, mcpserver.create, mcpserver.delete, mcpserver.tool.publish, mcpserver.tool.delete, mcpserver.version.rollback, mcpserver.version.yank, mcpserver.version.unyank, org.create, org.delete, org.member.add, org.member.remove, org.member.role, project.break-glass, project.create, project.delete, project.rename, project.invite, project.invite.revoke, project.member.add, project.member.remove, project.member.role, project.member.sa.role, project.member.sa.remove, project.crusoe-cloud.map, project.crusoe-cloud.unmap, project.crusoe-cloud.bucket.create, project.crusoe-cloud.repository.create, project.secret.write, project.secret.delete, project.secret.reveal, project.secret.issue-token, project.secret.apply, project.secret.bind, project.secret.unbind, serviceaccount.create, serviceaccount.delete, serviceaccount.key.create, serviceaccount.key.revoke, user.create, user.delete, user.role, user.reset-password, valkey-acl.backfill.
Audit writes never fail the request they record.
The audit trail has no export, no retention policy, and no date-range filter yet. See Break-glass and audit.
Search
GET /v1/search
Any session. Query params: q (substring) and optional project.
Case-insensitive substring search across your visible projects. Contents (agents, secrets, and so on) match only where you hold resource authority; secrets are matched by name only.
Response: 200
{"results": [{"kind": "agent", "name": "...", "project": "...", "project_short": "...",
"route": "...", "subtitle": "..."}],
"next_page_token": "..."}
kind is one of agent, function, service, mcp_server, service_account, secret, project.
Profile (platform model key)
| Method | Path | Auth |
|---|---|---|
| GET | /v1/profile | any session |
| PUT | /v1/profile/model-key | platform admin |
| DELETE | /v1/profile/model-key | platform admin |
GET /v1/profile
Response: 200 — {"model_key_configured": bool, "secret": "<secret name>", "namespace": "cai-agents", "scope": "platform-wide (shared by every user; per-user model keys are not implemented yet)"}
PUT /v1/profile/model-key
Body: {"api_key"}. Sets the platform-wide default model key. Rotation lands on each agent's next cold start; the response may carry a "warning" naming project namespaces that did not take the resync. DELETE removes it. A per-agent MODEL_API_KEY secret overrides this default — see the Agents API.
There is also one operator-only route, POST /v1/admin/valkey-acl/backfill (platform admin) — an internal backfill tool you will not need day to day.
Related pages
- API authentication · Projects and access · Service accounts and API keys · Quotas and audit
- Platform limits — token lifetimes, password rules, and quota defaults in one table.