Skip to main content

Agents API

This page documents every agent endpoint the platform serves: the full request and response shapes, auth gates, status codes, and the exact error messages the API returns. Set $CAI_API first — see the API overview.

Functions share this surface: deploying with framework=function creates a function, and every other route here works on it. See Functions overview.

Conventions

  • Errors use the standard envelope {"error": "<message>", "request_id": "<id>"}.
  • Project pinning: every route accepts an optional ?project=<slug|short|id> query. Agents are addressed by name, and the same name may exist in several projects.
  • Agent names must match ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ (a lowercase DNS label, max 63 characters). A bad name is 400 — invalid agent name (must be a lowercase DNS label).
  • Session IDs in paths must match ^[A-Za-z0-9_.-]{1,128}$ (400 — invalid session id). User IDs must match ^[A-Za-z0-9_.:@-]{1,128}$.
  • Auth gates used below:
    • owner — a project admin, or the user who deployed the agent. No grant on the project → 404 not found. Someone else's agent → 404 unknown agent: <name> (existence is never confirmed). An agent with no recorded owner → 403 — this agent has no recorded owner, so only a project admin can manage it (a project admin can adopt it by redeploying it).
    • open — no credential needed by default; your administrator can require one with INVOKE_AUTH_REQUIRED=true.

Endpoints at a glance

MethodPathAuthPurpose
POST/v1/agentsany credentialDeploy (create or redeploy) from a tarball
GET/v1/agentsany credentialList agents and functions
GET/v1/agents/{name}ownerGet one agent
DELETE/v1/agents/{name}ownerDelete an agent
POST/v1/agents/{name}/redeployownerRebuild from stored source
GET/v1/agents/{name}/logsownerLogs from the running instance (text/plain)
GET/v1/agents/{name}/logs/historyownerPersisted log history
GET/v1/agents/{name}/secretsownerList secret key names
PUT/v1/agents/{name}/secretsownerReplace all secrets
PATCH/v1/agents/{name}/secretsownerMerge/remove secrets
GET/v1/agents/{name}/envownerRead env vars (names and values)
PATCH/v1/agents/{name}/envownerMerge/remove env vars
GET/v1/agents/{name}/filesownerList stored source files
GET/v1/agents/{name}/files/{path...}ownerRead one file
PUT/v1/agents/{name}/files/{path...}ownerWrite one file
DELETE/v1/agents/{name}/files/{path...}ownerDelete one file
GET/v1/agents/{name}/revisionsownerList revisions with traffic share
POST/v1/agents/{name}/set-trafficownerReplace the traffic split
GET/v1/agents/{name}/configownerRead compute config
PATCH/v1/agents/{name}/configownerUpdate compute config (new revision)
GET/v1/agents/{name}/memory/policyownerRead the memory policy (off / auto / explicit)
PATCH/v1/agents/{name}/memory/policyownerUpdate the memory policy (new revision)
GET/v1/agents/{name}/memory/statusownerThe policy the running agent serves, and what its store holds
GET/v1/agents/{name}/memory?user_id=ownerThe facts remembered about one user
POST/v1/agents/{name}/memoryownerRemember one fact about a user, as written
DELETE/v1/agents/{name}/memory?user_id=ownerErase everything about one user
DELETE/v1/agents/{name}/memory/{id}ownerRemove one fact
POST/v1/agents/{name}/memory/{id}:invalidateownerMark one fact no longer true (kept, hidden)
POST/v1/agents/{name}/memory/{id}:confirmownerBelieve a pending fact
POST/v1/agents/{name}/memory:reindexownerDistil pre-fact transcript memories into facts
GET/v1/agents/{name}/knowledgeownerList the Knowledge store, or search it with ?query=
POST/v1/agents/{name}/knowledgeownerAdd operator text to the Knowledge store
DELETE/v1/agents/{name}/knowledgeownerEmpty the Knowledge store
DELETE/v1/agents/{name}/knowledge/{id}ownerRemove one knowledge fact
GET/v1/agents/{name}/specownerSanitized live YAML
GET/v1/agents/{name}/metricsownerLive instance/readiness counts
GET/v1/agents/{name}/usersownerList callers (users) of this agent
GET/v1/agents/{name}/sessionsownerList one user's sessions
GET/v1/agents/{name}/sessions/{id}ownerFull session transcript
DELETE/v1/agents/{name}/sessions/{id}ownerDelete a session
POST/v1/agents/{name}/sessions/{id}:cloneownerCopy a conversation into a debug session
POST/v1/agents/{name}/sessions/{id}:rewindownerCut a cloned conversation back to a turn
POST/v1/agents/{name}/invokeopenTalk to the agent (data plane)
POST/v1/agents/{name}/invoke/streamopenStreaming invoke (NDJSON)
POST/v1/agents/{name}/sessions/{id}/memorizealways authenticatedCommit one session's facts now (operator override; 409 when memory is off)
GET/v1/agents/{name}/embedownerRead chat-widget config
PUT/v1/agents/{name}/embedownerUpdate chat-widget config
DELETE/v1/agents/{name}/embedownerRemove chat-widget config
POST/v1/agents/{name}/embed/rotate-keyownerRotate the public embed key
GET/v1/embed/{key}/configanonymous, Origin-allowlistedPublic widget config
POST/v1/embed/{key}/invokeanonymous, Origin-allowlistedPublic widget invoke

Deploy and lifecycle

POST /v1/agents

Deploys an agent (or function): creates it, or redeploys it if the name already exists and you own it. The body is multipart/form-data, not JSON.

Form fieldTypeRequiredDefaultNotes
namestringyesLowercase DNS label, max 63 chars. Immutable.
frameworkstringnoadkOne of adk, langgraph, crewai, function, container.
runtimestringnopythonFunctions only. One of python, nodejs, go, ruby.
codefileyesA .tar.gz of the agent directory.
configstring (JSON)noSame shape as PATCH /config below; applied to the first revision.

Choosing the project. Agents are addressed by name, and the project comes from the query string, not the form:

curl -X POST "$CAI_API/v1/agents?project=$PROJECT_ID" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F name=my-agent -F framework=adk -F code=@agent.tar.gz

Omit it and the agent lands in your first project, which is what you want when you have one and a coin toss when you have several. ?project= applies to every per-agent route — get, logs, env, secrets, delete — for the same reason.

A project_id form field is refused with 400, rather than ignored:

{"error": "unknown form field(s): project_id. This form accepts name, framework, runtime, config and code. To choose the project, use the query parameter ?project=<project-id> - a form field cannot select one, and ignoring it would build your agent into a different project than you named."}

That refusal exists because the alternative was found the hard way: the field was dropped, the deploy answered 202, the agent went ready in the wrong project, and the mistake surfaced much later and somewhere else — as a gateway publish failing with "function <name> no longer exists".

Upload cap: 100 MiB by default (administrator setting MAX_UPLOAD_BYTES), plus 1 MiB of slack for the form fields. The uploaded source is also stored as editable files (see Source files).

Success: 202

{"agent": "<name>", "build_id": "<uuid>"}

The build runs asynchronously. Poll GET /v1/agents/{name} for state: buildingdeployingready, or failed.

Every agent and function also carries ready, a boolean that is true exactly when state is ready. It is there so one client can ask every resource on the platform the same question — memorystores, indexes, topics, subscriptions and serverless services report the same state / ready / message triple at the top level, spelled identically, so a script that walks a project needs one reader rather than one per resource type. state is the richer answer when you want to know which stage a deploy is in; ready is the one to branch on.

A deploy may wait before it builds

Builds run against a shared builder with a bounded number of slots, so a deploy submitted while others are running waits for its turn and stays building while it waits. That wait does not count against the build's own time limit — a build is only timed once it actually starts — so a queued deploy takes longer without becoming more likely to fail.

If the platform is so busy that a build never gets a turn, it fails with a message saying so rather than being timed out:

build not started: the build never reached the front of the queue: waited 30m0s while other builds held every slot

That is a "try again" error, not a "your code is wrong" error. A build that genuinely overran its own limit says something different — "the build ran out of time … which usually means a slow step in the image or a busy shared builder, not a broken build" — and points at your image.

::: There is no separate build-log endpoint — on failure, the first 4000 characters of the build output (with ...(truncated) appended) land in the agent's message field. Note that this is the beginning of the build log, so a failure reported at the very end of a long build may be cut off.

Errors:

  • 400 — missing or invalid 'name' (must be a lowercase DNS label)
  • 400 — unsupported 'framework': one of "adk", "langgraph", "crewai", "function", "container"
  • 400 — unsupported 'runtime': one of "python", "nodejs", "go", "ruby"
  • 400 — 'runtime' only applies to framework=function
  • 400 — missing 'code' file field: ... / invalid multipart form: ... / invalid 'config' field: ...
  • 409 — this project cannot deploy yet: its container images are built into your own Crusoe Cloud container registry, and no Crusoe Cloud credential is mapped to this project. ... The repository itself is created for you on the first deploy - there is nothing to pre-create. Nothing was built and your stored source was not touched. (the project has no Crusoe Cloud connection — see Crusoe Cloud integration. Checked before the build is claimed, so nothing is consumed)
  • 409 — this deploy could not be attached to a project, and images are built into the project's own Crusoe Cloud container registry - so there is no registry to push to. Deploy with a project-scoped credential, or name the project explicitly with ?project=<slug>, then deploy again. Nothing was built. (the request resolved to no project, so there was no credential to look up)
  • 409 — this project is at its service limit (N / M): deploying needs at least one more service and cannot proceed. Delete an agent or function, or ask an admin to raise the project's service quota, then deploy. (N and M are the live used/limit numbers)
  • 409 — a build is already in progress for agent <name> (concurrent deploys are arbitrated in the database; a stale claim becomes reclaimable after 30 minutes)
  • 403 — an agent named <name> already exists and has no recorded owner, so only a project admin can redeploy it
  • 404 — unknown agent: <name> (the name is owned by someone else — deliberately indistinguishable from not existing)
  • 503 — the project secret store is not configured, so this project's Crusoe Cloud credential cannot be read. This is a platform configuration problem, not yours.
Deploys can also wedge at "deploying"

If the project hits its service quota after the build, state stays deploying and message explains: This project is at its service limit (N / M), so the new revision cannot get a network route yet - .... See Platform limits.

A revision that reads "not ready" is not failed for its first two minutes

The serverless layer marks a new revision "not ready" for instance states that heal on their own: an instance the scheduler has not placed yet, an instance the project's quota would not admit while the previous revision was still winding down, a container that exited once and restarted. While that condition is under two minutes old, state stays deploying and message carries the layer's own text, for example exceeded quota: project-quota, requested: limits.cpu=4, used: limits.cpu=20, limited: limits.cpu=20. Only a "not ready" that persists past two minutes becomes failed, and platformctl deploy prints that message when it gives up.

GET /v1/agents

Lists agents and functions. Plain project members see only their own agents; project admins see everything in visible projects.

Query: ?project=, ?page_size (default 50, max 200 — over-max is rejected), ?page_token.

Response: 200

{"agents": [ ...agentView ], "next_page_token": ""}

next_page_token is always present and empty on the last page.

The agentView object:

FieldTypeMeaning
namestringThe agent's immutable name.
statestringbuilding | deploying | ready | failed. The word to show.
readyboolTrue exactly when state is ready. The field to branch on.
kindstringagent | function.
frameworkstringadk | langgraph | crewai | function | function-nodejs | function-go | function-ruby.
runtimestringFunctions only: python | nodejs | go | ruby.
imagestringBuilt image reference (digest-pinned at runtime).
urlstringThe platform's internal address for the agent, reachable only from inside the platform.
external_urlstringLive public HTTPS URL — present only when published and the certificate is valid.
public_urlstringCanonical public address: https://<name>-<project-short>.apps.codyhill.dev.
latest_revisionstringName of the newest revision.
messagestringLast error or build output.
ownerstringEmail of the deployer; empty for tokenless deploys.

GET /v1/agents/{name}

Returns one agentView (same shape as above). 404 — unknown agent: <name>.

DELETE /v1/agents/{name}

Deletes the runtime service, the per-agent Secret, the env ConfigMap, the compute ConfigMap, and the database row.

Response: 200

{"agent": "<name>", "deleted": true}

POST /v1/agents/{name}/redeploy

Rebuilds the agent from its stored source files — no tarball needed. This is the browser editor's deploy button. It works for functions too: the rebuild uses the framework recorded on the deployment, so a function is rebuilt as a function. (The console's Functions page has no redeploy button — it re-uploads through POST /v1/agents — but the endpoint itself is not restricted.)

Response: 202

{"agent": "<name>", "build_id": "<uuid>", "files": 3, "note": "rebuilding from the stored source"}

Errors: 400 — no source is stored for this agent - deploy it once from the CLI or upload files first. A rebuild also pushes to your own Crusoe Cloud registry, so it returns the same 409 refusals POST /v1/agents does when the project has no Crusoe Cloud credential.

Logs

GET /v1/agents/{name}/logs

Streams text/plain logs from the newest running copy of the agent. Add ?follow=true to keep tailing.

A scaled-to-zero agent still answers 200, with this body:

agent <name> has no running instances: it is scaled to zero, which is normal for an idle serverless agent - it cold-starts on the next invoke. For logs from earlier runs, use GET /v1/agents/<name>/logs/history.

A genuinely missing agent is 404.

GET /v1/agents/{name}/logs/history

Persisted logs that survive scale-to-zero and revision rollouts. Retention defaults to 14 days (administrator setting LOG_RETENTION).

Query: ?limit (default 500), ?since (RFC3339 timestamp).

Response: 200 (lines oldest-first)

{"agent": "<name>",
"lines": [{"ts": "...", "revision": "...", "instance": "...", "stream": "...", "message": "..."}],
"count": 42,
"note": "persisted history - survives scale-to-zero and revision rollouts"}

See also Agent logs.

Secrets and env

Secrets are write-only: key names are readable, values are never returned. Env vars are readable configuration. Every change to either rolls a new revision — a running revision never changes. Keys must match ^[A-Za-z_][A-Za-z0-9_]*$. Secret request bodies are capped at 1 MiB. See Secrets and env.

GET /v1/agents/{name}/secrets

Response: 200 — {"agent": "<name>", "keys": ["MODEL_API_KEY", ...]}

PUT /v1/agents/{name}/secrets

Full replace. Body is a flat map:

{"MODEL_API_KEY": "sk-...", "OTHER_KEY": "value"}

Empty values are dropped ("blank means inherit").

Response: 200 — {"agent": "<name>", "secrets_updated": true}

PATCH /v1/agents/{name}/secrets

Merge. Body:

{"set": {"KEY": "value"}, "remove": ["OLD_KEY"]}

An empty value inside set becomes a removal.

Response: 200 — {"agent": "<name>", "secrets_updated": true, "keys": [...]}

Errors:

  • 400 — nothing to do: provide 'set' and/or 'remove'
  • 400 — invalid secret key (must be a valid environment variable name): <key>
tip

Setting MODEL_API_KEY as a per-agent secret overrides the platform-default model key for that agent.

GET /v1/agents/{name}/env

Response: 200 — {"agent": "<name>", "env": {"CHAT_MODEL": "...", ...}} (names and values — env vars are not secret).

PATCH /v1/agents/{name}/env

Same {"set": {...}, "remove": [...]} shape as secrets. Applying a change rolls out a new version, because each version fixes its variables when it deploys — a running version is never edited in place.

Response: 200 — {"agent": "<name>", "env_updated": true, "env": {...}}

Overriding the platform's own variables

Your own variable names are always yours; the platform has no claim on them. Four of the platform's variables are deliberately overridable, so you can bring your own inference endpoint or pin a different model than the project default:

MODEL_BASE_URL, CHAT_MODEL, EMBED_MODEL, EMBED_BASE_URL

Everything else the platform injects is refused with 400, and the message names each key and why:

GroupWhy it is refusedWhat to do instead
Credentials — MODEL_API_KEY, CAI_INTERNAL_TOKEN, CAI_PROJECT_KEY, QDRANT_API_KEY, CRUSOE_VECTORDB_API_KEY, VALKEY_PASSWORD, CRUSOE_MEMORYSTORE_PASSWORDThe platform is accountable for these valuesBind your own credential from a Secret, under your own name
Wiring — VALKEY_ADDR, VALKEY_USERNAME, CRUSOE_MEMORYSTORE_ADDR, CRUSOE_MEMORYSTORE_USERNAME, QDRANT_URL, CRUSOE_VECTORDB_URL, SANDBOX_URL, CAI_API_URL, CAI_PUBSUB_URL, CAI_VECTORDB_URL, MCP_SERVERSThese say where your workload's platform services are; repointing one could address another project's dataNothing to do — the platform sets these per project
Identity — CAI_PROJECT_ID, PROJECT_SHORT, AGENT_NAME, AGENT_IMAGERenaming your workload would break its audit trail
Derived — CRUSOE_REQUEST_TIMEOUT_SECONDSMirrors the request timeout; crusoe.secret() sizes its token TTL from it, so drift would mint tokens that outlive or predecease their requestSet timeout_seconds on the workload
Memory policy — MEMORY_MODE, MEMORY_INJECT, MEMORY_TTL_SECONDS, MEMORY_EXCLUDE_SENSITIVE, MEMORY_TOPICS, MEMORY_INSTRUCTIONS, KNOWLEDGE_ENABLEDRendered from the agent's stored memory policy on every rebuild, so a value set here would be overwritten the next time the agent rolls and nothing would say soPATCH /v1/agents/{name}/memory/policy, or platformctl agents memory set
Reserved — LD_PRELOAD, PYTHONPATH, PATH, HOME, BAO_ADDR, BAO_TOKEN, NATS_URL, NATS_PASSWORD, TOOL_SANDBOX, KUBERNETES_SERVICE_*Refused for secret bindings for the same reason; plain text does not make them safer

remove is guarded the same way: removing one of these cannot unset the platform's value — the platform sets those variables directly when your agent deploys, not through the set you control — so reporting success would be misleading.

:::note Why a refusal and not a silent no-op Setting one of the first four groups used to answer 200, store the value, and echo it back from GET /env — and then it was discarded at deploy, because the platform's own value takes precedence over the set you control. Nothing gave you a reason to doubt the setting, so the next step was debugging your own code. It is refused now instead, and the error says which rule applies.

The Reserved group is different, and the error wording reflects that: nothing in the platform sets those names, so no value would have been discarded. They are refused as a rule — the same rule that refuses them as secret bindings. :::

Source files

The platform stores the latest version of your uploaded source so you can edit it in the browser. Latest version only — there is no history and no git. Limits: 1 MiB per file, 200 files per agent, and an uploaded archive may expand to at most 64 MiB. Symlinks and path-traversal entries are rejected. Binary content only survives via the tar.gz upload path. See Files and the editor.

GET /v1/agents/{name}/files

Response: 200

{"agent": "<name>", "agent_id": "<uuid>",
"files": [{"path": "agent.py", "size": 512, "updated_at": "..."}],
"editable": true,
"note": "latest version only - the platform keeps no history, so copy anything you want to keep"}

GET /v1/agents/{name}/files/{path...}

Response: 200 — {"path": "...", "content": "...", "updated_at": "..."}

PUT /v1/agents/{name}/files/{path...}

Body: {"content": "<text>"}

Response: 200 — {"path": "...", "bytes": 512, "note": "saved - redeploy the agent for this to take effect"}

File edits do not take effect until you call POST /v1/agents/{name}/redeploy.

DELETE /v1/agents/{name}/files/{path...}

Response: 200 — {"deleted": "<path>"}

Revisions, traffic, and compute config

Every code, secret, env, or config change creates an immutable revision. See Traffic and revisions.

GET /v1/agents/{name}/revisions

Response: 200 — {"agent": "<name>", "revisions": [...]}, newest first. Each revision carries name, generation, traffic_percent, tag, created_at, readiness, and replica count, annotated with the live traffic share.

POST /v1/agents/{name}/set-traffic

Replaces the complete traffic split. Percentages must sum to exactly 100.

{"traffic": [{"revision_name": "research-buddy-00002", "percent": 100}]}

Response: 202 — {"agent": "<name>", "traffic": [...]}

Errors:

  • 400 — traffic is required - send the complete split, e.g. [{"revision_name":"research-buddy-00002","percent":100}]
  • 400 — revision <r> does not exist for this agent
Rollback is traffic-only

set-traffic moves serving traffic only. The stored source is unchanged, so a later redeploy builds forward and supersedes the pin.

GET /v1/agents/{name}/config

Response: 200 — {"agent": "<name>", "config": {...}}config is null when the agent runs on platform defaults.

PATCH /v1/agents/{name}/config

Sparse update; omitted fields keep their current values. Rolls a new revision.

{"scaling": {"min_scale": 0, "max_scale": 5, "container_concurrency": 10},
"resources": {"requests": {"cpu": "100m", "memory": "256Mi"},
"limits": {"cpu": "1", "memory": "1Gi"}},
"timeout_seconds": 300}
FieldTypeMeaning
scaling.min_scaleintInstance floor. min_scale: 1 avoids cold starts.
scaling.max_scaleintInstance ceiling. On this agent surface, 0 means unbounded.
scaling.container_concurrencyintSimultaneous requests per instance.
resources.requests / resources.limitsobjectCPU and memory, written as quantity strings like 100m and 256Mi.
timeout_secondsintRequest timeout.

Response: 200 — {"agent": "<name>", "config_updated": true, "config": {...}}

Errors: negative values are 400 with explanatory messages, for example min_scale -1 is negative; ... and min_scale N is greater than max_scale M; ....

Spec and metrics

GET /v1/agents/{name}/spec

Returns the running service definition as sanitized YAML: internal bookkeeping fields and last-applied annotations are stripped, and any env var whose name matches (?i)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE) has its value replaced with ***REDACTED***.

Response: 200 — {"agent": "<name>", "yaml": "...", "note": "..."}

GET /v1/agents/{name}/metrics

Live counts only — there are no historical charts (no time-series database is deployed), and the response note says so plainly.

Response: 200 — {"agent", "instances", "revisions", "readiness", "latest_ready_revision", "target_concurrency", "note"}

readiness is the newest revision's readiness as {"state", "ready", "reason", "message", "last_transition_time"}state is ready, not_ready or unknown, ready is a boolean, and last_transition_time is how you answer "how long has it been like this" without a time-series database. Each entry in revisions carries the same state and boolean ready beside its instance count.

Users and sessions browser

These management routes let an agent's owner browse who has talked to it and read transcripts. They proxy to the agent's internal read surface using a platform-internal shared secret. When the platform's CAI_INTERNAL_TOKEN is not configured, every one of them answers 503 — session browsing is not configured: the control plane has no CAI_INTERNAL_TOKEN, so it cannot authenticate to the agent's read surface.

Harness-side page size: default 50, hard max 200. See Sessions.

GET /v1/agents/{name}/users

Query: ?page_size, ?page_token.

Response: 200

{"users": [{"user_id": "...", "first_seen": "...", "last_seen": "...", "session_count": 3}],
"next_page_token": ""}

GET /v1/agents/{name}/sessions

Query: ?user_id=<id> (required — 400 the user_id query parameter is required), plus pagination.

Response: 200

{"sessions": [{"session_id": "...", "created": "...", "last_update": "...", "event_count": 12, "preview": "..."}],
"next_page_token": ""}

GET /v1/agents/{name}/sessions/{id}

Returns the full transcript: an ordered events list where each event carries author ("user" or the agent name), timestamp, invocation_id, and content.parts (text parts, function_call, function_response; reasoning parts are flagged thought: true).

Errors: 404 — session <id> not found

DELETE /v1/agents/{name}/sessions/{id}

Response: 204 on success; 404 if missing.

POST /v1/agents/{name}/sessions/{id}:clone

Copies the whole conversation into a new session owned by a debug identity (debug:<original user>). The copy does not appear in the original user's session list, and nothing written to it can reach the original.

No request body.

Response: 200

{
"session_id": "dbg-26efd45f590bdb6b815ed2b7",
"cloned_from": "3f2c8a1e-9d41-4c1b-a2f7-0b1c2d3e4f5a",
"user_id": "debug:alice@example.com",
"events": 4,
"note": "A copy of that conversation, owned by a debug identity. ..."
}

The clone records _cloned_from, _cloned_from_user and _cloned_at in its state, so a later GET .../sessions/{clone} can tell it is a copy without being told.

Errors: 404 — session <id> not found

POST /v1/agents/{name}/sessions/{id}:rewind

Deletes a cloned conversation's history from a chosen turn onwards, and returns the prompt that started the first dropped turn so it can be edited or replayed.

Body: {"keep_turns": 0} — how many user turns to keep. 0 (the default) empties the conversation but keeps the session.

Response: 200

{"session_id": "dbg-26efd45f590bdb6b815ed2b7", "turns": 2, "kept_turns": 1,
"dropped_events": 2, "next_prompt": "Now say the single word: pong"}

Errors:

  • 403 — rewind only works on a cloned session: it deletes recorded history with no undo, and doing that to a live conversation destroys it for the person having it. Clone this session first.
  • 404 — session <id> not found
:clone and :rewind are custom methods, not sub-resources

The colon is part of the last path segment, so the id and the verb arrive together: .../sessions/live-1:clone, not .../sessions/live-1/clone. Do not percent-encode the colon — %3A addresses a session whose id literally ends :clone, and answers 404.

Invoke (the data plane)

POST /v1/agents/{name}/invoke

Talks to the agent. Open to anonymous callers by default (INVOKE_AUTH_REQUIRED=false). Request body max 1 MiB; the buffered upstream response is capped at 32 MiB (over-cap → 502). Timeout: 60 seconds by default (administrator setting INVOKE_TIMEOUT_SECONDS).

FieldTypeRequiredDefaultNotes
messagestringyesThe user's message.
session_idstringnoplatform-minted UUIDAnonymous callers who choose their own must use ≥ 24 characters.
user_idstringnoderived from the session ID (UUIDv5)Stable across repeat invokes on the same session — this is what makes multi-turn continuity work.
memoryboolnotruefalse opts this one turn out of every memory write - the "do not remember this conversation" signal.
memorizeboolnofalseDeprecated. Under memory mode explicit or auto it distils the whole session before replying; under off it is ignored. Anonymous callers may not set it (401, below). Whether the agent remembers is its memory policy, not this flag.

Response: 200 (the control plane guarantees session_id and user_id are filled)

{"session_id": "...", "user_id": "...", "output": "...", "reasoning": "",
"tool_calls": [{"name": "run_python", "summary": "called with args={...}"}],
"events": [ ... ]}

Errors:

  • 400 — message is required
  • 400 — session_id chosen by an unauthenticated caller must be at least 24 characters of unguessable randomness (or omit it and the platform will generate one). A short, guessable id would let anyone else read this conversation.
  • 400 — invalid user_id: must match ^[A-Za-z0-9_.:@-]{1,128}$ (omit it and the platform will derive one from the session)
  • 401 — memorize requires authentication: it writes durable memory that later callers read back. Invoke without 'memorize', then call POST /v1/agents/{name}/sessions/{id}/memorize with a session token.
  • 404 — unknown agent: <name>
  • 409 — the agent name <name> exists in more than one project; add ?project=<slug> to say which one, or sign in so it resolves within your project
  • 502 — invoke agent <name>: ... (upstream failures; upstream FastAPI detail errors are normalized into the standard error envelope)

Example:

curl -s -X POST "$CAI_API/v1/agents/my-agent/invoke" \
-H 'content-type: application/json' \
-d '{"message":"Compute 2**32 in python."}'

You should see:

{"session_id":"<minted>","user_id":"<derived>","output":"2**32 is 4294967296.","reasoning":"","tool_calls":[{"name":"run_python","summary":"called with args={...}"}],"events":[...]}

POST /v1/agents/{name}/invoke/stream

Same request body and validation as invoke. The response is application/x-ndjson — one JSON object per line, in turn order:

{"type":"thinking", "seq":N, "text":"<delta>"}
{"type":"output", "seq":N, "text":"<delta>"}
{"type":"block_end", "seq":N, "kind":"thinking"|"output"}
{"type":"tool_call", "name":..., "args":{...}}
{"type":"tool_result","name":..., "result":...}
{"type":"done", "session_id":..., "user_id":...}
{"type":"error", "message":...}

Minted IDs are also returned as response headers X-CAI-Session-Id and X-CAI-User-Id. You get token-level streaming when the model supports it; otherwise the first "delta" is the whole text. See Invoke an agent.

POST /v1/agents/{name}/sessions/{id}/memorize

The operator override: distils one session into facts about its user, now. Always requires authentication, even when invoke is open:

  • 401 — writing to the memory bank requires authentication, even though invoking this agent does not: stored memories are read back into later callers' context. Sign in (POST /v1/auth/login) and send 'Authorization: Bearer <token>'

Response: 200 — {"status": "ok"} (relayed from the agent). 404 — session <id> not found. 409 — memory is off for this agent (MEMORY_MODE=off); set the agent's memory mode to explicit or auto to allow writes.

Memory is per user: a fact is only ever served back to the user it was learned from. Whether the agent remembers at all is its policy, below.

Memory

Long-term memory is a policy on the agent and a per-user set of facts. The policy is control-plane state (stored with the agent, rolled into a revision as MEMORY_* env); the facts live in the agent's store and these routes proxy to it. All are owner-gated. See Long-term memory for the model.

GET /v1/agents/{name}/memory/policy

{"agent":"research-buddy","memory":{"mode":"auto","ttl_seconds":2592000},
"effective":{"mode":"auto","source":"stored"}}

memory is null for an agent that never set one; effective.source is then legacy_default and effective.mode is explicit (an agent deployed before policies existed behaves as it always did).

PATCH /v1/agents/{name}/memory/policy

Sparse. Fields you omit keep their stored value.

FieldTypeValues
modestringoff, auto, explicit
injectstringnone, profile
ttl_secondsint0 = never expires
exclude_sensitivebooldefault true
topicsstring[]lowercase identifiers; replaces the whole list
instructionsstringup to 2000 characters
knowledgeboolenables the agent-wide Knowledge store

Response: 200 — the GET shape plus "memory_updated": true. A new revision rolls. 400 — a value the agent would refuse, named: mode "sometimes" is not one of off, auto, explicit.

GET /v1/agents/{name}/memory/status

What the running agent serves (it lags a PATCH until the new revision is ready): mode, inject, ttl_seconds, exclude_sensitive, topics, knowledge_enabled, collection, knowledge_collection, and legacy_points - transcript memories from before facts existed that :reindex has not distilled.

GET /v1/agents/{name}/memory?user_id=U

user_id is required (400 without it). Optional include_invalid=true, include_pending=false, limit (max 1000).

{"user_id":"alice","count":1,"facts":[{"id":"6f1c...","kind":"fact","text":"Owns a Mastercraft Maristar 245.",
"user_id":"alice","author":"user","turn":1,"session_id":"3f2c...","source_kind":"conversation",
"topic":"personal_info","created_at":1788900000.0,"valid":true,"pending":false,"invalidated_by":""}]}

kind is legacy for a pre-fact transcript memory. source_kind is one of conversation, operator, model, tool, document; tool and document facts arrive pending.

POST /v1/agents/{name}/memory

Body {"user_id": "alice", "text": "Prefers replies in bullet points."}. Stores the fact as written, attributed to the operator, reconciled with what is already remembered. Response: 200 — {"user_id":"alice","added":1,"updated":0,"invalidated":0,"extracted":1,"ids":["..."]}. 409 when memory is off.

DELETE /v1/agents/{name}/memory?user_id=U

Erases everything about one user, including invalidated and pending facts. Works whatever the mode is. Response: 200 — {"user_id":"alice","deleted":3}.

DELETE /v1/agents/{name}/memory/{id} · POST .../memory/{id}:invalidate · POST .../memory/{id}:confirm

One fact's lifecycle: remove it (204); mark it no longer true - kept, hidden, never served ({"id":"...","valid":false}); believe a pending one ({"id":"...","pending":false}).

POST /v1/agents/{name}/memory:reindex

Body (optional) {"user_id": "...", "drop_legacy": false, "limit": 500}. Distils transcript memories written before facts existed into facts under their own user. Response: {"scanned":12,"distilled":10,"facts":31,"unattributed":2,"dropped":0}. 409 when memory is off.

The Knowledge store

Agent-wide, operator-written, read by every user; enabled with "knowledge": true on the policy (404 naming the setting otherwise).

  • GET /v1/agents/{name}/knowledge{"count":N,"facts":[...]}; with ?query=refunds{"query":"refunds","results":["..."]}.
  • POST /v1/agents/{name}/knowledge — body {"text": "...", "distil": true}; distil: false stores the text as one fact verbatim.
  • DELETE /v1/agents/{name}/knowledge — empties it: {"deleted":N}.
  • DELETE /v1/agents/{name}/knowledge/{id} — removes one fact (204).

Embed (chat widget)

The embed surface has two halves: owner-gated configuration on the agent, and an anonymous public plane keyed by a rotatable embed_key with an Origin allowlist. See Embed chat.

GET /v1/agents/{name}/embed

Response: 200

{"enabled": false, "embed_key": "", "allowed_origins": [], "title": "", "subtitle": "",
"greeting": "", "accent_color": "", "launcher_text": "", "position": "right", "updated_at": ""}

An unset config reads {"enabled": false, "allowed_origins": [], "position": "right"}.

PUT /v1/agents/{name}/embed

Partial update; all fields optional, same names as the GET response. The first PUT mints the public embed_key.

DELETE /v1/agents/{name}/embed

Removes the widget configuration.

POST /v1/agents/{name}/embed/rotate-key

Mints a new public embed_key; the old one stops working.

GET /v1/embed/{key}/config

Anonymous, CORS-gated by the Origin allowlist.

Response: 200 — {"agent", "title", "subtitle", "greeting", "accent_color", "launcher_text", "position", "streaming"}

POST /v1/embed/{key}/invoke

Anonymous, CORS-gated. Body: {"message", "session_id?", "user_id?"}. Memorize is not available on this plane. OPTIONS preflights are answered on both public routes.

Served by the agent itself

Every deployed agent also serves, on its own URL:

  • GET /healthz{"status": "ok"}
  • GET /debug/config — unauthenticated and deliberately narrow: agent name, framework, resolved model, model_key_present, and non-secret config env key names. Its note explains that full env and secret names are available only to the owner via GET /v1/agents/{name}/env and /secrets.

The agent's own read/delete/memorize surfaces fail closed without the platform-internal secret header (503 — read surface disabled: CAI_INTERNAL_TOKEN is not configured, 401 — missing or invalid X-CAI-Internal). Always go through the control-plane routes above instead.