Mastering Claude API Stop Reasons: Build Robust Applications with end_turn, max_tokens & Tool Use
Learn how to interpret and handle Claude API stop_reason values like end_turn, max_tokens, and tool_use. Practical code examples for building reliable AI applications.
This guide explains the four stop_reason values in Claude's Messages API (end_turn, max_tokens, tool_use, stop_sequence), how to handle empty responses, and best practices for building robust applications that respond appropriately to each stop condition.
Mastering Claude API Stop Reasons: Build Robust Applications
When building applications with Claude's Messages API, understanding why the model stopped generating its response is just as important as the response itself. The stop_reason field tells you exactly that—and handling it correctly can mean the difference between a smooth user experience and a broken workflow.
In this guide, you'll learn what each stop reason means, how to handle them in code, and how to avoid common pitfalls like empty responses.
What Is the stop_reason Field?
Every successful response from the Messages API includes a stop_reason field. Unlike HTTP errors (which indicate a failed request), stop_reason tells you why Claude successfully completed its response generation.
Here's a typical response structure:
{
"id": "msg_01234",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Here's the answer to your question..."
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 100,
"output_tokens": 50
}
}
The Four Stop Reason Values
Claude can stop generating for four distinct reasons. Let's explore each one.
1. end_turn – Natural Completion
Meaning: Claude finished its response naturally. The model decided it had said everything needed and ended its turn.
This is the most common stop reason and generally indicates success. However, there's a tricky edge case we'll cover below.
How to handle it:from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}]
)
if response.stop_reason == "end_turn":
# Process the complete response
print(response.content[0].text)
2. max_tokens – Token Limit Reached
Meaning: Claude stopped because it hit the max_tokens limit you set in your request. The response may be truncated—Claude had more to say but ran out of room.
How to handle it:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=150, # intentionally low
messages=[{"role": "user", "content": "Write a long story about a robot."}]
)
if response.stop_reason == "max_tokens":
# The response is incomplete. You can:
# 1. Increase max_tokens and retry
# 2. Ask Claude to continue
print("Response was truncated. Asking Claude to continue...")
# Add the current response to history and ask for continuation
messages = [
{"role": "user", "content": "Write a long story about a robot."},
{"role": "assistant", "content": response.content[0].text},
{"role": "user", "content": "Please continue from where you left off."}
]
continuation = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=messages
)
3. tool_use – Tool Call Requested
Meaning: Claude decided it needs to use a tool (function call) to complete your request. The response content will contain one or more tool_use blocks.
How to handle it:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[{
"name": "get_weather",
"description": "Get current weather for a city",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"}
},
"required": ["location"]
}
}],
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}]
)
if response.stop_reason == "tool_use":
# Extract tool calls from content
for block in response.content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_id = block.id
print(f"Claude wants to call: {tool_name}")
print(f"With input: {tool_input}")
# Execute the tool and send result back
result = execute_tool(tool_name, tool_input)
# Continue the conversation with tool result
messages = [
{"role": "user", "content": "What's the weather in Tokyo?"},
response, # assistant's response with tool_use
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_id,
"content": str(result)
}
]
}
]
final_response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[...],
messages=messages
)
4. stop_sequence – Custom Stop Sequence
Meaning: Claude encountered a custom stop sequence you defined in your request. This is rare and only happens when you explicitly set stop_sequences.
How to handle it:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
stop_sequences=["\n\nEND"],
messages=[{"role": "user", "content": "List 5 fruits. End with END."}]
)
if response.stop_reason == "stop_sequence":
print(f"Stopped at sequence: {response.stop_sequence}")
# The response content ends right before the stop sequence
print(response.content[0].text)
The Empty Response Problem (and How to Fix It)
A common frustration: Claude returns an empty response with stop_reason: "end_turn". This typically happens in tool use workflows.
Why It Happens
Claude sometimes interprets that the assistant's turn is complete—especially after tool results. Two common causes:
- Adding text blocks after tool results – Claude learns a pattern where users always insert text after tool results, so it ends its turn to follow that pattern.
- Sending Claude's completed response back unchanged – Claude already decided it was done, so it stays done.
How to Prevent It
Incorrect pattern:messages = [
{"role": "user", "content": "Calculate 1234 + 5678"},
{"role": "assistant", "content": [
{
"type": "tool_use",
"id": "toolu_123",
"name": "calculator",
"input": {"operation": "add", "a": 1234, "b": 5678}
}
]},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "6912"},
{"type": "text", "text": "Here's the result"} # ❌ Don't add text after tool_result
]
}
]
Correct pattern:
messages = [
{"role": "user", "content": "Calculate 1234 + 5678"},
{"role": "assistant", "content": [
{
"type": "tool_use",
"id": "toolu_123",
"name": "calculator",
"input": {"operation": "add", "a": 1234, "b": 5678}
}
]},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "6912"} # ✅ Just the tool_result
]
}
]
Handling Empty Responses When They Still Occur
If you've fixed the pattern above but still get empty responses, use a continuation prompt:
def handle_empty_response(client, messages):
response = client.messages.create(
model="claude-opus-4-20250514",
max_tokens=1024,
messages=messages
)
if response.stop_reason == "end_turn" and not response.content:
# ✅ Add a continuation prompt in a NEW user message
messages.append({"role": "user", "content": "Please continue"})
response = client.messages.create(
model="claude-opus-4-20250514",
max_tokens=1024,
messages=messages
)
return response
Building a Robust Response Handler
Here's a complete handler that manages all stop reasons gracefully:
from anthropic import Anthropic
client = Anthropic()
def handle_claude_response(response, messages, tools=None):
"""Process Claude's response based on stop_reason."""
if response.stop_reason == "end_turn":
if not response.content:
# Empty response - ask Claude to continue
messages.append({"role": "user", "content": "Please continue"})
return client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=messages,
tools=tools
)
else:
# Normal completion - return the response
return response
elif response.stop_reason == "max_tokens":
# Response truncated - ask for continuation
messages.append(response)
messages.append({"role": "user", "content": "Please continue from where you left off."})
return client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048, # Increase limit
messages=messages,
tools=tools
)
elif response.stop_reason == "tool_use":
# Execute tools and continue
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
messages.append(response)
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result)
}]
})
return client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=messages,
tools=tools
)
elif response.stop_reason == "stop_sequence":
# Custom stop sequence reached - return as-is
return response
else:
raise ValueError(f"Unknown stop_reason: {response.stop_reason}")
Best Practices Summary
- Always check
stop_reason– Don't assumeend_turnmeans success. Check formax_tokensto detect truncation. - Handle
tool_useexplicitly – If your app uses tools, you must loop until you getend_turnormax_tokens. - Avoid text after
tool_result– Keep tool results clean to prevent empty responses. - Use continuation prompts for truncation – When
max_tokensis hit, ask Claude to continue rather than retrying the same prompt. - Log
stop_reasonfor debugging – It's invaluable for understanding unexpected behavior.
Key Takeaways
- Four stop reasons exist:
end_turn(natural completion),max_tokens(truncated),tool_use(tool call needed), andstop_sequence(custom stop). - Empty responses with
end_turnare usually caused by adding text aftertool_resultblocks—remove the extra text to fix it. - For truncated responses (
max_tokens), use a continuation prompt rather than retrying the original request. - Tool use requires a loop: Keep calling the API with tool results until Claude returns
end_turnormax_tokens. - Always handle
stop_reasonin your code to build robust, production-ready applications that gracefully handle every scenario.