Concepts
Events & the stream
A task takes ninety seconds. Your customer is looking at the screen for all ninety of them. The event stream is what you put there — and because "watching the work happen" is most of the perceived value of an AI service, it is closer to the product than to plumbing.
The design choice that matters: events are appended to a durable log before they are streamed. The in-process emitter is a latency optimisation, not the transport. That is why replay is exact, why a second replica can serve a stream for a run it is not executing, and why there is no message broker in the stack.
Subscribe
GET /v1/tasks/:id/events Authorization: Bearer <key> Last-Event-ID: 42 # optional — replay everything after event 42
Each frame's SSE event: is the event type and data: is the whole TaskEvent:
{ id: 7, task_id: "…", seq: 7, type: "tool.called",
ts: "2026-01-01T12:00:00.000Z", data: { … } }id is monotonic per task, starting at 1, and is also the SSE event id — so Last-Event-ID works with no bookkeeping on your side. The server replays from the log first, then switches to live, deduplicating on id so the seam is invisible.
The thirteen event types
| Type | Render it as |
|---|---|
task.created | The run has started. Always id 1. |
step.started | A phase label |
tool.called / tool.result | The activity timeline — the most convincing thing on the screen |
progress | Reasoning and notes. Missing knowledge is reported here as Missing knowledge: … |
token.delta | Streamed text. Append, do not replace |
approval.requested | An editable card built from data.preview. The run is blocked |
approval.resolved | Decision, plus policy_reason when it was auto-approved |
output.validated | { ok, errors }. ok: false means the task is about to fail |
artifact.created | A deliverable, fetchable at GET /v1/artifacts/:id |
cost.charged | Running spend. Estimated, not billed — see below |
feedback.recorded | A correction was captured |
task.finished | Terminal. Always last |
Cost is an estimate from a small hardcoded rate table, not a provider invoice. Show it as a running figure, not as what you bill.
Order, honestly
task.created step.started tool.called / tool.result / token.delta / progress / cost.charged (interleaved) approval.requested ← status becomes awaiting_approval, run blocks approval.resolved ← status returns to running output.validated artifact.created task.finished
Two details that will bite a UI written against the diagram alone:
task.createdis emitted after the sandbox is provisioned, not when you posted the task. BetweenPOST /v1/tasksand the first event there can be several seconds of nothing. Render the queued state from the task, not the stream.cost.chargedarrives during the run, interleaved with tool calls — not at the end.
Statuses
queued → provisioning → running ⇄ awaiting_approval → succeeded | failed | rejected | expired | cancelled.
awaiting_approvalis the only pause. It is the only status where the system is waiting on a person- The five terminals are final; non-success ones carry
task.error validatingexists in the type but nothing sets it today. Do not switch on it- A rejected approval ends the task as
rejected. An unattended one ends it asexpired
Rules for clients
- Ignore unknown event types. The contract is additive — new types will appear and your UI must not break
- Render from the stream. Do not build a parallel polling loop; use
GET /v1/tasks/:idonly to reconcile after a gap - Reconnect with
Last-Event-ID. Without it you will miss an approval request and the run will expire while your UI shows nothing - Treat
task.finishedas terminal even if you missed everything before it
The client-facing stream is deliberately narrower
GET /v1/portal/tasks/:id/events, authenticated with a client session, serves the same log through an allowlist: task.created, step.started, tool.called, tool.result, progress, output.validated, artifact.created, task.finished.
Stripped out: cost.charged, token.delta, approval.requested, approval.resolved, feedback.recorded. Your customer sees the work, not your margin and not the moment you overruled the agent. The filter applies to replay as well as live, so history cannot leak what the live stream hides.
Failure modes
| Symptom | Cause and fix |
|---|---|
| Long silence on an open stream | Normal — there is no heartbeat frame. Proxies that time out idle connections will cut it; your client should reconnect with Last-Event-ID rather than assume failure. |
An error frame saying stream overflow — reconnect with Last-Event-ID | The consumer fell more than 5000 events behind and the server closed the stream. Do exactly what it says. |
| Nothing arrives, ever | A wrong or out-of-scope task id returns 404, not an empty stream. Check the response status before opening an EventSource. |
| Events arrive in bursts of up to 400ms | You are connected to a replica that is not running the task; it polls the durable log. Correct, just less smooth. |
| Stream ends abruptly, task shows failed | The kernel restarted. There is no mid-run resume — interrupted tasks are failed with interrupted by a restart. |
What to read next
Integrating a frontend — proxying this stream without buffering it, which is the one mistake everybody makes first.