Agents API
This page documents every agent endpoint on agent-engine-api: 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 — the automation token, a project admin, or the user who deployed the agent. No grant on the project → 404
not found. Someone else's agent → 404unknown 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; the operator can require one with
INVOKE_AUTH_REQUIRED=true.
- owner — the automation token, a project admin, or the user who deployed the agent. No grant on the project → 404
Route table
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/agents | any credential | Deploy (create or redeploy) from a tarball |
| GET | /v1/agents | any credential | List agents and functions |
| GET | /v1/agents/{name} | owner | Get one agent |
| DELETE | /v1/agents/{name} | owner | Delete an agent |
| POST | /v1/agents/{name}/redeploy | owner | Rebuild from stored source |
| GET | /v1/agents/{name}/logs | owner | Live pod logs (text/plain) |
| GET | /v1/agents/{name}/logs/history | owner | Persisted log history |
| GET | /v1/agents/{name}/secrets | owner | List secret key names |
| PUT | /v1/agents/{name}/secrets | owner | Replace all secrets |
| PATCH | /v1/agents/{name}/secrets | owner | Merge/remove secrets |
| GET | /v1/agents/{name}/env | owner | Read env vars (names and values) |
| PATCH | /v1/agents/{name}/env | owner | Merge/remove env vars |
| GET | /v1/agents/{name}/files | owner | List stored source files |
| GET | /v1/agents/{name}/files/{path...} | owner | Read one file |
| PUT | /v1/agents/{name}/files/{path...} | owner | Write one file |
| DELETE | /v1/agents/{name}/files/{path...} | owner | Delete one file |
| GET | /v1/agents/{name}/revisions | owner | List revisions with traffic share |
| POST | /v1/agents/{name}/set-traffic | owner | Replace the traffic split |
| GET | /v1/agents/{name}/config | owner | Read compute config |
| PATCH | /v1/agents/{name}/config | owner | Update compute config (new revision) |
| GET | /v1/agents/{name}/spec | owner | Sanitized live YAML |
| GET | /v1/agents/{name}/metrics | owner | Live instance/readiness counts |
| GET | /v1/agents/{name}/users | owner | List callers (users) of this agent |
| GET | /v1/agents/{name}/sessions | owner | List one user's sessions |
| GET | /v1/agents/{name}/sessions/{id} | owner | Full session transcript |
| DELETE | /v1/agents/{name}/sessions/{id} | owner | Delete a session |
| POST | /v1/agents/{name}/invoke | open | Talk to the agent (data plane) |
| POST | /v1/agents/{name}/invoke/stream | open | Streaming invoke (NDJSON) |
| POST | /v1/agents/{name}/sessions/{id}/memorize | always authenticated | Commit a session to the memory bank |
| GET | /v1/agents/{name}/embed | owner | Read chat-widget config |
| PUT | /v1/agents/{name}/embed | owner | Update chat-widget config |
| DELETE | /v1/agents/{name}/embed | owner | Remove chat-widget config |
| POST | /v1/agents/{name}/embed/rotate-key | owner | Rotate the public embed key |
| GET | /v1/embed/{key}/config | anonymous, Origin-allowlisted | Public widget config |
| POST | /v1/embed/{key}/invoke | anonymous, Origin-allowlisted | Public 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 field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | — | Lowercase DNS label, max 63 chars. Immutable. |
framework | string | no | adk | One of adk, langgraph, crewai, function. |
runtime | string | no | python | Functions only. One of python, nodejs, go, ruby. |
code | file | yes | — | A .tar.gz of the agent directory. |
config | string (JSON) | no | — | Same shape as PATCH /config below; applied to the first revision. |
Upload cap: 100 MiB by default (operator knob 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 status: building → deploying → ready, or failed. 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" - 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 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)
If the project hits its service quota after the build, status 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.
GET /v1/agents
Lists agents and functions. Plain project members see only their own agents; project admins and the automation token 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:
| Field | Type | Meaning |
|---|---|---|
name | string | The agent's immutable name. |
status | string | building | deploying | ready | failed. |
kind | string | agent | function. |
framework | string | adk | langgraph | crewai | function | function-nodejs | function-go | function-ruby. |
runtime | string | Functions only: python | nodejs | go | ruby. |
image | string | Built image reference (digest-pinned at runtime). |
url | string | Internal cluster URL. |
external_url | string | Live public HTTPS URL — present only when published and the certificate is valid. |
public_url | string | Canonical public address: https://<name>-<project-short>.apps.codyhill.dev. |
latest_revision | string | Name of the newest revision. |
message | string | Last error or build output. |
owner | string | Email 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
Logs
GET /v1/agents/{name}/logs
Streams text/plain logs from the newest live pod. Add ?follow=true to keep tailing.
A scaled-to-zero agent still answers 200, with this body:
agent <name> has no live pods: it is scaled to zero (no running replicas), 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 (operator knob LOG_RETENTION).
Query: ?limit (default 500), ?since (RFC3339 timestamp).
Response: 200 (lines oldest-first)
{"agent": "<name>",
"lines": [{"ts": "...", "revision": "...", "pod": "...", "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>
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.
Response: 200 — {"agent": "<name>", "env_updated": true, "env": {...}}
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
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}
| Field | Type | Meaning |
|---|---|---|
scaling.min_scale | int | Instance floor. min_scale: 1 avoids cold starts. |
scaling.max_scale | int | Instance ceiling. On this agent surface, 0 means unbounded. |
scaling.container_concurrency | int | Simultaneous requests per instance. |
resources.requests / resources.limits | object | CPU/memory, Kubernetes quantity strings. |
timeout_seconds | int | Request 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 live Knative Service as sanitized YAML: managedFields 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"}
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.
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 (operator knob INVOKE_TIMEOUT_SECONDS).
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
message | string | yes | — | The user's message. |
session_id | string | no | platform-minted UUID | Anonymous callers who choose their own must use ≥ 24 characters. |
user_id | string | no | derived from the session ID (UUIDv5) | Stable across repeat invokes on the same session — this is what makes multi-turn continuity work. |
memorize | bool | no | false | Anonymous callers may not set it (401, below). |
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 FastAPIdetailerrors are normalized into the standarderrorenvelope)
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
Commits a session to the agent's long-term memory bank. 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.
The memory bank has no per-caller partition: anything memorized is retrievable by every later caller of that agent. That is exactly why memorize always requires auth. See Memory.
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. Itsnoteexplains that full env and secret names are available only to the owner viaGET /v1/agents/{name}/envand/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.
Related pages
- Deploy an agent · Sessions · Memory · Logs · Troubleshooting
- Platform limits — all caps on this surface in one table.