Claude tool_use Loops: How We Fixed Multi-Step AI
Here's the deal: the Claude tool use API is genuinely impressive until you chain more than three or four tools together and watch the whole thing spiral into a loop that burns your token budget and returns nothing useful. We hit this wall hard while building multi-step AI workflows, and the official docs — good as they are — gloss over the failure modes that actually hurt you in production.
This post is about those failure modes and the patterns that fixed them.
What the AI Integration Docs Don't Warn You About
The Anthropic tool use documentation walks you through the basic request/response cycle cleanly. You send a message, Claude decides to call a tool, you execute the tool, you send the result back, Claude continues. Simple enough when it's one tool. The moment you're orchestrating five or more tools in sequence — think: fetch user context, query a database, call an external API, transform the result, write to storage — you're in different territory.
Here's what the standard tutorial cycle looks like in practice:
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "get_weather",
"description": "Get the current weather in a given location",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
}
},
"required": ["location"]
}
}
]
messages = [{"role": "user", "content": "What's the weather in Boston?"}]
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
tools=tools,
messages=messages
)
That works. Now imagine you have a workflow where the output of get_weather feeds into check_flight_availability, which feeds into calculate_travel_cost, which feeds into fetch_user_preferences, which feeds into generate_itinerary. Each tool call adds a round trip. Each round trip adds tokens to your context. And Claude, being a good language model, sometimes decides mid-chain that it needs to re-call a tool it already called because the context has shifted enough that it's not confident in the earlier result.
That's the loop. And it's expensive.
The Three Failure Modes We Actually Hit
Tool Call Loops
The most painful failure: Claude calls tool_A, gets a result, calls tool_B, gets a result, then calls tool_A again with slightly different parameters because something in tool_B's output made it second-guess the first call. This can repeat. Without a hard circuit breaker, you'll exhaust your max_tokens budget or hit rate limits before the model ever reaches a stop_reason of end_turn.
The fix is a loop guard in your agentic runner. You track which tools have been called with which inputs, and if you see the same tool called with substantially the same parameters more than a set number of times, you inject a tool_result that tells Claude to proceed with what it has. Here's the pattern we settled on:
import anthropic
import json
from collections import defaultdict
client = anthropic.Anthropic()
def run_agent_loop(tools, tool_executor, initial_messages, max_iterations=10):
messages = initial_messages.copy()
call_counts = defaultdict(int)
iteration = 0
while iteration < max_iterations:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
tools=tools,
messages=messages
)
if response.stop_reason == "end_turn":
return response
if response.stop_reason != "tool_use":
# Unexpected stop — surface it instead of silently continuing
raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
call_key = f"{block.name}:{json.dumps(block.input, sort_keys=True)}"
call_counts[call_key] += 1
if call_counts[call_key] > 2:
# Circuit breaker: tell Claude to stop retrying this call
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "This tool has already been called with these parameters. Use the previous result and continue.",
"is_error": False
})
continue
result = tool_executor(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
iteration += 1
raise RuntimeError(f"Agent loop exceeded max_iterations ({max_iterations})")
The call_key approach is blunt but effective. You could get fancier with semantic similarity checks, but in practice the exact-match guard catches the majority of loops.
Silent Failures in Tool Results
This one is subtle. When a tool call fails — network timeout, bad data, whatever — you have two options for how to return that failure to Claude: set is_error: true in the tool_result, or return a string describing the error without flagging it as an error. The difference matters more than you'd think.
When is_error is true, Claude tends to acknowledge the failure and either try an alternative approach or surface the problem to the user. When you return an error description as a normal string result, Claude often treats it as valid information and continues reasoning from it. We had a case where a database query returned an empty result set, our handler returned "No records found" as a plain string, and Claude proceeded to generate a detailed report based on the assumption that the absence of records was meaningful signal rather than a potential data issue. Technically correct. Operationally wrong.
Return errors as errors:
def safe_tool_executor(tool_name, tool_input):
try:
result = execute_tool(tool_name, tool_input)
return {
"type": "tool_result",
"tool_use_id": tool_input.get("id"),
"content": json.dumps(result)
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_input.get("id"),
"content": f"Tool execution failed: {str(e)}",
"is_error": True
}
Token Budget Blowouts
Chaining five or more tools means your message history grows with every round trip. The assistant message containing the tool call gets appended. The user message containing the tool result gets appended. By the time you're on tool call six, you're carrying a lot of context weight.
Anthropic's extended thinking documentation covers budget_tokens for thinking, but the same principle applies to your overall context strategy. You need to be deliberate about what stays in the message history.
Two patterns that helped us:
First, summarize intermediate results rather than appending raw tool output verbatim. If a tool returns a 200-line JSON payload and only three fields are relevant to the next step, transform it before appending it. Claude doesn't need the full payload in context — it needs the signal.
Second, set a hard max_tokens on each individual API call that reflects what you actually need for that step, not a ceiling that gives Claude room to ramble. If the next step is a simple lookup, max_tokens=512 is fine. Reserving 4096 tokens per call across a six-tool chain is how you blow budgets.
What Most Guides Miss: The stop_reason Check
Every agentic loop tutorial I've seen focuses on handling tool_use as the stop reason. Almost none of them tell you to explicitly handle every other stop reason as a distinct case.
Claude can return stop_reason: "max_tokens" mid-chain. When that happens, the response content may include a partial tool call — a tool_use block with an incomplete input object. If your loop just checks if stop_reason != "end_turn": continue, you'll try to execute a malformed tool call and get confusing downstream errors.
Check stop_reason explicitly at every iteration:
VALID_CONTINUATION_REASONS = {"tool_use"}
TERMINAL_REASONS = {"end_turn", "stop_sequence"}
ERROR_REASONS = {"max_tokens"}
if response.stop_reason in TERMINAL_REASONS:
return response
elif response.stop_reason in ERROR_REASONS:
raise RuntimeError(f"Hit token limit mid-chain at iteration {iteration}. Consider summarizing intermediate results.")
elif response.stop_reason not in VALID_CONTINUATION_REASONS:
raise RuntimeError(f"Unhandled stop_reason: {response.stop_reason}")
This makes failures loud instead of silent. Loud failures are fixable. Silent failures ship to production.
Structuring Tools for LLM Reasoning
The quality of your tool descriptions is a bigger lever than most engineers expect. Claude decides which tool to call and in what order based on the description field. Vague descriptions produce erratic tool selection. Specific descriptions with clear scope boundaries produce predictable chains.
Compare these two descriptions for the same tool:
Vague: "Get information about a user"
Specific: "Retrieve a user's account status, subscription tier, and last login timestamp by user ID. Does not return PII or payment information."
The second description tells Claude what the tool returns, what it requires, and what it explicitly does not do. That last part matters because it prevents Claude from calling this tool hoping to get payment info and then re-calling it when it doesn't find any.
When we tightened up our tool descriptions while building out a workflow system at Bedda.tech, tool call efficiency improved noticeably — fewer redundant calls, cleaner chains, less context bloat. No invented numbers here, just a consistent pattern we observed across multiple workflow configurations.
The Pattern That Works in Production
Here's the mental model that pulled everything together for us: treat the Claude tool use API as a state machine, not a conversation. Each tool call is a state transition. Your agentic runner is the state machine controller. Claude is the transition function.
That framing changes how you think about every design decision:
- State machines have defined terminal states. Your runner needs explicit terminal conditions, not just "loop until
end_turn." - State machines don't revisit states without reason. Your loop guard enforces that.
- State machines carry only the state they need. Your context management reflects that.
- State machine failures are observable. Your
stop_reasonhandling makes failures visible.
The Anthropic documentation on agentic loops has gotten better over the past several months, and the core mechanics are solid. The production hardening — loop guards, error typing, context discipline, explicit stop reason handling — is the layer on top that the docs assume you'll figure out yourself.
You don't have to figure it out the hard way.
Concrete Recommendation
If you're building multi-step AI workflows with Claude right now, implement these four things before you ship anything:
- A hard iteration cap on your agentic loop with a loud exception when you hit it.
- A call-key-based loop guard that circuit-breaks repeated tool calls.
- Explicit
is_error: trueon all failed tool results, no exceptions. - Explicit
stop_reasonhandling that treatsmax_tokensas an error, not a continuation signal.
Everything else — semantic deduplication, dynamic context summarization, parallel tool execution — is optimization. Get the four fundamentals right first. The optimizations are a lot easier to reason about when your loop doesn't silently eat failures and spin forever.