Reading the raw events
Anthropic and OpenAI each emit a specific, named sequence of SSE events, and the SDK abstraction over them hides exactly the boundaries a UI needs — so read the wire once, deliberately, before you let a library read it for you.
You have almost certainly never looked at one of these streams. There is no reason you would have — every SDK offers a two-line path that hands you text, and it works:
const stream = client.messages.stream({ model, max_tokens, messages })
stream.on('text', (text) => process.stdout.write(text))That is a projection, and it is lossy in a way that matters. It flattens an event stream into a character stream, which is exactly the transformation that makes it impossible to render a tool call, mark a step boundary, distinguish thinking from answering, or notice that the response died. This lesson is one deliberate hour spent below that line, so that when you later choose an abstraction you know what it is choosing not to tell you.
Anthropic: a named envelope around typed deltas
Anthropic documents its own format — vendor source, and the defining one for this wire, not a summary of someone else’s. “Each server-sent event includes a named event type and associated JSON data. Each event uses an SSE event name (for example, event: message_stop), and includes the matching event type in its data.” The sequence is fixed:
message_start
content_block_start (index: 0)
content_block_delta (index: 0) ×N
content_block_stop (index: 0)
content_block_start (index: 1)
...
message_delta
message_stop
// plus, at any point: ping, errorThe load-bearing detail is index. Anthropic: “Each content block has an index that corresponds to its index in the final Message content array.” That is the entire addressing scheme. A delta is not “the next text” — it is an update to a specific slot, and the slots have types. Once you see that, the string-accumulator model of a response stops looking like a simplification and starts looking like a bug.
Here is a complete plain-text response, verbatim from that page, trimmed to the shape:
event: message_start
data: {"type":"message_start","message":{"id":"msg_1nZ...","role":"assistant",
"content":[],"stop_reason":null,"usage":{"input_tokens":25,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: ping
data: {"type":"ping"}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}
event: message_stop
data: {"type":"message_stop"}The delta types are a closed, named set: text_delta, input_json_delta, thinking_delta, signature_delta, and — documented on the citations page rather than the streaming one — citations_delta, which “contains a single citation to add to the citations list on the current text content block”. A UI that renders a cited answer differently from an uncited one is reading that delta. A UI built on a text stream cannot see it at all.
How a tool call arrives
This is the part that most changes how you design. A tool-use block opens with an id and a name but an empty input object, and the arguments arrive afterwards as fragments of JSON text:
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":
{"type":"tool_use","id":"toolu_01T1...","name":"get_weather","input":{}}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":
{"type":"input_json_delta","partial_json":"{\"location\":"}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":
{"type":"input_json_delta","partial_json":" CA\"}"}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}Anthropic states the contract exactly: “To support maximum granularity, the deltas are partial JSON strings, whereas the final tool_use.input is always an object”, and tells you what to do about it: “You can accumulate the string deltas and parse the JSON once you receive a content_block_stop event.”
Two consequences that land directly in UI design. First, you know the tool’s name long before you know its arguments — so “Searching flights…” can render immediately while “Austin to Tokyo, March” fills in after. Second, the pauses are documented and expected: “Current models only support emitting one complete key and value property from input at a time. As such, when using tools, there may be delays between streaming events while the model is working.” A UI that reads silence as failure will report a hang that is the documented behaviour of a working system.
OpenAI: two formats, and only one of them is named
OpenAI — also a vendor documenting its own product — has two streaming shapes in current use, and conflating them is a common source of wrong assumptions.
The Responses API emits semantically named events, which its streaming guide introduces as response.created, response.output_text.delta, response.completed and error. The full set is much larger — the SDK’s own event union runs to dozens of classes — and includes response.function_call_arguments.delta, whose delta field is documented as “the function-call arguments delta that is added”. Same accumulate-then-parse burden as Anthropic’s input_json_delta, under a different name.
Correlation works differently and this is the part worth internalising. Anthropic gives you one integer, index. The Responses API gives you a sequence_number on every event plus item_id, output_index and content_index to identify what an event is about. Neither is harder; they are different addressing models, and a reducer written against one does not port to the other.
Chat Completions streaming is a different animal entirely: no named SSE event types at all, just bare data: lines carrying chat.completion.chunk objects, where the content lives in choices[].delta and tool arguments accumulate as string fragments inside delta.tool_calls[].function.arguments, keyed by an index on each entry. There is no per-block stop event to parse on — you parse when the stream ends or when you can.
Where people get burned
On the [DONE] sentinel, be precise, because the answer differs within OpenAI rather than between vendors. Chat Completions terminates with it — the SDK’s own include_usage docstring says “an additional chunk will be streamed before the data: [DONE] message” — and so does the legacy Completions endpoint. For the Responses API, this course could not verify it: neither the streaming guide nor the request-params source mentions [DONE], and the documented terminator is response.completed. The SDK’s SSE decoder does break on [DONE], but that code path is shared across every endpoint, so it proves nothing about which ones emit it. Rely on response.completed; tolerate a [DONE] line if one shows up. Anthropic has no sentinel at all — termination is message_stop.
The three things the abstraction hides
Having read both wires, the losses in on('text') are nameable rather than vague:
- Boundaries. Where one content block ends and the next begins — and therefore where a tool call sits relative to the prose around it. A text stream has no seams, so a UI built on one can only render a single undifferentiated blob.
- Incompleteness. Tool arguments are partial JSON in flight, on both providers. That is not an implementation detail to be hidden — it is the raw material for rendering a tool call as it forms, which is the single most valuable thing you can show during a long agentic wait.
- In-band failure. Anthropic documents an
errorevent carrying, for example,overloaded_error, and is explicit that this is the streaming equivalent of an HTTP 529 — “an error can occur after the API returns a 200 response. In that case, error handling doesn’t follow these standard mechanisms”. A text projection has nowhere to put that, so it usually becomes a truncated answer that looks finished.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
A tool call in the stream goes quiet for several seconds between events. Your UI shows nothing new. Is something broken?
Check your answer
Almost certainly not. Anthropic documents that current models emit one complete key and value from a tool’s input at a time, and that “there may be delays between streaming events while the model is working.” The gap is the system working as documented.
The real defect is on your side: the UI has no rendering that distinguishes “a tool call is open and its arguments are still arriving” from “nothing is happening.” You already received the content_block_start with the tool’s name in it. Everything needed to render an honest in-progress state arrived before the silence began — it was discarded at the parse step.
Hands on
Capture and annotate a real event log
Done when: A saved raw event log from your own chatbot’s provider, committed alongside ARTIFACT.md, with the event sequence written out and at least one event named that the SDK abstraction was hiding from your UI.
- Call your provider directly with
curl -Nand"stream": true, using a prompt that forces at least one tool call — the two-passengers-to-Tokyo question, or anything that makes the agent look something up. Pipe it to a file. Do not use the SDK for this step; the whole point is to see what the SDK receives. - Read the file top to bottom and write out the event sequence by name. Count how many events arrived and how many of them carried text. The ratio is usually the moment this lesson lands.
- Find the tool call in the log. Write down the moment its name became known and the moment its arguments became complete, and the gap between them in events. That gap is a rendering opportunity your current UI does not use.
- Provoke a failure. Send a request that will be rejected mid-stream, or cut the connection, and record what the log looks like when the response does not complete normally. Note specifically whether the HTTP status told you anything useful.
- Save the annotated log next to
ARTIFACT.mdand fill in the raw-events fields, including the last one: something in the log that surprised you. Leave it blank rather than inventing something — a blank there is a real signal that the log was skimmed, and I will ask.
What this does not cover
This lesson reads the wire and stops at parsing it. It does not build the model your application should hold, and the two are not the same job: a provider’s event vocabulary is designed to describe one API response, while your UI needs a vocabulary that describes an agent turn — possibly several model calls, several tools, and a failure that can land anywhere in the middle.
The agent-stream lesson that closes this module builds that second vocabulary: a typed event union your reducer consumes, deliberately not one-to-one with any provider’s events, so that swapping providers or putting an SDK back in front of the wire changes one adapter instead of your whole render tree. Everything about deciding what to draw for a half-arrived tool call belongs to the partial-answer module after that.
Read this next — primary source
Streaming messagesAnthropic — vendor documenting its own API. Free. Note the host: docs.anthropic.com now permanently redirects to platform.claude.com, so older bookmarks and older blog posts point at a moved target
The lesson takes the event sequence and the delta types from it. Read the whole page for the parts a summary cannot carry: the complete raw SSE transcripts for a plain response, a tool-use response and an extended-thinking response, side by side — which is the fastest way to internalise that the three differ only in which delta types appear inside the same envelope. It also documents the edge cases you will otherwise meet in production first: a fallback content block that opens and closes with no deltas between, and the instruction to handle unknown event types gracefully because new ones get added under the versioning policy.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.