Streaming, stop reasons, and how responses end
Two facts force streaming on any serious application. Users abandon interfaces that sit silent for twenty seconds while a long answer generates. And long generations over plain HTTP eventually hit connection timeouts — which is why the SDKs require streaming for large max_tokens. Streaming solves both, and along the way this lesson covers the field you must read on every response, streamed or not: stop_reason.
Streaming with the SDK helper
The raw wire format is server-sent events, but the SDK helper hides the event bookkeeping and gives you the two things you actually want — text as it arrives, and the complete message at the end:
with client.messages.stream(
model="claude-opus-5",
max_tokens=64000,
messages=[{"role": "user", "content": "Write a detailed migration guide from REST to gRPC."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True) # tokens as they arrive
final = stream.get_final_message() # the assembled Message object
print(final.usage.output_tokens, final.stop_reason)That get_final_message() call is the pattern to internalise: stream for delivery, then work with the final message for logic. Your UI consumes the token stream; your history-appending, tool-loop, and logging code operates on the same complete Message you would have had without streaming. Never hand-assemble the final message from deltas — the helper exists so you don't.
The habit worth adopting even for short calls: streaming plus get_final_message() gives you timeout protection for free, because data flows continuously instead of one long silent wait. Default to it; opt out only for quick, small calls.
Stop reasons: the response's exit code
Every response carries stop_reason — why generation ended — and production code branches on it the way shell scripts branch on exit codes. The values you will actually meet:
end_turn— the model finished naturally. The happy path.max_tokens— generation hit your cap and the output is truncated mid-thought. Treating this as success is how half-written JSON reaches a parser. Detect it; raise the cap or shorten the task.tool_use— the model is requesting tool calls; the loop from the tools lesson takes over.pause_turn— a long server-tool turn paused; re-send the conversation as-is and the server resumes.stop_sequence— a custom stop string you configured was hit.refusal— safety systems declined to continue. Thecontentmay be empty or partial, which is why code that unconditionally readscontent[0]breaks here. Surface the situation honestly to the user; don't blind-retry the identical request.model_context_window_exceeded— the input filled the window before output could finish; trim or summarize history (the context-engineering lesson shows the durable fixes).
The rule that unifies them: check stop_reason before consuming content. It is one line, and it is the difference between a system that degrades honestly and one that silently ships truncated or empty output downstream.
final = stream.get_final_message()
if final.stop_reason == "refusal":
return explain_refusal(final)
if final.stop_reason == "max_tokens":
log.warning("truncated response", request_id=final._request_id)
text = "".join(b.text for b in final.content if b.type == "text")Sizing max_tokens now that you stream
Streaming removes the timeout reason to keep max_tokens small, so give the model room: 64,000 is a sensible streaming default, and the frontier models go to 128,000 when you need long documents. Remember from the earlier lessons that thinking spends from the same budget — a deep-reasoning request with a tight cap produces a long think and a truncated answer, which reads as a model failure but is a configuration failure.
What to take into the next lesson
Stream for users and for timeout safety; get_final_message() for logic; branch on stop_reason always; size the cap generously. With input economics (caching) and output delivery (streaming) in place, the next lessons widen the input side itself: Claude doesn't only read text.