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

TypeRender it as
task.createdThe run has started. Always id 1.
step.startedA phase label
tool.called / tool.resultThe activity timeline — the most convincing thing on the screen
progressReasoning and notes. Missing knowledge is reported here as Missing knowledge: …
token.deltaStreamed text. Append, do not replace
approval.requestedAn editable card built from data.preview. The run is blocked
approval.resolvedDecision, plus policy_reason when it was auto-approved
output.validated{ ok, errors }. ok: false means the task is about to fail
artifact.createdA deliverable, fetchable at GET /v1/artifacts/:id
cost.chargedRunning spend. Estimated, not billed — see below
feedback.recordedA correction was captured
task.finishedTerminal. 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.created is emitted after the sandbox is provisioned, not when you posted the task. Between POST /v1/tasks and the first event there can be several seconds of nothing. Render the queued state from the task, not the stream.
  • cost.charged arrives during the run, interleaved with tool calls — not at the end.

Statuses

queuedprovisioningrunning awaiting_approvalsucceeded | failed | rejected | expired | cancelled.

  • awaiting_approval is 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
  • validating exists 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 as expired

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/:id only 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.finished as 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

SymptomCause and fix
Long silence on an open streamNormal — 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-IDThe consumer fell more than 5000 events behind and the server closed the stream. Do exactly what it says.
Nothing arrives, everA 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 400msYou 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 failedThe 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.