Module 16 · 50 min

Streaming and Activity

You can stream an agent's answer into a browser as it is written, show what the agent is doing while it works, and say which models need opting in before a todo list renders at all.

Surface
includePartialMessages · stream_event
Workbench tag
module-16
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30
A heavy grey arm stopped partway through its arc, held in place by a single slender pin.

In module 15 the browser waits in silence and the whole answer lands at once. That works, but it feels dead, nothing moves until the session finishes. This module makes the answer appear as it is written, the way it does in the claude terminal, and adds a line for each tool the agent runs. To stream is to send something in pieces as it is produced instead of whole at the end, and each piece of streamed text is called a delta: the change since the last piece.

The lesson is streaming the lifecycle as well as the words. The agent-loop page describes the shape: the model writes, asks for a tool, the tool runs, the model writes again, and a result closes the session. The text deltas are the smallest part of what streams; the tool events are what tell a person the agent is working and on what.

flowchart TD
subgraph before["at tag module-15"]
  direction LR
  a["session"] --> b["...silence..."] --> c["result"]
end
subgraph now["at tag module-16"]
  direction LR
  d["session"] --> e["activity: Read, start"] --> f["activity: Read, done"] --> g["delta"] --> h["delta"] --> i["delta"] --> j["result"]
end
before ~~~ now
One prompt under streaming. The frames between session and result are new; everything around them is unchanged from the previous tag.

One option turns the stream on

The SDK sends whole messages by default: an assistant message arrives complete or not at all. Setting includePartialMessages: true in the query() options adds a fourth message type to the loop you built in module 14: stream_event, carrying the raw events the model emits as it writes. The one this module reads is the text delta. Record 0030 in the workbench repository cites the exact type declarations the claim rests on.

That new case slots into the same switch the loop has had since module 14:

case "stream_event": {
  // One raw API event. These arrive only because sdk-stream.js asks for
  // them with includePartialMessages; without that option the SDK sends
  // whole assistant messages and nothing else. See docs/decisions/0030.
  const text = textDeltaOf(message);
  if (text !== undefined && text !== "") {
    assistantText.push(text);
    handlers.onDelta?.(text);
  }
  break;
}

Nothing else in the loop changed. The result message still arrives at the end and still carries the complete answer, and that is what makes the next rule possible.

Two new frames on the wire

The protocol file gains two frame types without changing its version number. Version 0 clients ignore unknown types by rule, so a module 15 client against a module 16 server renders exactly what it did before.

delta carries id and text, in order, and concatenating every delta for an id gives a prefix of the final result text, for a single-turn answer. activity carries id, tool, phase (start or done), and a one-line summary the server builds from the tool’s name and its most informative input field:

export function summariseToolUse(tool, input) {
  if (typeof input !== "object" || input === null) return tool;

  for (const field of SUMMARY_FIELDS) {
    const value = input[field];
    if (typeof value !== "string" || value.trim() === "") continue;
    // Newlines would break the one-line promise, so a multi-line value is
    // flattened before it is measured.
    const flat = value.replace(/\s+/g, " ").trim();
    const shown =
      flat.length > COMMAND_LIMIT ? flat.slice(0, COMMAND_LIMIT) + "..." : flat;
    return `${tool} ${shown}`;
  }

  return tool;
}

So a Read on notes/todo.md becomes the line Read notes/todo.md, and a long Bash command is flattened and cut. Record 0031 argues the format: a verb per tool (“Reading”, “Running”) is a second list to maintain and a guess for every tool not on it, while the tool’s own name is the string a reader would search the docs for.

Watch it stream

Check out the tag and run fake mode, no API key, no cost:

git checkout module-16
WORKBENCH_FAKE_SDK=1 npm run dev

Type a prompt at http://localhost:3000. The activity lines appear first, each dimming as its tool finishes, then the answer writes itself in pieces. The fake stream scripts the same sequence a real session produces, so this costs nothing. The demo script shows the same thing as frames:

[recv] {"type":"activity",...,"tool":"Read","phase":"start","summary":"Read notes/todo.md"}
[recv] {"type":"activity",...,"tool":"Read","phase":"done","summary":"Read notes/todo.md"}
[recv] {"type":"activity",...,"tool":"Grep","phase":"start","summary":"Grep TODO"}
[recv] {"type":"activity",...,"tool":"Grep","phase":"done","summary":"Grep TODO"}
[recv] {"type":"delta",...,"text":"Fake mode is on, so no model wa"}
[recv] {"type":"delta",...,"text":"s called. The prompt was: Summa"}
[recv] {"type":"delta",...,"text":"rise what is in this workspace."}
[recv] {"type":"result",...,"text":"Fake mode is on, so no model was called. ...","subtype":"success"}
[deltas] 3 pieces, 93 chars, prefix of result: true

That last line is the invariant the tests hold: the deltas join into a prefix of the result. The browser side leans on it. When the result frame arrives, the client replaces the accumulated streamed text with the result’s text. Because the streamed text is a prefix, the reader sees the same opening words stay put and the tail fill in. Record 0070 carries the reasoning; the trap below shows what the other choices look like.

One caveat the protocol states: the prefix relation holds for a single-turn answer, which is what this workbench produces. A session that writes, runs a tool, and writes again streams both stretches while result keeps only the final answer, so a client treats the result text as the authority whenever the two differ.

This round also showed why the demo script exists beside the tests. Its first real transcript read Read notes[path] instead of Read notes/todo.md: the redaction pass from module 15 was bracketing relative paths, a bug that had been reachable in every result since, sitting where no test looked. The transcript made it visible in one run. Record 0032 has the fix.

The todo list is opt-in on the newest models

The plan for this page promised a third feature: rendering the agent’s own task list. The todo-tracking page gates it: on TypeScript Agent SDK 0.3.233 and later, or Python Agent SDK 0.2.139 and later, the tools TodoWrite, TaskCreate, TaskGet, TaskUpdate, and TaskList are not available on Opus 4.8, Sonnet 5, Fable 5, Mythos 5, or later, unless the session opts in. Those models track multi-step work without a written list, so your stream shows no task events and nothing is wrong. Three routes opt a session in: naming a task tool in allowedTools, listing it in the tools option, or exporting CLAUDE_CODE_ENABLE_TODO_TOOLS=1, which the live page’s own examples now use. On other models Claude Code provides the Task tools by default, and TodoWrite only when CLAUDE_CODE_ENABLE_TASKS=0 is set. So a todo panel is a feature your app requests because it renders it, never ambient behaviour to rely on, and the workbench leaves it out until a module needs it.

Build

The ladder, as always:

  1. Run it. Fake mode, one prompt, watch the activity lines dim and the answer assemble. Then run npm run demo -w server against it and check the last line says prefix of result: true.
  2. Read one file. The stream_event case in server/src/agent.js, top to bottom, with record 0030 beside it.
  3. Change one line and see it. Start the server with WORKBENCH_FAKE_DELAY_MS=400 and watch the same stream in slow motion; then change it to 0 and see the whole answer land at once, which is module 15’s behaviour reproduced by timing alone.
  4. Build. Add a fourth field to SUMMARY_FIELDS in server/src/agent.js (the array names which input field a summary shows) and prove your change with one demo run whose summary line uses it.
The mistake most people make first

You render each delta by appending it to the answer, and when the result arrives you append that too, because it is the answer, after all. Every reply on screen now starts with itself: “Fake mode is on, so no model… Fake mode is on, so no model was called…” No error anywhere, and the duplication only shows once a whole answer is on screen, which in a quick test with a one-word reply it barely does.

The rule that fixes it comes from the protocol’s own promise: the deltas are a prefix of the result, so the result replaces the streamed text rather than following it. The opening words do not move, the tail fills in, and a partial stream can never leave a silently truncated answer behind. The client’s record 0070 walks the three choices; replacing is the one where both failure directions stay visible.

Does this travel?

Streaming as pieces-plus-final-authority is how every model vendor’s API works, and the replace-on-result rule travels with it anywhere the final message repeats the streamed content. The activity feed travels as an idea: any agent framework that reports tool calls can feed one. What does not travel is the shape, stream_event, content_block_delta, and the todo-tool version gate are this SDK’s own, and the gate’s model list will be stale before most readers arrive. Check the todo-tracking page, not this one, for the current list.

Check yourself

  1. A module 15 client connects to this module 16 server. What does the user see while a prompt runs, and which line of the protocol file makes that outcome a rule rather than luck?
  2. The demo prints prefix of result: false after a change you made. Name the two places the mismatch could have come from, and which frame’s text the client shows when they differ.
  3. Your stream from a real session on a new model shows no TodoWrite activity, and your todo panel stays empty. What are the two possible causes, and which one is the docs’ stated behaviour?