Invoke an agent
This page shows you how to talk to a deployed agent: the invoke request and response shapes, streaming, calling the agent's public URL, and what to expect from cold starts.
Before you begin
- You have a deployed agent in
readystate. If not, start with deploy an agent. - The
curlexamples read the API endpoint from$CAI_API; set it tohttps://api.codyhill.dev. Theplatformctlexamples need no setup at all — the CLI uses that address by default.
export CAI_API=https://api.codyhill.dev
Talking to an agent needs no credential by default. Your administrator can require one by setting INVOKE_AUTH_REQUIRED=true on the platform. Managing an agent — deploying it, reading its logs, setting secrets, deleting it — always requires signing in.
The invoke endpoint
POST /v1/agents/{name}/invoke — send one message, get one reply.
- Auth: none required by default. You may send a bearer token — a credential you put in the
Authorization: Bearer <token>header, which the platform accepts as proof of who you are. - Request body: JSON, at most 1 MiB.
- Add
?project=<slug>if the same agent name exists in more than one project. A slug is the project's short, URL-safe name.
Request fields
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
message | string | yes | — | The user's message. Blank or missing returns 400 message is required. |
session_id | string | no | platform-minted UUID | Reuse it to continue a conversation. Anonymous callers who pick their own must use at least 24 characters. |
user_id | string | no | derived from the session id | Must match ^[A-Za-z0-9_.:@-]{1,128}$. Omit it and the platform derives a stable one. |
memorize | boolean | no | false | Also commit this session to the memory bank. Requires authentication. |
Response fields
| Field | Type | Notes |
|---|---|---|
session_id | string | Always filled — save it to continue the conversation. |
user_id | string | Always filled — the caller identity the session belongs to. |
output | string | The agent's answer. |
reasoning | string | The agent's thinking text, when the model produced any. |
tool_calls | array | One entry per tool the agent used: {"name": "run_python", "summary": "called with args={...}"}. |
events | array | The raw framework events for the turn (verbose; useful for debugging). |
Invoke
- platformctl
- curl
- Console
platformctl invoke my-agent "My boat is a Mastercraft Maristar 245. Compute 2**32 in python."
You should see:
2**32 is 4294967296.
(session: 3f2c8a1e-9d41-4c1b-a2f7-0b1c2d3e4f5a)
tool_call: run_python called with args={'code': 'print(2**32)'}
Continue the same conversation by passing the session id back:
platformctl invoke my-agent "What boat do I have?" --session 3f2c8a1e-9d41-4c1b-a2f7-0b1c2d3e4f5a
The flags are short:
--session <id>continues an existing conversation.--memorizealso writes the conversation to the memory bank. Sign in withplatformctl loginfirst.- There is no
--userflag. The platform works the user id out from the session id. -o jsonprints{"session_id", "response", "tool_calls"}instead of the human-readable form.
The CLI waits up to 5 minutes for a reply.
curl -s -X POST "$CAI_API/v1/agents/my-agent/invoke" \
-H 'content-type: application/json' \
-d '{"message":"My boat is a Mastercraft Maristar 245. Compute 2**32 in python."}'
You should see:
{
"session_id": "3f2c8a1e-9d41-4c1b-a2f7-0b1c2d3e4f5a",
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"output": "2**32 is 4294967296.",
"reasoning": "",
"tool_calls": [
{"name": "run_python", "summary": "called with args={'code': 'print(2**32)'}"}
],
"events": ["..."]
}
Continue the conversation by sending the same session_id back:
curl -s -X POST "$CAI_API/v1/agents/my-agent/invoke" \
-H 'content-type: application/json' \
-d '{"message":"What boat do I have?",
"session_id":"3f2c8a1e-9d41-4c1b-a2f7-0b1c2d3e4f5a"}'
Open the agent's Test tab and type your message. It is a chat panel: the answer streams in as it is written, and the agent's reasoning and each tool call appear inline between your question and its answer, in the order they happened. The session id is filled in for you and reused for follow-up messages — so proving multi-turn memory takes no copying.
The Users & Sessions tab is where past conversations live, grouped by end user.
Session id rules for anonymous callers
If you omit session_id, the platform mints a UUID and returns it — this is the easy path. If an unauthenticated caller supplies their own session_id, it must be at least 24 characters long, or the call fails with 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.
Authenticated callers are exempt. If you omit user_id, the platform derives the same one from the session id every time, so multi-turn conversations just work. See sessions for the full story.
The memorize flag
"memorize": true asks the platform to also commit the session to the agent's long-term memory bank. Anonymous callers get 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.
See long-term memory.
Streaming
Streaming sends you the answer as it is written, instead of making you wait for the finished reply.
POST /v1/agents/{name}/invoke/stream takes the exact same request body and the same validation rules. It responds with application/x-ndjson — newline-delimited JSON, meaning one complete JSON object per line, in the order things happened.
- platformctl
- curl
- Console
Not available. platformctl invoke has no streaming flag — it waits for the finished reply and prints it. Use the curl tab, or the console's Test tab, when you need to watch a turn as it happens.
curl -sN -X POST "$CAI_API/v1/agents/my-agent/invoke/stream" \
-H 'content-type: application/json' \
-d '{"message":"Compute 2**32 in python."}'
-N disables curl's own buffering, which is what makes the lines appear as they arrive rather than all at once.
The agent's Test tab streams by default: the answer appears as it is written, with tool calls and their results shown in order.
You should see (one object per line):
{"type":"thinking", "seq":1, "text":"<delta>"}
{"type":"output", "seq":2, "text":"<delta>"}
{"type":"block_end", "seq":3, "kind":"output"}
{"type":"tool_call", "name":"run_python", "args":{"code":"print(2**32)"}}
{"type":"tool_result","name":"run_python", "result":"4294967296\n"}
{"type":"done", "session_id":"...", "user_id":"..."}
A delta is one small piece of text — the next few characters of the answer, not the whole thing. Here is every line type you can receive:
type | What it means |
|---|---|
thinking | A delta of the agent's reasoning text |
output | A delta of the answer itself |
block_end | A thinking or output block just finished; kind says which |
tool_call | The agent is calling a tool, with the args it chose |
tool_result | That tool returned, with its result |
done | The turn is over; carries the final session_id and user_id |
error | The turn failed; the reason is in a message field |
Two extras worth knowing:
- The ids the platform mints also come back as the response headers
X-CAI-Session-IdandX-CAI-User-Id, so you can capture them before the stream ends. - Deltas are token-sized when the model supports streaming. When it doesn't, the first "delta" is simply the whole text at once.
Invoke via the agent's public URL
Agents are private by default. The deployed service answers only from inside the platform, so the control-plane routes above are the only way to reach it. GET /v1/agents/{name} does return a public_url — the address the agent would have, https://<name>-<project-short>.apps.codyhill.dev. Nothing serves that address until you publish the agent.
Publishing takes two variables, not one:
CAI_EXPOSE_EXTERNALis the auth mode, not a boolean:apikey,jwt, ornone. A baretrueis refused, because an address published without an explicit protection decision is the hole this contract exists to close.CAI_EXPOSE_RATE_LIMITis required whenever exposure is on, writtenN/second,N/minute,N/hourorN/day. A public invoke path with no budget is an unbounded spend of your own model key.jwtmode needsCAI_EXPOSE_JWT_ISSUERandCAI_EXPOSE_JWT_JWKS_URIas well, plus an optional comma-separatedCAI_EXPOSE_JWT_AUDIENCES.
- platformctl
- curl
- Console
platformctl agents env set my-agent CAI_EXPOSE_EXTERNAL=apikey CAI_EXPOSE_RATE_LIMIT=100/minute
agents env is for plain configuration, whose values read back. Credentials belong in platformctl secrets set instead.
curl -s -X PATCH "$CAI_API/v1/agents/my-agent/env" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'content-type: application/json' \
-d '{"set": {"CAI_EXPOSE_EXTERNAL": "apikey", "CAI_EXPOSE_RATE_LIMIT": "100/minute"}}'
You should see:
{"agent": "my-agent", "env_updated": true, "env": {"CAI_EXPOSE_EXTERNAL": "apikey", "CAI_EXPOSE_RATE_LIMIT": "100/minute"}}
On the agent's page, open the Access tab, edit the Environment variables section, add CAI_EXPOSE_EXTERNAL=apikey and CAI_EXPOSE_RATE_LIMIT=100/minute on their own lines, and save.
200 and then quietly unpublishes the agentThe env write is not what validates the contract. CAI_EXPOSE_EXTERNAL is not one of the platform's reserved names, so PATCH /env accepts whatever you send and answers 200. The refusal lands later, on the agent itself: the deploy path parses the pair, refuses it, and the agent drops out of ready with Reason: ExposureRefused and the refusal as its message. It never serves the public address.
So a stale CAI_EXPOSE_EXTERNAL=true looks like it worked and then reads back as:
CAI_EXPOSE_EXTERNAL must name the protection the address gets (CAI_EXPOSE_EXTERNAL=apikey, jwt or none) - a bare yes predates that decision and is refused
Setting the mode without a limit reads back as CAI_EXPOSE_RATE_LIMIT is required when exposing ("100/minute") - a public invoke path needs a budget. Check with platformctl status my-agent after the revision rolls, not with the 200 from the PATCH.
That change rolls a new revision. Once the address is live over a valid TLS certificate, the external_url field is filled in. The agent then serves POST /invoke and POST /invoke/stream on that host, with the same request body:
export AGENT_URL="$(curl -s -H "Authorization: Bearer $CAI_TOKEN" "$CAI_API/v1/agents/my-agent" | jq -r .public_url)"
curl -s -X POST "$AGENT_URL/invoke" \
-H 'content-type: application/json' \
-d '{"message":"Hello!"}'
Use the public URL for plain invoking — from a web page, say, or from a webhook, which is another service calling your URL when something happens on its side.
Anything that touches sessions or memory is different: browsing transcripts, deleting sessions, and memorize all go through the control-plane routes under $CAI_API/v1/agents/{name}/.... Those same routes on the agent's own public URL are locked to the platform's internal credential and will refuse you.
The agent serves two open helper endpoints on its public URL:
GET /healthzreturns{"status": "ok"}when the agent is up.GET /debug/configreturns non-secret configuration — the resolved model, the framework, and whether a model key is present. It is handy for troubleshooting.
Invoke on a schedule
You don't have to be the one calling. A trigger fires a workload on a clock, and an agent is a valid trigger target. So "summarize yesterday's papers every night at 2am" needs no scheduler of your own.
Triggers live on the serverless surface. A cron expression is five fields — minute, hour, day of month, month, day of week — where * means "every". So 0 2 * * * reads as "minute 0 of hour 2, every day".
- platformctl
- curl
- Console
One command, and no endpoint to set:
platformctl serverless triggers create nightly-digest \
--type schedule --target my-agent --target-path /invoke \
--cron '0 2 * * *' --time-zone America/New_York \
--payload '{"message": "Summarize the papers from yesterday."}'
Triggers are project-scoped, so the project id goes in the path as a UUID — not the ?project= query parameter the /v1/agents routes take:
curl -sS -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{
"name": "nightly-digest",
"target": {"service": "my-agent", "path": "/invoke"},
"source": {"type": "schedule",
"schedule": {"cron": "0 2 * * *",
"time_zone": "America/New_York",
"payload": "{\"message\": \"Summarize the papers from yesterday.\"}"}}
}' | jq '.state, .delivery_url'
You should see (HTTP 202):
"pending"
"http://<private-hostname>/invoke"
Open the agent's page and click Add trigger. Pick the schedule source, set the cron expression, time zone, and payload, and set the target path to /invoke.
Three details that decide whether this works:
"path": "/invoke"is required. The default path is/, and your agent serves nothing there. A trigger without this field POSTs to/forever and gets a 404 every time.payloadis the request body, word for word. It is a JSON string holding the invoke body, so it needs the samemessagefield you would send by hand. Each firing starts a fresh conversation unless your payload names asession_id.time_zonedefaults to UTC. Set it, or "2am" will not be 2am where you are.
The trigger sits at state: "pending" for a moment, then reports state: "ready" with ready: true beside it. To find out whether it actually fired, and what happened when it did, read runs on the single-trigger GET. The full field reference, the cron rules, and the other two trigger sources are in the serverless API reference.
Cold starts
Idle agents scale to zero: they run nothing at all until the next request arrives, and you pay nothing to keep them around.
The first invoke after an idle period causes a cold start — the delay while the platform builds a running copy of your agent from scratch. It finds a machine, starts the harness, and imports your code, all before it can answer. Expect that first reply to take noticeably longer than later ones.
The whole invoke, cold start included, has to finish inside the invoke timeout, which is 60 seconds by default. So an agent with slow imports can time out on its first call and succeed on the second. See autoscaling and scale-to-zero.
Limits
| Limit | Value |
|---|---|
| Request body | 1 MiB |
| Response body (buffered by the control plane) | 32 MiB — larger replies fail with 502 |
| Invoke timeout | 60 s default (platform-configurable via INVOKE_TIMEOUT_SECONDS) |
Anonymous self-chosen session_id | at least 24 characters |
session_id charset | Invoke does not check it. The memorize and session-browsing routes do: a session id in one of their URLs must match ^[A-Za-z0-9_.-]{1,128}$, or you get 400 invalid session id. Stay inside that set from the start |
user_id charset | ^[A-Za-z0-9_.:@-]{1,128}$ |
Status codes
All errors use the envelope {"error": "<message>", "request_id": "<id>"}.
| Code | Meaning | Example message |
|---|---|---|
| 400 | Bad request | message is required |
| 401 | Auth needed | the memorize message above, or when the platform runs with INVOKE_AUTH_REQUIRED=true |
| 404 | No such agent (or not visible to you) | unknown agent: my-agent |
| 409 | Ambiguous name | the agent name my-agent exists in more than one project; add ?project=<slug> to say which one, or sign in so it resolves within your project |
| 502 | The agent failed or timed out | invoke agent my-agent: ... |
Next steps
- Sessions — how conversation history works.
- Long-term memory — teach an agent facts it keeps forever.
- Built-in tools — what
run_pythonandsearch_memorydo. - Embedded chat widget — let visitors invoke this agent from your own website, with no credentials of their own.
- Troubleshooting — real error messages and fixes.