Claude tool_use: Surviving 5+ Chained AI Calls
The Claude tool_use API will humble you around call number four. That's not a guess — that's what happened to us building the research pipeline for KRAIN, and it happened again on Crowdia's data enrichment workflow. This post is a direct comparison of two approaches to multi-step agentic loops: the naive "just chain the calls" method we started with versus the structured, budget-aware, failure-hardened approach we ended up with. One of them works at scale. One of them burns tokens, loops forever, and silently corrupts state.
I'll tell you exactly which is which, show the real code we use, and give you a clear verdict on when to use each pattern.
The Two Approaches: Naive Chaining vs. Structured Agentic Loops
Approach A — Naive Chaining: You call Claude, it returns a tool_use block, you execute the tool, you append the tool_result, you call Claude again. Repeat. You stop when Claude returns a final text response with no tool calls. Simple, fast to prototype.
Approach B — Structured Agentic Loop: You define a hard turn limit, a token budget per turn, explicit error recovery paths, loop detection via call fingerprinting, and a graceful degradation strategy. More setup. Dramatically more reliable.
I ran Approach A in production for about three weeks on KRAIN before I switched. Here's what broke.
Artificial Intelligence Failure Mode #1: The Infinite Tool Loop
Approach A has no loop detection. Claude will absolutely call the same tool with the same arguments multiple times if its internal reasoning gets stuck. We saw this on a workflow that called a search_documents tool — Claude would search, get a result, reason that it needed more context, and search again with a nearly identical query. Six times. On a 200k context window, that's catastrophic.
The specific error pattern looked like this in our logs:
Turn 1: tool_use { name: "search_documents", input: { query: "KRAIN user onboarding flow" } }
Turn 2: tool_use { name: "search_documents", input: { query: "user onboarding flow KRAIN" } }
Turn 3: tool_use { name: "search_documents", input: { query: "onboarding KRAIN users" } }
Semantically identical. Token cost: ~14,000 tokens per round trip on claude-opus-4. Three redundant calls = ~42,000 tokens wasted before we even got a useful response.
The fix in Approach B:
from hashlib import md5
import json
def fingerprint_tool_call(tool_name: str, tool_input: dict) -> str:
canonical = json.dumps({"name": tool_name, "input": tool_input}, sort_keys=True)
return md5(canonical.encode()).hexdigest()
class AgentLoop:
def __init__(self, max_turns: int = 10, max_identical_calls: int = 2):
self.max_turns = max_turns
self.max_identical_calls = max_identical_calls
self.call_fingerprints: dict[str, int] = {}
self.messages: list[dict] = []
self.turn_count = 0
def check_loop(self, tool_name: str, tool_input: dict) -> bool:
fp = fingerprint_tool_call(tool_name, tool_input)
count = self.call_fingerprints.get(fp, 0) + 1
self.call_fingerprints[fp] = count
return count > self.max_identical_calls
def run(self, client, system_prompt: str, initial_message: str, tools: list) -> str:
self.messages = [{"role": "user", "content": initial_message}]
while self.turn_count < self.max_turns:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
system=system_prompt,
tools=tools,
messages=self.messages,
)
self.turn_count += 1
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
break
if response.stop_reason != "tool_use":
break
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
if self.check_loop(block.name, block.input):
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"[LOOP DETECTED] Tool '{block.name}' called with identical arguments too many times. Proceed with available information.",
"is_error": True,
})
continue
result = self._execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
self.messages.append({"role": "assistant", "content": response.content})
self.messages.append({"role": "user", "content": tool_results})
return "[Agent loop exhausted without final response]"
def _execute_tool(self, name: str, input_data: dict) -> str:
raise NotImplementedError("Subclass and implement tool dispatch here")
That [LOOP DETECTED] string injected as a tool_result with is_error: True is the key. Claude reads it, acknowledges it's stuck, and moves on. Without it, it doesn't know it's looping.
LLM Token Budget Math: Why Call #5 Is Different From Call #1
This is the part most tutorials skip entirely, and it killed us on Crowdia's enrichment pipeline. With claude-opus-4-5, you're paying per input token on every single API call — and in a chained workflow, your input tokens compound.
By turn 5 in a naive chain, your messages array contains:
- The original user message
- 4x assistant responses (including all tool_use blocks)
- 4x user messages with tool_results
On a moderately complex workflow touching 3-4 tools per turn, we measured input token counts like this:
| Turn | Approx Input Tokens | Approx Output Tokens |
|---|---|---|
| 1 | 1,200 | 800 |
| 2 | 2,800 | 900 |
| 3 | 5,100 | 750 |
| 4 | 8,400 | 1,100 |
| 5 | 13,200 | 850 |
| 6 | 19,500 | 900 |
That's not linear growth — tool_use blocks from Claude's responses are verbose JSON, and they accumulate. By turn 6 you're paying for nearly 20k input tokens to process what started as a 1,200 token conversation.
Approach B's answer: Sliding window compression. We keep the last N turns in full fidelity and summarize earlier turns into a compact context block. Here's what that looks like in practice:
def compress_message_history(
messages: list[dict],
keep_recent_turns: int = 4,
summarizer_fn=None,
) -> list[dict]:
"""
Keeps the last `keep_recent_turns` user/assistant pairs intact.
Earlier turns are replaced with a summarized context block.
"""
# Each turn = 1 user message + 1 assistant message = 2 entries
cutoff_index = len(messages) - (keep_recent_turns * 2)
if cutoff_index <= 0:
return messages
early_messages = messages[:cutoff_index]
recent_messages = messages[cutoff_index:]
if summarizer_fn:
summary_text = summarizer_fn(early_messages)
else:
# Default: extract just text content from early messages
summary_parts = []
for msg in early_messages:
if isinstance(msg["content"], str):
summary_parts.append(f"[{msg['role']}]: {msg['content'][:200]}")
summary_text = "Prior context summary:\n" + "\n".join(summary_parts)
compressed_context = {
"role": "user",
"content": summary_text,
}
compressed_ack = {
"role": "assistant",
"content": "Understood. I have the prior context summary and will continue from there.",
}
return [compressed_context, compressed_ack] + recent_messages
We call this every 3 turns in our production loops. It keeps input token growth roughly linear instead of quadratic.
AI Integration Error Recovery: The is_error Flag Is Not Optional
Approach A almost universally ignores error handling on tool results. You execute a tool, it throws, you either crash the loop or return an empty string. Claude then hallucinates a result because it received no meaningful feedback.
The Anthropic tool_use documentation is explicit: set is_error: true in your tool_result when a tool fails. Claude is trained to handle this gracefully — it will acknowledge the failure, attempt an alternative approach, or ask for clarification rather than fabricating an answer.
In Approach A, we were returning:
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
Claude would receive an empty result and just... continue reasoning as if the tool had succeeded. Silent corruption. We caught this three weeks in when KRAIN's document pipeline was returning confidently-worded summaries of documents that didn't exist.
In Approach B:
try:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Tool execution failed: {type(e).__name__}: {str(e)}. Please adjust your approach.",
"is_error": True,
})
The string content of the error matters. "Tool execution failed: ConnectionError: Database unreachable" gives Claude enough signal to try a fallback. "" gives it nothing.
Machine Learning Best Practices: Prompt Injection via Tool Results
This one's subtle and increasingly relevant. If your tools fetch external data — web searches, database records, user-generated content — that content lands in your tool_result and goes straight into Claude's context. A malicious document can contain instructions that redirect the agent's behavior.
Projects like AgentNest are building sandboxed execution environments partly to address this. For our purposes, we added a lightweight sanitization layer on tool results before they re-enter the message history:
INJECTION_PATTERNS = [
r"ignore previous instructions",
r"you are now",
r"system prompt:",
r"<\|im_start\|>",
r"assistant:",
]
import re
def sanitize_tool_result(content: str) -> str:
for pattern in INJECTION_PATTERNS:
if re.search(pattern, content, re.IGNORECASE):
return f"[CONTENT REDACTED: Potential injection pattern detected]"
# Truncate oversized results to prevent context flooding
if len(content) > 8000:
content = content[:8000] + "\n\n[Result truncated at 8000 chars]"
return content
Not bulletproof, but it stops the obvious attacks. For anything handling truly untrusted input, run your tools in an isolated process.
Direct Comparison: Naive Chaining vs. Structured Agentic Loop
| Dimension | Naive Chaining (Approach A) | Structured Loop (Approach B) |
|---|---|---|
| Setup time | ~30 minutes | ~4 hours |
| Loop detection | None | Fingerprint-based, configurable |
| Token growth | Quadratic | Near-linear with compression |
| Error handling | Silent empty results | Explicit is_error with context |
| Turn limit | None (runs until end_turn) | Hard cap, configurable |
| Prompt injection | No protection | Pattern-matched sanitization |
| Debuggability | Poor (no state tracking) | Full turn-by-turn logging |
| Production ready | No | Yes |
Verdict: Use Approach A Never, Approach B Always
I'll be direct: Approach A is only appropriate for throwaway demos where you control every tool input and the workflow is 2-3 turns maximum. The moment you have external data sources, more than 3 tool calls, or any user-facing output that needs to be reliable, you need Approach B.
The setup cost is real — four hours is not nothing. But the debugging cost of silent failures in production, the token waste from undetected loops, and the support burden of a pipeline that confidently hallucinates results far exceeds that upfront investment. We learned this the hard way on KRAIN and paid for it in both engineering time and API costs.
The Anthropic documentation on agentic loops covers the message structure requirements well. What it doesn't cover is the operational reality of what happens when you chain 5, 8, or 12 calls in a production environment. That's the gap this post is meant to fill.
Start with Approach B. Add your loop limit first, your error handling second, your token compression third. The Claude tool_use API is genuinely powerful for building agentic workflows — but it rewards the engineers who respect it with structure, not the ones who assume it'll sort itself out.
It won't. Ask me how I know.