Skip to main content

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 ready state. If not, start with deploy an agent.
  • The curl examples read the API endpoint from $CAI_API; set it to https://api.codyhill.dev. The platformctl examples need no setup at all — the CLI uses that address by default.
export CAI_API=https://api.codyhill.dev
Invoking is open by default

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

FieldTypeRequiredDefaultNotes
messagestringyesThe user's message. Blank or missing returns 400 message is required.
session_idstringnoplatform-minted UUIDReuse it to continue a conversation. Anonymous callers who pick their own must use at least 24 characters.
user_idstringnoderived from the session idMust match ^[A-Za-z0-9_.:@-]{1,128}$. Omit it and the platform derives a stable one.
memorizebooleannofalseAlso commit this session to the memory bank. Requires authentication.

Response fields

FieldTypeNotes
session_idstringAlways filled — save it to continue the conversation.
user_idstringAlways filled — the caller identity the session belongs to.
outputstringThe agent's answer.
reasoningstringThe agent's thinking text, when the model produced any.
tool_callsarrayOne entry per tool the agent used: {"name": "run_python", "summary": "called with args={...}"}.
eventsarrayThe raw framework events for the turn (verbose; useful for debugging).

Invoke

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.
  • --memorize also writes the conversation to the memory bank. Sign in with platformctl login first.
  • There is no --user flag. The platform works the user id out from the session id.
  • -o json prints {"session_id", "response", "tool_calls"} instead of the human-readable form.

The CLI waits up to 5 minutes for a reply.

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.

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.

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:

typeWhat it means
thinkingA delta of the agent's reasoning text
outputA delta of the answer itself
block_endA thinking or output block just finished; kind says which
tool_callThe agent is calling a tool, with the args it chose
tool_resultThat tool returned, with its result
doneThe turn is over; carries the final session_id and user_id
errorThe 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-Id and X-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_EXTERNAL is the auth mode, not a boolean: apikey, jwt, or none. A bare true is refused, because an address published without an explicit protection decision is the hole this contract exists to close.
  • CAI_EXPOSE_RATE_LIMIT is required whenever exposure is on, written N/second, N/minute, N/hour or N/day. A public invoke path with no budget is an unbounded spend of your own model key.
  • jwt mode needs CAI_EXPOSE_JWT_ISSUER and CAI_EXPOSE_JWT_JWKS_URI as well, plus an optional comma-separated CAI_EXPOSE_JWT_AUDIENCES.
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.

A bad exposure value answers 200 and then quietly unpublishes the agent

The 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 /healthz returns {"status": "ok"} when the agent is up.
  • GET /debug/config returns 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".

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."}'

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.
  • payload is the request body, word for word. It is a JSON string holding the invoke body, so it needs the same message field you would send by hand. Each firing starts a fresh conversation unless your payload names a session_id.
  • time_zone defaults 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

LimitValue
Request body1 MiB
Response body (buffered by the control plane)32 MiB — larger replies fail with 502
Invoke timeout60 s default (platform-configurable via INVOKE_TIMEOUT_SECONDS)
Anonymous self-chosen session_idat least 24 characters
session_id charsetInvoke 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>"}.

CodeMeaningExample message
400Bad requestmessage is required
401Auth neededthe memorize message above, or when the platform runs with INVOKE_AUTH_REQUIRED=true
404No such agent (or not visible to you)unknown agent: my-agent
409Ambiguous namethe 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
502The agent failed or timed outinvoke agent my-agent: ...

Next steps