Real-time streaming

Clanker streams live output to clients over Server-Sent Events (SSE). Three product surfaces stream: skill execution, chat, and workflow runs. They share one wire contract. This page is the single reference for all three — the frame format, the auth model, and resume/reconnect.


The streaming contract

Transport. text/event-stream. Each event is one frame:

id: <seq>
data: {"seq":<n>,"id":"<uid>","type":"<chunk-type>","payload":{…}}
  • type is a UI-message chunk type (start, text-start, text-delta, text-end, finish, …) plus lifecycle events (e.g. session.status_idle).
  • payload is the chunk body. For text-delta, payload.delta is the token text.
  • seq is a per-stream, 1-based counter (see resume). id is a unique envelope id used for client-side de-duplication across reconnects.

Openers & keep-alive.

  • A : connected comment primer (~2 KB) is sent first to flush buffering readers (notably iOS NSURLSession/XHR, which withholds a streaming body until ~1–2 KB).
  • A : ping heartbeat every 10 s keeps the connection alive through long cold-starts. Comments are invisible to the SSE parser.

Terminal. A terminal frame (e.g. session.status_idle) is always emitted on completion — even for a zero-output run — so an early joiner never hangs.

Resume. Reconnect with ?lastSeq=<n>; the server replays seq > lastSeq then goes live. A fresh connect uses lastSeq=0 (full replay). Clients de-dup by envelope id, so a full replay is always safe.

Client transport split. Browsers use fetch + ReadableStream; React Native uses XMLHttpRequest.onprogress. Native EventSource cannot set headers, which is why an execution stream is authorized by a signed URL (below) rather than a bearer header.


Auth model — signed-URL capability

Execution streams are authorized by a short-lived signed URL, not a session cookie or a long-lived key:

  1. Call a mint endpoint (authenticated + ownership-checked): GET /api/v1/executions/:id/stream-token{ sseUrl }.
  2. sseUrl carries ?ts=<now>&sig=<hmac> — an HMAC over the resource id + a timestamp, valid for 5 minutes.
  3. Open sseUrl directly. The signed URL IS the capability — scoped to one resource, expiring in minutes. Ownership is enforced at mint time.

A server-minted, single-purpose, short-TTL credential is the correct way to let a browser talk to a streaming endpoint directly — no long-lived account key ever reaches the client.

Query-string credential hygiene. A signed URL can leak via Referer, browser history, and access logs. Mitigations: short TTL, and set Referrer-Policy: no-referrer on the app.

Chat and workflow-run streams are authorized by your normal session/API-key (x-auth-token / x-api-key) — no signed URL.


The three surfaces

SurfaceOpenReconnect / resumeAuth
ExecutionGET /api/v1/executions/:id/stream-token → open the sseUrlreopen the sseUrl with &lastSeq=<n>signed URL
ChatPOST /api/v1/chat/:agentId (streams the turn; response header x-clanker-run-id)GET /api/v1/chat/:agentId/stream?runId=<>&threadId=<>&offset=<n> — replays the turn’s chunks from offset and follows the live tail, so a dropped connection doesn’t lose the turnx-auth-token
WorkflowGET /api/v1/workflows/runs/:runId/eventsreconnect and re-read; the run’s current state is emitted on connectx-auth-token

For execution and chat, the run is decoupled from the viewer’s connection: a dropped connection (or a client crash) doesn’t kill the run, and a reconnect replays what was missed and continues live.


Client recipe (execution)

// 1. Mint a short-lived signed stream URL (authenticated + ownership-checked).
const { sseUrl } = await fetch(
  `${BASE_URL}/api/v1/executions/${executionId}/stream-token`,
  { headers: { "x-auth-token": token } },
).then((r) => r.json());

// 2. Open it. Auth is in the URL, so EventSource works with no custom headers.
const es = new EventSource(sseUrl); // full replay from seq 1, then live
let lastSeq = 0;
es.onmessage = (e) => {
  const { seq, type, payload } = JSON.parse(e.data);
  lastSeq = seq;
  if (type === "text-delta") appendToOutput(payload.delta);
  if (type === "session.status_idle") es.close(); // terminal
};

// 3. Reconnect gap-free by resuming from the last seq seen (dedup by envelope id).
es.onerror = () => reopen(`${sseUrl}&lastSeq=${lastSeq}`);

See api/executions.md and api/workflows.md for each surface’s endpoints, and the API overview for the chat endpoints. They share the frame format above.