API
Invocations
Everything to do with running an agent or a workflow: starting one, watching what it emits, letting a browser watch it too, answering it when it pauses, and reading it back afterwards.
An agent and a workflow start the SAME way
One route, no kind field, no separate endpoint. An agent gets its instruction in text; a workflow gets its launch inputs in inputs and still needs text as a readable one-liner. The start endpoint below has a sample of each — the picker says which shape it is showing.
There are TWO kinds of pause, answered in two places
A WORKFLOW pauses at a node asking for an input, and POST /v1/invocations/{invocation_id}/resume below answers it. A TOOL pauses for a confirmation, an approval, or a long-running call your own backend owes — those are answered through the Autonomy API, which is also where the inbox and the policies that decide what ever reaches it live. GET /v1/runs?status=paused is the one call that sees both.
What differs AFTER the start is where you read progress
Both emit the same event stream, and node identity inside a workflow is in node_info.path rather than the event author — only model nodes set one. For a workflow there is also a per-node projection with its pause, GET /v1/projects/{project}/sessions/{workflow}/{session_id}/run, on the Sessions page.
A run and a conversation are read back in different places
GET /v1/runs/{invocation_id} is the record of one RUN — how it ended, what it cost, whether it is waiting. What was SAID across a conversation is ADK's own record, read from your agent's database through the Sessions API, in the same event shape this page's socket delivers.
Two hosts, one surface
Starting, resuming, the socket, tickets and the approval routes are served by rt-service; reading runs and conversations back is served by deploy-service. They share one hostname, split by path, so a caller never addresses them separately — but a browser session only authenticates against the second.
The samples use $API, $TOKEN and, for the socket, WS, which the API overview sets.
Start an invocation
POST/v1/invocationsrt-service
Starts one run of an agent or workflow and returns immediately. The result arrives on the event stream, not in this response.
AN AGENT AND A WORKFLOW ARE THE SAME ROUTE, and differ in two fields
There is no separate endpoint and no kind to send. An AGENT gets its instruction in text. A WORKFLOW gets its launch inputs in inputs — and text is still required, as a readable one-liner rather than a prompt, because a run's title in any list is projected out of it. Sending inputs to an agent is accepted and ignored; omitting them for a workflow is not.
You never send nulls — an omitted input is filled from its default
Every declared input always resolves to a value in session state, because an OPTIONAL input is refused at manifest time unless it declares a default. So omit what you have no value for: an explicit null is treated identically. The reason it works that way is that a node reads its parameters out of session state, where a missing key RAISES rather than arriving as None — a hazard the platform closes at build time rather than pushing onto you. node_input is a reserved name and cannot be a declared input.
SENDING SOME INPUTS AND SENDING NONE ARE DIFFERENT MODES
Supply any inputs and requiredness is enforced: a missing one fails the run with input 'headcount' is required. Supply NO inputs and it is a FREE-TEXT launch instead — requiredness is not enforced, only defaulted inputs reach state, and each node's own Python default covers the rest. That is what keeps scripts and pre-declaration callers working, and it means a half-filled form is stricter than an empty one.
inputs is passed through unvalidated, and validated in the pod
rt-service holds no manifests by design, so a wrong or missing input is not a 422 here — the run starts, the runtime checks them against the declaration and names the offending field on the event stream. Watch the run to see it.
Which one is a workflow? manifest.kind, on GET /v1/agents
That route, on the Projects page, reports each target's manifest.kind and, for a workflow, manifest.input_names — its declared inputs by name. Their types, defaults and requiredness are served by no route; they are in the manifest you wrote.
state_delta seeds THIS conversation's state, on this turn
It is ADK's own run_async(state_delta=...): the keys are written on the run's own user event, so the agent reads them from the first model call and they stay in the conversation afterwards. Conversation keys only — app: reaches every user of the agent, user: every conversation of that user, temp: is never kept and agentkit: is the platform's — so each is refused with a 400 naming the key. 64 KiB serialized at most. A key that is also one of a WORKFLOW's launch inputs is refused too, but by the runtime, which holds the manifest — so that one arrives as an error event on the stream rather than as a 400. Pass it in inputs.
run_config is an allowlist of two
max_llm_calls can only LOWER the agent's own limit — the lower of the two applies, because that limit is the agent author's bound on what a run may cost. custom_metadata is merged by ADK into every event the run emits, live and stored, at payload.custom_metadata — the event's own keys win. Anything else is refused by name: streaming_mode because partial events would multiply what is published and billed per run, and the rest because they configure ADK's live audio sessions, which a queued run is not.
An agent deployed before these options must be redeployed once
An invocation with neither is byte-for-byte what it always was. One carrying either reaches an agent image that predates them as a message it cannot parse, which is dead-lettered: your socket sees queued and then nothing. Redeploy the agent and it accepts both.
end_user_id sends the events somewhere else
Naming one addresses the run's events to that person, so your own socket receives NOTHING for a run you started on their behalf — their browser watches it with a ticket. A process that invokes and watches at once will look broken.
Subscribe before you invoke
Events published to a topic with no subscriber are discarded, and the subscription is created when your first socket opens. Invoke first and you lose the reply — on a warm agent, that is the whole run.
curl -X POST "$API/v1/invocations" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{
"target_slug": "hotel-concierge",
"project": "salmon",
"session_id": "web-9a3f1b2c4d5e",
"text": "Is late checkout available?"
}'Body
| Field | Type | Description | |
|---|---|---|---|
| target_slug | string | required | The agent OR workflow. Never a group — grouping is operational and never addressed. |
| project | string | optional | Always send it. The model defaults it to empty, and empty means the PLATFORM's own project, which you do not own: a project-scoped credential is refused with 403, and any other gets a bare 502. Both SDKs refuse an empty project before sending. |
| session_id | string | required | Yours to choose. The conversation this turn belongs to. |
| text | string | required | For an AGENT, the message. For a WORKFLOW, a readable one-liner — the run's title is projected out of it. Required and non-empty for both. |
| inputs | object | optional | A WORKFLOW's launch inputs, as declared in its manifest. Ignored for an agent. Omit what you have no value for — defaults are filled in. Send any, and every REQUIRED input must be among them. |
| end_user_id | string | optional | One of YOUR end users. Makes the run theirs — their wallet pays, their autonomy policy applies, a confirmation goes to them. Needs a tenant admin. |
| state_delta | object | optional | Keys to write into this conversation's state on this turn. No app:, user:, temp: or agentkit: prefix; at most 64 KiB serialized. |
| run_config | object | optional | max_llm_calls (a positive integer; can only lower the agent's own limit) and custom_metadata (an object, at most 64 KiB). Nothing else is accepted. |
Responses
| Status | Meaning |
|---|---|
| 202 | Queued. Nothing is running yet; the reply arrives on the socket. |
| 400 | An end_user_id that is * or contains : or /, or a refused state_delta key or run_config field, named in the message. |
| 401 | No credential, or one that does not verify. |
| 402 | Your account is suspended for an unpaid balance. Settling it restores service. |
| 403 | A ticket; a project-scoped credential naming another project, or naming none; or end_user_id without a tenant admin. |
| 404 | An unknown target — only on a deployment that keeps an allowlist of targets. Without one, an unknown target is a 502. |
| 422 | A body that does not match the model: target_slug, session_id or a non-empty text missing, a wrong type, or JSON that does not parse. Unknown fields are ignored, not refused. |
| 502 | Could not queue: an unknown target, or an empty project from a credential that is not project-scoped. |
Response body
202 {
"invocation_id": "f27bfbb4a1c34e...",
"message_id": "14029384710293"
}Watch the event stream
WS/wsrt-service
The live events of the runs that belong to you, filtered to the sessions you subscribe to. This is where a reply actually arrives.
ticket takes a ticket or your own token
A browser connects with a ticket from one of the two routes below; a backend can pass its own bearer token as ticket. An event reaches the socket only if its run belongs to the same subject as the credential — so a run started with end_user_id reaches that user's ticket and never your token.
A paused run emits no terminal event
It emits two paused events and no finished one, because the pause IS where it stopped. A reader waiting for a terminal kind waits out its whole timeout. Only one of the two carries interrupt_id — that key is the discriminator.
# Ends by itself at the run's terminal event — including a pause.
async with elan.runtime.watching("sess-1") as stream:
async for event in stream.events(invocation_id=invocation_id):
print(event.kind, event.text)Responses
| Status | Meaning |
|---|---|
| 101 | Switching protocols. ready arrives unprompted. |
| 1008 | Refused at the handshake: no ticket parameter, one that does not verify, or a ticket presented from an origin it was not minted for. The socket is never accepted. |
Response body
// connect with the credential on the handshake:
// wss://<host>/ws?ticket=<jwt>
// you send // you get
{"type": "ready", "user_id": "..."}
{"type": "subscribe", "session_id": "..."} {"type": "subscribed", "session_id": "..."}
{"type": "unsubscribe", "session_id": "..."}
{"type": "ping"} {"type": "pong"}
// then AgentEventEnvelope objects. Filter on invocation_id.Read a run back
GET/v1/runs/{invocation_id}deploy-service
The durable record of one run: whose it was, how it ended or whether it is still waiting on somebody, and every event it emitted.
The stream is live, this is durable
Nothing buffers events for a socket that is not open, so a client that disconnected has lost them. This is the read path — it is also the only one a browser session can authenticate against.
It says that a run paused, not what for
interrupt_id is set while status is paused. Which kind of pause it is — an input, a confirmation, a tool call — is interrupt_kind on the last paused event in events. What the run cost is GET /v1/usage/invocations/{invocation_id} on the Usage page.
curl "$API/v1/runs/$INVOCATION_ID" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| project | query | optional | Look only in this project. A project-scoped credential is held to its own whatever it sends. |
Responses
| Status | Meaning |
|---|---|
| 200 | The run, including its current status. Counted as one billable read. |
| 401 | No credential, or one that does not verify. |
| 404 | No such run in a project this credential may see. |
| 501 | This deployment keeps no run records. |
| 502 | The run record could not be read. |
Response body
{
"invocation_id": "f27bfbb4a1c34e...",
"target_slug": "hotel-concierge",
"target_version": "1.4.0",
"tenant_id": "acme",
"project_id": "salmon",
"user_id": "eu:ada",
"session_id": "web-9a3f1b2c4d5e",
"interrupt_id": "int-77",
"status": "paused",
"error": null,
"started_at": "2026-10-17T09:14:02.481000+00:00",
"finished_at": null,
"events": [
{"seq": 1, "segment": "", "kind": "started", "payload": {},
"emitted_at": "2026-10-17T09:14:02.481000+00:00"}
]
}List runs
GET/v1/runsdeploy-service
Recent runs for this tenant, newest first. Filterable by project, target, conversation and status.
status=paused answers "what is waiting on me"
It covers BOTH pause kinds — a tool confirmation and a workflow input. The actions inbox holds only confirmations, so it cannot.
A listed run carries no interrupt
The rows are summaries, without interrupt_id or events. Read a paused run with GET /v1/runs/{invocation_id} to get what it is waiting on.
curl "$API/v1/runs?project=salmon&status=paused&limit=25" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| project | query | optional | Narrow to one project. Omitted, every project this credential may see. |
| target | query | optional | One agent or workflow slug. |
| session_id | query | optional | One conversation's runs. |
| status | query | optional | running, paused, finished or error. A run is running from its first event; nothing is recorded while it is only queued. |
| limit | query | optional | Page size. Default 25, clamped to 1–200. |
| offset | query | optional | Rows to skip. Default 0. |
Responses
| Status | Meaning |
|---|---|
| 200 | A page of runs, plus the total. Counted as one billable read. |
| 401 | No credential, or one that does not verify. |
| 404 | A project-scoped credential naming another project. |
| 422 | A project or target that could not be a slug. |
| 501 | This deployment keeps no run records. |
| 502 | The run records could not be read. |
Response body
{
"runs": [
{
"invocation_id": "f27bfbb4a1c34e...",
"target_slug": "invoice-triage",
"target_version": "2.0.1",
"tenant_id": "acme",
"project_id": "agents",
"user_id": "u_01HQ2K8ZJ1",
"session_id": "web-9a3f1b2c4d5e",
"status": "paused",
"event_count": 31,
"started_at": "2026-09-28T09:14:02.481000+00:00",
"finished_at": null,
"error": null
}
],
"total": 1
}Mint a ticket for one of your end users
POST/v1/ticketsrt-service
Exchanges your own credential for one scoped to a single end user, a single origin and a few minutes — so a browser can open the event stream without ever holding your service secret.
A ticket may open the socket and do NOTHING else
rt-service refuses a ticket on every HTTP route, including this one — so a ticket cannot invoke, cannot list, cannot decide and cannot mint another. That is a capability which is not expressible rather than a check somebody could forget to write.
So the browser WATCHES and your backend ACTS
The split is the design: your backend starts the run with end_user_id set, the browser watches it with a ticket. If you need the person to approve something, the decision goes through your backend, not from the page holding the ticket.
It is pinned to the origin you name
A ticket presented from any other web origin is refused, which is what makes a leaked one useless. Name the origin the browser will actually connect from, scheme and host.
Needs a tenant admin, and the project matters
Issuing a credential on behalf of your user is an administrative act; a service user with the admin role is the intended caller. The wallet is keyed on (project, end_user_id), so ada in one of your products is not ada in another — and a credential scoped to one project cannot mint a ticket for another's customer. The event stream is keyed on the person, though, not the project: the project a ticket carries does not filter what it receives, so ada's ticket from one project can watch a session ada has in another.
curl -X POST "$API/v1/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"end_user_id": "ada",
"project": "salmon",
"origin": "https://app.acme.example"
}'Body
| Field | Type | Description | |
|---|---|---|---|
| end_user_id | string | required | YOUR id for this person. Never a platform user id. No *, no : or /. |
| origin | string | required | The web origin the browser will connect from, e.g. https://app.acme.example. The ticket is pinned to it. |
| project | string | optional | Which of your projects this person is a customer of — their wallet is keyed on it. Empty means the platform's own. |
Responses
| Status | Meaning |
|---|---|
| 201 | Minted. Carries the ticket, expires_in and end_user_id. |
| 400 | A rejected end_user_id or origin. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket, not a tenant admin, or a project the credential is not scoped to — an empty project included. |
| 501 | This deployment issues no tickets: no signing secret configured. A configuration, not a fault. |
Response body
201 {
"ticket": "eyJhbGciOi...",
"expires_in": 300,
"end_user_id": "ada"
}Mint a ticket for your own stream
POST/v1/tickets/selfrt-service
Exchanges your own credential for a ticket to YOUR OWN event stream — one project, one origin, a few minutes — for a page that cannot put your token on a socket.
Why there is a second kind of ticket
The socket delivers an event only to the person the run belongs to. An end user's ticket is eu:<id>, and a run you start AS YOURSELF belongs to your own subject — so no end-user ticket can ever watch it. This one carries your own subject. It is what the console's Playground uses: somebody signed in by cookie, talking to their own agent.
Anybody may mint one, and only for themselves
There is no field naming whose: the subject is the verified caller's own, so the ticket reaches nothing your token does not already reach — and less. Like every ticket it is never an admin, is refused on every HTTP route, and cannot mint another.
It is pinned to the origin you name
Presented from any other web origin it is refused at the socket's handshake. Name the origin the browser will actually connect from, scheme and host.
curl -X POST "$API/v1/tickets/self" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"project": "salmon", "origin": "https://app.acme.example"}'Body
| Field | Type | Description | |
|---|---|---|---|
| origin | string | required | The web origin the browser will connect from. The ticket is pinned to it. |
| project | string | optional | The project whose runs the page will watch. Checked against the credential's own scope and carried on the ticket. Empty means the platform's own. |
Responses
| Status | Meaning |
|---|---|
| 201 | Minted. Carries the ticket and expires_in. |
| 400 | An empty origin. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket presented here, or a project the credential is not scoped to. |
| 422 | An unknown field — including any attempt to name whose ticket it is. |
| 501 | This deployment issues no tickets: no signing secret configured. |
Response body
201 {
"ticket": "eyJhbGciOi...",
"expires_in": 300
}Resume a paused run
POST/v1/invocations/{invocation_id}/resumert-service
Answers a workflow that paused at a node asking for input, with what the person supplied, and lets it continue.
Only a node's request for input
Whatever the run is waiting for, this route publishes its answer as the reply to a node's input request — it does not check the kind of pause. A run paused on a tool confirmation, an approval or a long-running tool call is answered through the Autonomy API instead; resuming one here does not answer it.
payload is for data, not consent
A node that asked for a corrected address, a chosen option or a reference is answered with one. approved, note and decided_by are the service's and cannot be set through it — a decision signed by whoever typed the request body is not signed at all.
A resume is the same invocation
The run keeps its invocation id, so one query over its events reads both halves. It replays the completed prefix without re-running it: zero extra model calls, but the events you already saw arrive again.
curl -X POST "$API/v1/invocations/$INVOCATION_ID/resume" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"approved": true, "payload": {"cost_centre": "CC-3317"}}'Body
| Field | Type | Description | |
|---|---|---|---|
| approved | boolean | required | The decision. A REJECTION ALSO RESUMES the graph — it is a decision, not a cancellation. |
| note | string | optional | Up to 1000 characters, recorded with the decision. |
| payload | object | optional | What the human supplied. Becomes the paused node's output and flows to the next node as its input. |
Responses
| Status | Meaning |
|---|---|
| 202 | Accepted. The run continues on the same invocation id. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket, or you neither started the run nor are a tenant admin. |
| 404 | No such run, for you — including one recorded in a project the credential is not scoped to. Authorization is against the RECORDED run, so guessing an id gets nothing. |
| 409 | The run is not paused, or is paused with no interrupt recorded. |
| 422 | A missing approved, a note over 1000 characters, or an unknown field. {"approved": true, "result": {…}} is refused rather than silently dropping your result. |
| 502 | The resume could not be queued. |
| 503 | The run record could not be read, so the resume could not be authorized. |
Response body
202 {
"invocation_id": "f27bfbb4a1c34e...",
"message_id": "14029384710293"
}