"Claude's response feels like it's getting cut off mid-sentence." If you've shipped anything on the Messages API, you've likely heard this from a user. The first time I deployed a Claude-powered summarizer to production, I wasn't paying attention to stop_reason — and a max_tokens-truncated meeting summary went straight to a customer with the final decision missing. The bug wasn't in my prompt. It was in not reading the one field that tells you why the model stopped.
stop_reason is a small response field, but branching on it correctly prevents a whole category of production accidents — truncated outputs delivered as if complete, tool calls that silently halt the conversation, retries that achieve nothing. This guide walks through all six values and the practical patterns I use to handle each one without overengineering.
stop_reason is the only signal that tells you how the response ended
Every Messages API response carries a stop_reason. The six common values are:
end_turn: The model ended its turn naturally. The healthy case.max_tokens: Generation hit themax_tokensceiling. The output is almost certainly incomplete.stop_sequence: One of thestop_sequencesyou supplied appeared in the output.tool_use: The response contains tool-use blocks. You must execute the tools and continue the conversation.pause_turn: Extended thinking or a long-running server-side tool paused the turn. You need to call again to resume.refusal: The model declined to respond on safety grounds.
The trap most teams fall into is treating "I got a stop_reason back" as "the response is complete." It isn't. Three of the six — max_tokens, tool_use, pause_turn — mean the model stopped mid-task. Without a follow-up call, the user gets a partial answer.
Confusing end_turn with max_tokens is the most common production bug
The accident I see most often is shipping a max_tokens-truncated response as if the turn ended cleanly. Consider this minimal implementation:
# A naive Messages API call — not safe for production
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Summarize this long meeting transcript..."}],
)
# ⚠️ Returns the body without checking stop_reason
print(resp.content[0].text)If the response hit the token limit, this code happily renders a paragraph that ends mid-word. In my own incident, the truncation happened during the "decisions made" section of a meeting summary — the part the customer cared about most. Branching on stop_reason is the cheapest fix for the highest-impact failure mode:
# Minimal stop_reason branching
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "..."}],
)
text = "".join(b.text for b in resp.content if b.type == "text")
if resp.stop_reason == "end_turn":
return {"status": "ok", "text": text}
elif resp.stop_reason == "max_tokens":
# Either continue or retry with a higher max_tokens
return {"status": "truncated", "text": text, "hint": "raise max_tokens"}
elif resp.stop_reason == "refusal":
return {"status": "refused", "text": text}
else:
return {"status": resp.stop_reason, "text": text}
# Example output (truncated case):
# {"status": "truncated", "text": "...partial summary...", "hint": "raise max_tokens"}A note on the fix: don't reflexively double max_tokens and retry. Ask first whether the original ceiling was even reasonable for the task. Summarization rarely fits in 1,024 tokens; multi-step reasoning routinely exceeds 2,000. Setting a sane default like 4,096–8,192 makes most max_tokens truncations disappear without any retry logic at all. Reserve aggressive retry-with-larger-budget logic for tasks where you genuinely cannot estimate output length up front, like document-to-document translation.
tool_use and pause_turn both mean "keep going"
When tool_use comes back, the response contains one or more tool_use blocks that you need to execute. Their results go back as a new user message, and the conversation continues. Skip this loop and the user sees a model that "always stops as soon as it tries to use a tool."
# The standard tool_use continuation loop
def run_with_tools(messages, tools):
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
while resp.stop_reason == "tool_use":
tool_blocks = [b for b in resp.content if b.type == "tool_use"]
tool_results = []
for block in tool_blocks:
output = run_tool(block.name, block.input) # your code
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": tool_results})
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
return resppause_turn is the cousin of tool_use for cases like extended thinking or long-running server-side tools (code execution). The model is essentially saying "this turn isn't over, please call me again." The continuation pattern is even simpler: append the assistant message and call messages.create once more — no extra user message required.
For the streaming-plus-tool variant, see Combining Streaming and Tool Use in the Claude API, which goes into the event ordering you'll need.
refusal is a design decision, not a retry candidate
refusal means the model declined for safety reasons. The first instinct of many teams — including mine, the first time — is to retry. Resending the same prompt achieves nothing except burning rate limit. Three rules I now follow without exception:
- Don't auto-retry the same prompt. The model's safety classifier is essentially deterministic for a given input, so retries cost rate limit and produce the same outcome.
- Show the user a neutral "we can't respond to this" message. Don't surface the model's refusal text directly.
- Log the input for human review. Sometimes the refusal is correct; sometimes the prompt was unintentionally triggering it and is worth refining.
Pair this with the retry policy in Claude API Error Handling so that 429s, 5xx errors, and refusal aren't treated identically.
In streaming, stop_reason arrives last
Streaming surfaces stop_reason inside a message_delta event near the end of the stream — not in message_start. If you try to read it at stream open, you'll always see null.
# Capturing stop_reason in a streamed response
stop_reason = None
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{"role": "user", "content": "..."}],
) as stream:
for event in stream:
if event.type == "message_delta" and event.delta.stop_reason:
stop_reason = event.delta.stop_reason
# text_delta handling omitted
print("stop_reason:", stop_reason)
# Example: stop_reason: end_turnA subtlety on the UI side: if your "thinking" indicator hides only on end_turn, a pause_turn mid-stream can leave the spinner running while the conversation actually needs another API call. Treat any of tool_use / pause_turn / max_tokens as "still in progress" and you'll avoid that class of frozen-UI bug. The same logic applies to your timeout watchdog — if your code aborts a stream after N seconds of silence, make sure that watchdog resets when you trigger the continuation call, not when the original stream closes.
stop_sequence is useful, but easy to forget you set it
stop_sequence only appears when one of the strings in stop_sequences shows up in the model's output. It's a powerful tool for forcing structured generation — for example, you can stop generation as soon as the model emits a closing tag like </answer> and trim the response cleanly. The pattern looks like this:
# Use stop_sequences to constrain output
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
stop_sequences=["</answer>"],
messages=[{"role": "user", "content": "Wrap your answer in <answer>...</answer>"}],
)
if resp.stop_reason == "stop_sequence":
# resp.stop_sequence tells you which one fired
print("matched:", resp.stop_sequence)The pitfall I keep seeing in code reviews: someone sets stop_sequences=["\n\n"] to make answers terse, then forgets about it months later. New tickets come in saying "Claude won't produce multi-paragraph output anymore." The fix is to grep your codebase for stop_sequences whenever you see unexplained short responses — old configuration is the most common culprit.
A quick checklist for handling each value
To make this concrete, here is the minimal correct behavior I encode for each stop_reason:
end_turn— return the response. Done.max_tokens— log it, return a "truncated" status, surface the truncation in your error tracker. If the use case demands a full answer, raisemax_tokensrather than retrying blindly.stop_sequence— strip the matched sequence if needed, then return. Verify yourstop_sequencesconfiguration is still relevant.tool_use— execute the tool blocks, append assistant + user messages, call again. Cap the loop iterations to avoid runaway sessions.pause_turn— append the assistant message and call again. No extra user message required.refusal— show a neutral message to the user, log the input for review, do not auto-retry.
Start by catching max_tokens — the rest can wait
You don't need to handle all six values perfectly on day one. The highest-leverage move is to log every max_tokens response and surface it to your error tracker today. That single change makes most "the response cuts off" reports disappear, because they were never a model problem to begin with — they were a missing branch in your code. The tool_use, pause_turn, and refusal paths can grow with your product surface area.