stop_reason is the single field that tells you whether a Claude response is finished, interrupted, or waiting on you. Treating every 200 response as complete is the most common way to ship truncated output to a user without noticing.
Where the field appears
On a non-streaming call, stop_reason is a top-level field on the Message object, next to content, usage and model. It is null only while a message is still being generated, which in practice means you will never see null on a complete non-streaming response. There is a companion field, stop_sequence, which is null except in the one case described below.
The distinction between an HTTP status and a stop reason is the one to fix in your head first. A 4xx or 5xx means the request failed and there is no message. A 200 with a stop_reason means the model ran and something decided when to stop it — which may be the model itself, your parameters, a safety system or a limit. Monitoring that only watches status codes is blind to every one of those, and they are the interesting failures.
{
"id": "msg_01XF...",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [ { "type": "text", "text": "..." } ],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": { "input_tokens": 812, "output_tokens": 96 }
}
The documented values
Anthropic’s Messages API reference is the authority for this enum. The values, and the condition each one means:
end_turn
The model finished on its own. It emitted its end-of-turn token because it had nothing more to say. This is the only value that means “the answer you are holding is the whole answer”. Everything else is a qualification of that.
max_tokens
Generation hit the max_tokens ceiling in your request, or the model’s own output cap. The content is real but incomplete, and it will usually be cut mid-sentence or mid-JSON. Do not parse it, do not show it, and do not treat it as a refusal — it is a budget exhaustion. The two recoveries are to raise max_tokens and retry, or to continue the turn by appending the partial assistant message and asking the model to carry on, which works because a trailing assistant message is a prefill; see prefilling Claude’s assistant turn.
stop_sequence
One of the strings you passed in stop_sequences was generated, so the API halted there. This is the one case where the companion stop_sequence field is populated: it holds the exact string that matched, which matters when you supplied several and need to know which branch fired. The matched sequence is not included in the returned text.
tool_use
The model wants to call a tool and has stopped to let you run it. The content array contains one or more tool_use blocks, each with an id, a name and an input object matching your declared schema. This is not an ending — the turn is suspended. Your next request must include the assistant message verbatim followed by a user message whose leading blocks are tool_result entries keyed by tool_use_id.
pause_turn
The turn was paused mid-execution, which happens with long-running server-side tools where the API stops rather than holding a connection open indefinitely. The handling is to pass the response back unchanged as the next assistant message and let the model continue. This value is easy to miss, and code written before server-side tools existed often falls through to an “unknown stop reason” branch on it.
refusal
The model declined to continue for safety reasons. This is distinct from a text response explaining a refusal — it is a structural signal, so you can detect it without pattern-matching apology phrasing in the output. Retrying the identical request is not a fix; the request needs to change. The response format is covered in Claude’s refusal response format.
model_context_window_exceeded
Generation stopped because the total sequence reached the model’s context window rather than your max_tokens. It is the sibling of max_tokens for the case where the binding limit was the window, and it tells you that raising max_tokens will not help — you have to shorten the input.
This enum grows. pause_turn and refusal were both added after the API launched, and a client that switches exhaustively over the values it knew about will break on the next addition rather than degrade. Write the default branch first.
Reading it from a stream
When you stream, stop_reason is null in the opening message_start event, because it is not known yet. It arrives in the message_delta event near the end, alongside the final output token count:
event: message_delta
data: {"type":"message_delta",
"delta":{"stop_reason":"tool_use","stop_sequence":null},
"usage":{"output_tokens":142}}
event: message_stop
data: {"type":"message_stop"}
Code that reads only content_block_delta events to build up the text and then stops listening at message_stop will never see the stop reason at all — and a truncated stream looks exactly like a complete one from the deltas alone. The full event sequence is in Claude’s streaming event types.
A handler that covers all of them
The useful shape is a dispatch that separates “done”, “continue the loop” and “something is wrong”, with an explicit unknown branch:
switch (message.stop_reason) {
case "end_turn":
return { done: true, text: textOf(message) };
case "tool_use":
return { done: false, next: await runTools(message) };
case "pause_turn":
return { done: false, next: [message] }; // hand it straight back
case "max_tokens":
throw new Truncated(message); // never parse this content
case "model_context_window_exceeded":
throw new ContextFull(message); // shorten input, not max_tokens
case "stop_sequence":
return { done: true, text: textOf(message), hit: message.stop_sequence };
case "refusal":
return { done: true, refused: true };
default:
// A value added after this code was written. Log it and treat as done
// rather than crashing or silently looping.
log.warn("unknown stop_reason", message.stop_reason);
return { done: true, text: textOf(message) };
}
Two of those branches are the ones worth being strict about. max_tokens must never fall through into JSON parsing, because truncated JSON is exactly the input that produces a confusing parse error three layers away from the real cause. And tool_use must never be treated as terminal, because a loop that returns the tool call to the user as text produces the recognisable failure of an assistant that announces it is searching and then says nothing.
Three failures this field prevents
Each of these is a real class of bug that a stop_reason check catches for free, and that nothing else catches at all.
- Silently truncated output shown as a finished answer. A 200 response with well-formed prose that stops mid-sentence is indistinguishable from a complete one if you only read
content. Users report it as “the AI cut off” and the logs show a successful request. The check is one comparison. - Retrying a refusal. Generic retry-on-anomaly logic will happily re-send a request that was declined, several times, burning tokens and rate limit on an outcome that cannot change without the request changing. A
refusalis terminal by construction; a 529 overload is not. Treating them the same way gets one of them wrong. - Dropping out of a tool loop. A turn that ends in
tool_useand is treated as final produces the most confusing symptom in agent development: the model appears to stop working mid-task, with no error anywhere, because the loop that was supposed to continue it returned instead.
There is also a positive use for the field that is easy to overlook. Logging stop_reason alongside usage.output_tokens on every request gives you a distribution, and the shape of that distribution is a health metric. A rising proportion of max_tokens means prompts have drifted towards longer answers or a ceiling was set too low for real traffic. Any appearance of model_context_window_exceeded means conversation history is growing without a trim step. A cluster of refusal against one route says something about that route’s prompt. None of those are visible in an error rate, because none of them are errors.
Top comments (0)