Stream output
You want a route's response to arrive incrementally rather than as a single blob at the end. Here's how to call /threads/:id/runs/stream and read the SSE frames.
The code
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: "/research#agent",
input: { query: "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) {
const lines = frame.split("\n")
// Every SSE frame has 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 to display.
process.stdout.write(payload.content ?? "")
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.tool_call, subagent.tool_result,
// subagent.message, subagent.end, etc.
console.log(`[${eventType}]`, payload)
}
}
}SSE event types
Every frame is event: <type>\ndata: <json>\n\n. Parse the event: line to route frames correctly — do not treat all frames as the same type.
| Event type | Payload shape | Notes |
|---|---|---|
chunk | { content: string } | Streamed text fragment from the LLM. |
tool_call | { name: string, input: unknown } | LLM is calling a tool. |
tool_result | { name: string, output: unknown } | Tool returned a result. |
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 | { name, routeId, depth, call_id } | A subagent started. |
subagent.tool_call | { call_id, name, input } | Tool call inside a subagent. |
subagent.tool_result | { call_id, name, output } | Tool result inside a subagent. |
subagent.message | { call_id, content } | Text chunk from a subagent. |
subagent.end | { call_id, final_message?, error? } | Subagent finished or failed. |
done | { output: unknown } | Run complete; output is the final route result. |
Notes
- AP URL shape. The endpoint is
/threads/:id/runs/stream; the body is{ route, input }. Create or retrieve a thread id viaPOST /threadsfirst. text/event-stream, not JSON. The response is raw SSE. Parseevent:anddata:lines per frame, or use a client likeeventsource-parser.- Use
/threads/:id/runs/waitwhen you don't need progress. Streaming adds parsing complexity for callers. Pickruns/streamwhen partial output is meaningful — long agent reasoning, token-by-token text, intermediate workflow states, planning updates, or subagent activity. - Retry has limits during streams. Once a token has been emitted, the response is committed and cannot be retried. See Retry for the streaming caveat.