Skip to main content

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; limit accepted as an alias) and page_token; responses carry next_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 admin on 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 except GET /v1/auth/me and POST /v1/auth/change-password.

Authentication and session

MethodPathAuth
POST/v1/auth/loginnone (rate-limited)
GET/v1/auth/mesession (works pre-password-change)
POST/v1/auth/change-passwordsession (works pre-password-change)
GET/healthznone

POST /v1/auth/login

FieldTypeRequired
emailstringyes
passwordstringyes

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 a Retry-After header (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)

MethodPathAuth
GET/v1/usersplatform admin
POST/v1/usersplatform 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-passwordplatform admin

GET /v1/users

Response: 200 — {"users": [...], "unowned_agents": [...], "next_page_token": "..."}

Each user:

FieldTypeMeaning
idUUIDAccount ID.
emailstringSign-in address.
rolestringadmin | user (platform role).
must_change_passwordboolTrue until the first-login password change.
org_idUUIDHome organization; absent for external users.
externalbooltrue = no home org (joined by invitation).
created_atstringRFC3339.
projectsstring[]Project slugs.
agentsstring[]Agent names — scoped to the caller's resource grants; a platform admin sees accounts, not tenant workload names.
agent_countintCount of the above.

unowned_agents lists agents deployed before ownership existed or by the automation token.

POST /v1/users

FieldTypeRequiredDefault
emailstringyes
rolestringnouser (admin | user)
passwordstringnoa temporary password is generated
org_idUUIDnothe 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.

MethodPathPurpose
GET/v1/orgsList organizations
POST/v1/orgsCreate
DELETE/v1/orgs/{orgID}Delete an empty org
GET/v1/orgs/{orgID}/membersList org members
POST/v1/orgs/{orgID}/membersAdd 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 is default)
  • 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

MethodPathAuth
GET/v1/projectsany session
POST/v1/projectsorg 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):

FieldTypeMeaning
idUUIDProject ID.
org_idUUIDOwning organization.
slugstringRenameable handle.
namestringDisplay name.
shortstringImmutable ID fragment used in the namespace and hostnames — survives renames.
namespacestringcai-p-<short>. Immutable.
created_atstringRFC3339.
your_rolestringYour contents (resource) authority: admin | member | absent.
your_metadata_rolestringYour 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

MethodPathAuth
GET/v1/projects/{projectID}/membersvisible
POST/v1/projects/{projectID}/membersmetadata 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.

FieldTypeRequiredDefaultRule
reasonstringyes≥ 8 characters; shown verbatim to the project's members.
rolestringnoadmin
minutesintno240 (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

MethodPathAuth
GET/v1/projects/{projectID}/invitationsvisible
POST/v1/projects/{projectID}/invitationsmetadata admin
DELETE/v1/projects/{projectID}/invitations/{invitationID}metadata admin
GET/v1/invitations/{token}unauthenticated (the token is the credential)
POST/v1/invitations/acceptunauthenticated, 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."}
MethodPathPurpose
GET/v1/users/me/keysList your keys — {"keys": [...]}
POST/v1/users/me/keysCreate — 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.

MethodPathAuth
GET/v1/projects/{projectID}/service-accountsmember
POST/v1/projects/{projectID}/service-accountsresource admin
DELETE/v1/projects/{projectID}/service-accounts/{sa}resource admin
GET/v1/projects/{projectID}/service-accounts/{sa}/keysmember
POST/v1/projects/{projectID}/service-accounts/{sa}/keysresource 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

FieldTypeRequiredDefaultNotes
namestringyesDNS-1035 label. Reserved permanently, even after deletion.
display_namestringnodescription accepted as an alias; display_name wins.
rolestringnomemberProject 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"}.

Machine credentials cannot mint credentials

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.

Alpha

The audit trail has no export, no retention policy, and no date-range filter yet. See Break-glass and audit.

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)

MethodPathAuth
GET/v1/profileany session
PUT/v1/profile/model-keyplatform admin
DELETE/v1/profile/model-keyplatform 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.