Streaming: The Same Answer, One Event at a Time

Server-sent events from the Messages API — the message_start / content_block / message_delta / message_stop lifecycle, how a tool call arrives as input_json_delta fragments you accumulate and parse at the stop, and the SDK helpers that do the bookkeeping.

The previous chapter made one Messages call and waited for the whole answer. That is fine when nothing is watching. But a person waiting on a non-streaming call sees nothing at all until the entire response is done. The model generates token by token on the server, and a plain create holds every one of them back until the last. For anything a human watches, a chat or a long generation, that is the difference between “instant” and “frozen.” Streaming delivers the response incrementally instead, as server-sent events (SSE): each fragment reaches you the moment it is produced, and you render it as it arrives.

This chapter covers what streaming is at the wire level, and when it earns its extra handling (and when it does not). Then the one genuinely tricky part — streamed tool calls — and the SDK helpers that spare you most of the bookkeeping.

Why streaming is more than a nicety

Beyond perceived latency, streaming is sometimes required. Long generations can exceed the non-streaming request’s socket timeout: the connection dies before the full response is assembled. So large outputs are expected to stream. And extended thinking (Arc 4) streams its reasoning as it goes. So streaming isn’t only a UX flourish; it’s how you make certain requests at all.

The honest counterweight: when nothing is watching and nothing is large, streaming is pure overhead. A batch extraction, a server-to-server classification, a short structured answer nobody reads character by character — none of these gain anything from incremental delivery. Each one costs you event-handling code you didn’t need. A plain create is simpler, and simpler is the right call when there’s no human at the other end and no generation long enough to outlive a socket. Reach for streaming when someone is waiting or the output is big; otherwise don’t.

The event lifecycle

A streamed response is a strict sequence of typed events. From the raw API, the shape is:

  1. message_start: a Message object with empty content and the initial usage (input tokens known, output not yet).
  2. For each content block, a nested group:
    • content_block_start: the block begins, with its index and type (text, tool_use, thinking).
    • content_block_delta: one or more incremental updates. The delta carries a typed payload: text_delta for text, input_json_delta for tool arguments, thinking_delta for extended thinking.
    • content_block_stop: that block is complete.
  3. message_delta — top-level updates that arrive near the end: the final stop_reason and the cumulative output token count.
  4. message_stop — the terminal event.

ping events may appear anywhere and mean nothing; error events can interrupt the stream. The invariant to hold: message_delta is where the final stop_reason lives, and message_stop ends it. A block is only complete at its content_block_stop.

Text streaming, the easy case

The Python SDK’s high-level helper makes text trivial. It handles accumulation and exposes convenience events:

import anthropic
client = anthropic.Anthropic()

with client.messages.stream(
    model="claude-haiku-4-5",
    max_tokens=512,
    messages=[{"role": "user", "content": "Recommend a spy novel in two sentences."}],
) as stream:
    for text in stream.text_stream:      # just the text deltas, already unwrapped
        print(text, end="", flush=True)
    final = stream.get_final_message()   # the fully assembled Message, as if non-streamed

text_stream yields the text fragments; get_final_message() hands you the same complete Message object a non-streaming call would have returned — same content, stop_reason, and usage. So you get live output and the assembled result, without stitching deltas yourself. The lower-level for event in stream loop is there when you want every raw event.

Tool calls stream as JSON fragments — the part to get right

Here’s the detail the exam probes and that surprises people the first time. When the model calls a tool, the tool’s input arguments do not arrive as a JSON object. They arrive as a stream of string fragments under input_json_delta, which you must concatenate and parse only once the block completes.

Watching the raw events for a tool call to get_weather("Paris"), the observed sequence was:

message_start
content_block_start          (a tool_use block: name=get_weather, empty input)
content_block_delta  →  input_json_delta  partial_json='{"city"'
content_block_delta  →  input_json_delta  partial_json=': "Paris"}'
content_block_stop
message_delta                (stop_reason=tool_use)
message_stop

Each partial_json is a raw string chunk — '{"city"', then ': "Paris"}' — and only their concatenation, {"city": "Paris"}, is valid JSON. If you try to json.loads a fragment mid-stream, it throws; the arguments aren’t parseable until content_block_stop. The rule: accumulate partial_json per block, and parse at the stop.

import anthropic
client = anthropic.Anthropic()

TOOLS = [{
    "name": "get_weather",
    "description": "Get the current weather for a city.",
    "input_schema": {"type": "object",
                     "properties": {"city": {"type": "string"}}, "required": ["city"]},
}]

partial = ""
with client.messages.stream(model="claude-haiku-4-5", max_tokens=200, tools=TOOLS,
                            messages=[{"role": "user", "content": "Weather in Paris?"}]) as stream:
    for event in stream:
        if event.type == "content_block_delta" and event.delta.type == "input_json_delta":
            partial += event.delta.partial_json
    final = stream.get_final_message()
# partial == '{"city": "Paris"}'; final.stop_reason == 'tool_use'

The good news: you rarely hand-accumulate. The Python SDK emits a convenience input_json event carrying the running snapshot. More usefully, get_final_message() returns the tool-use block with its input already parsed into a dict ({'city': 'Paris'}). The wire-level fragmentation is what’s happening underneath, and the exam expects you to know it; the SDK is what you actually code against. Knowing both is the point: you can explain why a naive per-delta parse fails, and you know the helper that makes it a non-issue.

The SDKs accumulate for you

Every official SDK ships a stream accumulator so you don’t reassemble events by hand:

  • Python: stream.get_final_message() (and stream.text_stream for text-only)
  • TypeScript: stream.finalMessage()
  • Others follow the same pattern (Go’s Accumulate, Java’s MessageAccumulator)

The takeaway: stream for the UX and the long-generation safety, let the SDK accumulate, and remember that tool arguments are only whole at content_block_stop. Reach for the raw event loop when you need per-block control — rendering thinking separately from text, or updating a UI as each tool call forms. Use the convenience helpers otherwise.

Fine-grained control when you need it

Two lower-level facts round out the picture. First, the raw HTTP stream (client.messages.create(..., stream=True)) gives you the untouched SSE events if you’re building your own transport or a non-Python client. Second, latency-critical tool use has fine-grained tool streaming, which emits argument fragments even more eagerly. A UI can then start reacting to a parameter before the whole call is formed. Neither is needed for the common case; both exist for when the default granularity isn’t enough.

Final thoughts

Streaming is the same Messages API call delivered as a lifecycle: message_start, then per-block content_block_start / content_block_delta / content_block_stop, then message_delta carrying the final stop_reason, then message_stop. Text deltas are trivial; the one real subtlety is that tool arguments stream as input_json_delta string fragments that are only valid JSON once concatenated at the block’s stop — parse early and it throws. Let the SDK’s accumulator (get_final_message / finalMessage) do the reassembly, use the raw event loop when you need per-block control, and reach for streaming whenever a human is watching or the generation is large enough to outlive a non-streaming socket.

Next: multi-format input — sending images and PDFs, the Files API, and what a picture actually costs in tokens.

Comments