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":{…}}
typeis a UI-message chunk type (start,text-start,text-delta,text-end,finish, …) plus lifecycle events (e.g.session.status_idle).payloadis the chunk body. Fortext-delta,payload.deltais the token text.seqis a per-stream, 1-based counter (see resume).idis a unique envelope id used for client-side de-duplication across reconnects.
Openers & keep-alive.
- A
: connectedcomment primer (~2 KB) is sent first to flush buffering readers (notably iOSNSURLSession/XHR, which withholds a streaming body until ~1–2 KB). - A
: pingheartbeat 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:
- Call a mint endpoint (authenticated + ownership-checked):
GET /api/v1/executions/:id/stream-token→{ sseUrl }. sseUrlcarries?ts=<now>&sig=<hmac>— an HMAC over the resource id + a timestamp, valid for 5 minutes.- Open
sseUrldirectly. 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 setReferrer-Policy: no-referreron 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
| Surface | Open | Reconnect / resume | Auth |
|---|---|---|---|
| Execution | GET /api/v1/executions/:id/stream-token → open the sseUrl | reopen the sseUrl with &lastSeq=<n> | signed URL |
| Chat | POST /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 turn | x-auth-token |
| Workflow | GET /api/v1/workflows/runs/:runId/events | reconnect and re-read; the run’s current state is emitted on connect | x-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.