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 typePayload shapeNotes
chunkstringStreamed 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_updateplan objectEmitted after a Planning writeTodos result updates the route's todo state. (Does not write back to plan.md.)
interruptinterrupt objectEmitted 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 from POST /threads first.
  • The response is text/event-stream. Parse the event: and data: lines in each frame, or use a client like eventsource-parser.
  • Heartbeats are comments. A : ping heartbeat has no event: or data: line, so clients skip it.
  • Use /threads/:id/runs/wait when you don't need progress. It returns one JSON result, which is less to parse. Pick runs/stream when 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.