Module 01 · Lesson 9 of 13Free
Streaming responses: showing output as it arrives
- Level
- Beginner
- Topic
- AI engineering fundamentals
- Reading time
- 13 min
When you finish this page you can
- Explain why streaming shortens the time to first visible text but not the total time or the cost.
- Name the events in a Messages API stream, in order, and say which one carries stop_reason and usage.
- Stream a reply with the Python SDK and relay its text from your server to a browser.
- Handle what streaming adds: a pause while the model thinks, a cut-off seen only at the end, and an error after text is on screen.
Builds on 1 lesson
Start with lesson 315 minVideo lesson · coming soon
Priya watches a spinner for six seconds
Deskora is a help desk app. A shop’s support team uses it to answer customer emails from one shared inbox. Each customer problem becomes a ticket: one customer’s whole conversation with the support team, message by message.
Priya is a support agent at a kitchenware shop that uses Deskora. A customer, Amara, writes that her new pan set arrived without one of its lids. Priya presses a new button, Draft a reply. A spinner turns. About six seconds later, a whole draft appears at once.
Tomás, the Deskora engineer who builds the AI features, made that button. His ticket summary runs in the background, so nobody waits for it. The draft is different: Priya asks for it and watches the screen. Six seconds of nothing looks broken, so some agents press the button again, which pays for a second call.
This lesson answers one question: how can Priya see the reply while the model is still writing it, and what changes in Tomás’s code when she does?
The draft-reply feature, its token counts and its speeds are estimates for this lesson’s scenario.
Streaming moves the first word, not the last
Without streaming, the call works as in Calling a model API: the API waits until the model has finished, then sends the whole response as one JSON body. With streaming, the API sends the response in small pieces while the model produces them. The model writes in tokens, small pieces of text often shorter than a word.
Here is where Priya’s 5.5 seconds go. The draft runs on Claude Haiku 5.5, which thinks before it answers by default; thinking tokens are billed as output but not shown 2.
Assumed speeds
0.5 s before the first output token; then 100 output tokens per second
Round numbers for this lesson, not measured speeds. Real speed varies with model, input and load.
Thinking
300 thinking tokens ÷ 100 tokens/s = 3.0 s
Assumed, for a typical ticket of 4,150 input tokens.
The reply
200 reply tokens ÷ 100 tokens/s = 2.0 s
About 150 words.
Total
0.5 s + 3.0 s + 2.0 s = 5.5 s
Without streaming, Priya sees nothing until then.
Tomás turns on streaming. When does the first word of the draft appear on Priya’s screen: at about 0.5 s, 3.5 s or 5.5 s?Show answer
Diagram not drawn yet: draft-reply-wait-vs-stream.svg
Two timelines from 0 to 5.5 seconds for the same typical draft. Top, without streaming: 0 to 0.5 s the request travels and the input is read, 0.5 to 3.5 s hidden thinking, 3.5 to 5.5 s the reply is written, and Priya sees a spinner the whole time; the full draft appears at 5.5 s. Bottom, with streaming: the same three phases; Priya sees a Writing status during thinking, the first words at 3.5 s, and words keep appearing until 5.5 s.
The wait for the first output token is called time to first token. Priya feels the time to first visible text; hidden thinking sits between the two. On an easy input the model may skip thinking, and text starts almost at once 2.
Streaming hides the time spent writing, not the time spent thinking. A long ticket shows it:
| Ticket (estimates) | Thinking + reply tokens | Draft appears, no streaming | First words, streaming | Last word, streaming |
|---|---|---|---|---|
| Typical: 4,150 input tokens | 300 + 200 | 5.5 s | 3.5 s | 5.5 s |
| Long: 15,150 input tokens | 800 + 250 | 11.5 s | 9.0 s | 11.5 s |
The long row assumes 1 second before the first token, since more input takes longer to read. To shorten the pause itself, ask the model to think less; Reasoning effort covers that control.
Streaming doesn’t change the price. On Claude Haiku 5.5, as of October 2026, a typical draft costs 4,150 input tokens × $0.10 per million + 500 output tokens (thinking included) × $0.50 per million = about $0.0007 3. A second press doubles that.
How should the Draft a reply button deliver the draft?
Wait for the whole response, then show it.
One JSON body, read exactly as in the summary code, with stop_reason checked before anything is shown. But Priya watches a spinner for 5.5 s on a typical ticket and 11.5 s on a long one, and some agents press twice.
Stream the reply text, with a status line while the model thinks.
A Writing status appears within about 0.5 s, the first words at 3.5 s, and Priya reads while the rest arrives. Same cost. Tomás’s code must now handle events, a stop_reason that comes last and errors after text is shown.
Stream the thinking summary as well, by asking for it to be displayed.
Readable text moves from about 0.5 s, but it is the model’s notes, not a reply, and the reply itself starts later than with the thinking text hidden.
Why this choice
Priya can start reading before the draft ends, so time to first visible text is what she feels. Hidden thinking is the better default: with the thinking text omitted, the reply starts streaming sooner 2.
When the choice changes. If nobody is watching, streaming adds code for nothing, so the background summary stays a plain call. If the whole output must pass a check before anyone sees it, such as JSON your code parses, show it only once it is complete.
So what does a streamed response look like on the wire?
The response arrives as a sequence of events
Tomás adds "stream": true to the request body. The API answers with status 200 and a content-type of text/event-stream, then keeps the HTTP response open and writes each piece as soon as it is ready. This format is called 5. Each event is an event: line naming its type, a data: line holding JSON, and a blank line 1.
One example stream for Amara’s draft, shortened; the text differs on every run:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_01...", "type": "message", "role": "assistant", "content": [], "model": "claude-haiku-5-5", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4163, "output_tokens": 1}}}
event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": "", "signature": ""}}
event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": ""}}
event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQB..."}}
event: content_block_stop
data: {"type": "content_block_stop", "index": 0}
event: content_block_start
data: {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}
event: content_block_delta
data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "Hi Amara, I'm sorry"}}
... more text_delta events ...
event: content_block_stop
data: {"type": "content_block_stop", "index": 1}
event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null}, "usage": {"output_tokens": 512}}
event: message_stop
data: {"type": "message_stop"}The content blocks of a normal response arrive one at a time. Each opens, receives deltas (small pieces of the block) and closes; index is its position in the final content list 1.
Diagram not drawn yet: messages-stream-event-timeline.svg
A vertical timeline from 0 to 5.5 seconds with events on the left and Priya’s screen on the right. At 0.5 s: message_start, with stop_reason null; content_block_start index 0, type thinking; the screen shows Writing. From 0.5 to 3.5 s: no readable events, a pause; the screen still shows Writing. At 3.5 s: an empty thinking_delta, a signature_delta and content_block_stop index 0, then content_block_start index 1, type text. From 3.5 to 5.5 s: many text_delta events; words appear on the screen. At 5.5 s: content_block_stop index 1; message_delta with stop_reason end_turn and output_tokens 512; message_stop; the screen shows the finished draft.
| Event | What it carries | What Tomás’s server does |
|---|---|---|
message_start |
The message id and input token count; stop_reason is null |
Starts the draft |
content_block_start |
A new block’s index and type: thinking or text |
On thinking, tells the browser “Writing…” |
content_block_delta |
A piece of that block: text_delta, thinking_delta or signature_delta |
Forwards each text_delta |
content_block_stop |
The block at this index is complete | Nothing |
message_delta |
stop_reason and cumulative usage |
Decides done or cut off; logs the cost |
message_stop |
The end of the stream | Ends the browser’s stream |
With the thinking text hidden, the thinking block carries one empty thinking_delta and a signature_delta, the encrypted thinking 2. That is the pause on Priya’s screen. A stream may also include ping events, and new event types may be added later, so code skips any event it doesn’t know 1.
These events reach Tomás’s server. Priya’s browser still sees nothing.
Deskora’s server relays the text to Priya
The browser can’t call the model API, the provider’s HTTP service that runs the model, because the call carries Deskora’s . So there are two streams: Claude to Deskora’s server, and Deskora’s server to Priya’s browser.
Diagram not drawn yet: draft-reply-relay-path.svg
Three boxes from left to right. 1: Priya’s browser sends POST draft-reply for the ticket to the Deskora app server. 2: the app server loads the thread and opens a streaming request to the Claude Messages API; the API key stays on the app server. 3: a dashed stream of Claude events, such as content_block_delta, returns to the app server. 4: a dashed stream of Deskora’s own events, status, text, done, cut_off and failed, goes from the app server to the browser.
The browser needs only the text and how the draft ended, so the server sends its own events instead of Claude’s: status, text, then one of done, cut_off or failed. The provider’s format stays on the server, which also logs the cost. The code uses the SDK’s stream helper and Python’s built-in HTTP server 4:
import json
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
SYSTEM = ("Draft a short, polite reply to the customer's last message. "
"A support agent will edit it before sending.")
def sse(event: str, data: str) -> bytes:
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode("utf-8")
def draft_reply(thread: str) -> Iterator[bytes]:
try:
with client.messages.stream(
model="claude-haiku-5-5",
max_tokens=4000, # room for thinking plus a few paragraphs
system=SYSTEM,
messages=[{"role": "user", "content": thread}],
) as stream:
for event in stream:
if (event.type == "content_block_start"
and event.content_block.type == "thinking"):
yield sse("status", "writing")
elif (event.type == "content_block_delta"
and event.delta.type == "text_delta"):
yield sse("text", event.delta.text)
final = stream.get_final_message()
except Exception as err: # an error event, or the connection dropped
yield sse("failed", type(err).__name__)
return
print(f"in={final.usage.input_tokens} out={final.usage.output_tokens}")
ending = "done" if final.stop_reason == "end_turn" else "cut_off"
yield sse(ending, final.stop_reason or "")
class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
# A real handler checks the session and loads the thread by ticket id.
length = int(self.headers["content-length"])
thread = self.rfile.read(length).decode("utf-8")
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.send_header("cache-control", "no-cache")
self.end_headers()
for frame in draft_reply(thread):
self.wfile.write(frame)
self.wfile.flush() # send each event now, not when the buffer fills
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()client.messages.stream(...) sends the request with streaming on, and looping over stream gives each event as it arrives. Afterwards, stream.get_final_message() returns the whole message built from the events, the same object a normal call returns 1. If you need only the text, stream.text_stream yields just the text pieces.
In the browser, Deskora reads the response body piece by piece with fetch and adds each text event to the reply box. A WebSocket would also work, but traffic here goes one way; WebSockets, SSE and long polling compared weighs the options.
Priya now sees words at 3.5 s. But some things still arrive last.
The ending comes last, and it can go wrong
In a stream, stop_reason is null in message_start and arrives only in message_delta, after every word is on screen 1. If it says max_tokens, the draft Priya has been reading was cut off. Deskora keeps the text and marks it: “This draft stopped early.”
usage also arrives at the end, and its counts are cumulative 1. Take the last value; don’t add them up.
The harder case is an error. Status 200 and the headers went out first, so a later failure can’t change them. Instead, the API sends an error event, such as an overloaded_error during heavy load, which a normal call would return as HTTP 529 1.
Amara gets half a reply
- At 4.5 s, Priya’s reply box shows: “Hi Amara, I’m sorry the lid was missing from your order. I’ve”.
- The API sends an
errorevent. The SDK raises an exception inside Tomás’s loop. - In Tomás’s first version, the server just ends the browser’s stream. The spinner stops, and the draft looks finished.
- Priya fixes a typo and presses Send. Amara receives a reply that stops mid-sentence.
- The fix is the
failedevent in the code above: the browser marks the draft incomplete, disables Send and offers “Try again”. A stream that ends withoutdoneis treated the same, since a dropped connection ends it silently.
When to retry, and how often, is the subject of Model API errors, rate limits and retries.
When a background job streams anyway
Long background jobs may need streaming anyway: the SDK requires it for requests with a large max_tokens, to avoid HTTP timeouts. stream.get_final_message() lets such a job stream underneath and still get one complete message 1.
Try it yourself
Watch a raw stream with curl; -N makes it print events as they arrive. One run costs at most about $0.0005 on Claude Haiku 5.5: at most 1,000 output tokens × $0.50 per million, plus a few dozen input tokens. Without an API key, skip this.
curl -N https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2026-10-21" \
-d '{"model": "claude-haiku-5-5", "max_tokens": 1000, "stream": true, "messages": [{"role": "user", "content": "Write a two-sentence reply to a customer whose pan set arrived without a lid."}]}'Example output, shortened; your events and text will differ:
event: message_start
data: {"type":"message_start","message":{...,"stop_reason":null,...}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking",...}}
...
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hi there,"}}
...
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn",...},"usage":{"output_tokens":131}}What to look for: a pause before the first text_delta, which is hidden thinking (none if the model skipped it), and stop_reason set only in message_delta.
Where this shows up
- Design: a writing assistant inside an editor streams text into a document as the writer watches.
- Design: scaling a chat assistant to a million users keeps many streams open at once.
- Design: an AI customer support platform mixes streamed drafts with background jobs.
Half a draft and a Try again button raise the next question: when the model API fails, which calls should Deskora retry, and how soon?
Common mistakes
- Reading
stop_reasonfrommessage_start. It is null there; read it frommessage_delta. - Adding up
usageacrossmessage_deltaevents. The counts are cumulative. - Treating the end of the stream as success. A dropped connection also ends it. Only
donemeans complete. - A buffering proxy in the path. A that buffers responses holds every event until the end, and the spinner is back.
Check your understanding
Tomás switches the background ticket summary to streaming. What does Priya gain?Show answer
Streaming is on, yet on long tickets the first words of a draft appear after about 9 seconds. Why, and what could shorten the wait?Show answer
A draft shows two sentences, then the browser’s stream ends with no done, cut_off or failed event. What should the reply box do?Show answer
A stream carries two message_delta events whose usage shows output_tokens 340 and then 512. How many output tokens does Deskora pay for?Show answer
Summary
- Streaming shows the reply while it is written: Priya sees the first words at about 3.5 s instead of a whole draft at 5.5 s, with the same total time and the same cost.
- Hidden thinking comes first and streams no readable text, so it stays a pause; show a status line, and think less if the pause is too long.
- The API sends server-sent events in order:
message_start, then per blockcontent_block_start, deltas andcontent_block_stop, thenmessage_deltawithstop_reasonand cumulativeusage, thenmessage_stop. - Deskora’s server holds the API key, reads Claude’s stream with
client.messages.stream, and relays its own small events to the browser. stop_reason,usageand errors can arrive after text is on screen, so mark cut-off or failed drafts, and stream only when someone is watching.