Stream Output
A research run can take a minute or more. Let's stream its output as it arrives by calling /threads/:id/runs/stream and reading the SSE frames:
scripts/ stream-research.ts
// 1. Create a thread first (or reuse an existing one).
const threadRes = await fetch("http://127.0.0.1:3001/threads", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
const { thread_id } = (await threadRes.json()) as { thread_id: string }
// 2. Start a streaming run on that thread.
const res = await fetch(`http://127.0.0.1:3001/threads/${thread_id}/runs/stream`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
route: "/navlog#agent",
input: { messages: [{ role: "user", content: "latest LLM benchmarks" }] },
}),
})
if (!res.ok || !res.body) {
throw new Error(`stream failed: ${res.status}`)
}
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += value
// SSE frames are delimited by a blank line.
const frames = buffer.split("\n\n")
buffer = frames.pop() ?? ""
for (const frame of frames) {
// Ignore SSE comments (for example, the `: ping` heartbeat) first.
const lines = frame.split("\n").filter((line) => !line.startsWith(":"))
// Event frames have an "event:" line and a "data:" line.
const eventLine = lines.find((l) => l.startsWith("event: "))
const dataLine = lines.find((l) => l.startsWith("data: "))
if (!eventLine || !dataLine) continue
const eventType = eventLine.slice("event: ".length)
const payload = JSON.parse(dataLine.slice("data: ".length))
switch (eventType) {
case "chunk":
// Streamed text fragment: append it to the display.
if (typeof payload === "string") process.stdout.write(payload)
break
case "tool_call":
console.log(`\n[tool_call] ${payload.name}`, payload.input)
break
case "tool_result":
console.log(`[tool_result] ${payload.name}`, payload.output)
break
case "plan_update":
console.log("[plan_update]", payload)
break
case "interrupt":
console.log("[interrupt]", payload)
break
case "done":
console.log("\n[done]", payload.output)
break
default:
// subagent.start, subagent.token, subagent.tool_call,
// subagent.tool_result, subagent.end, etc.
console.log(`[${eventType}]`, payload)
}
}
}SSE event typesCopy link to section: SSE event types
Every event frame is event: <type>\ndata: <json>\n\n. Parse the event: line to tell the frame types apart.
Every subagent.* payload carries the child's identity, written …identity below: subagent (its name), route_id and depth. Apart from that identity, a child event has exactly the shape of the root event of the same name.
| Event type | Payload shape | Notes |
|---|---|---|
chunk | string | Streamed text fragment from the model. |
tool_call | { name: string, input: unknown } | The model is calling a tool. |
tool_result | { name: string, output: unknown } | A tool returned a result. A tool that threw still emits one, and output is the status: "error" ToolMessage the model receives. |
plan_update | plan object | Emitted after a Planning writeTodos result updates the route's todo state. (Does not write back to plan.md.) |
interrupt | interrupt object | Emitted when the agent hits a HITL interrupt point. |
subagent.start | { call_id, parent_call_id?, subagent, route_id, depth, description? } | A subagent started. parent_call_id names the call that dispatched it when the parent is itself a subagent. |
subagent.token | { call_id, …identity, data, messageId } | Text from the child, framed like root chunks: messageId is the child's model invocation. |
subagent.reasoning | { call_id, …identity, data, messageId } | The child's visible reasoning, when its route streams it. |
subagent.message_end | { call_id, …identity, messageId } | The child's model invocation ended. |
subagent.tool_call | { call_id, …identity, id, name, input } | Tool call inside a subagent, under the model's tool-call id. |
subagent.tool_call_args | { call_id, …identity, id, name, delta } | Streamed arguments for that call, for display only. |
subagent.tool_result | { call_id, …identity, id, name, output } | The result, under the same id. |
subagent.usage | { call_id, …identity, provider?, model?, usage_metadata } | One child model call's token counts. |
subagent.end | { call_id, …identity, final_message?, error? } | Subagent finished or failed. |
done | { output: unknown } | Run complete. output is the final route result. |
NotesCopy link to section: Notes
- AP URL shape. The endpoint is
/threads/:id/runs/stream, and the body is{ route, input }. Get a thread id fromPOST /threadsfirst. - The response is
text/event-stream. Parse theevent:anddata:lines in each frame, or use a client likeeventsource-parser. - Heartbeats are comments. A
: pingheartbeat has noevent:ordata:line, so clients skip it. - Use
/threads/:id/runs/waitwhen you don't need progress. It returns one JSON result, which is less to parse. Pickruns/streamwhen partial output is useful: long agent reasoning, token-by-token text, intermediate workflow states, planning updates, or subagent activity. - Retry never repeats a token. Each model call is retried only before its response starts. A response that fails after part of it has streamed isn't retried; the error comes through the stream. See Retry.