The Tool-Use Loop¶
Level: Intermediate · Time: ~45 min · Exam relevance: CCAR-P 3.3, 5.3
In plain English¶
One tool call is rarely enough. To answer "Which of my overdue invoices are over £5,000, and email the account owners?", Claude might list invoices, filter them, look up owners and draft emails. Each step needs a tool result before the next step makes sense.
The tool-use loop is the small piece of code that keeps the conversation going:
- Send the conversation to Claude.
- If Claude asks for tools, run them and send the results back.
- Repeat until Claude says it is done.
You can write this loop yourself (a manual loop) or let the Anthropic SDK's Tool Runner do it. Either way, you need to understand the rules underneath: how to send results for several tools at once, how to report a failed tool, and what each stop_reason means.
Why it matters¶
- Correctness. Most tool-use bugs are loop bugs: a missing
tool_result, results split across messages, or a paused server turn treated as finished. - Latency. Running parallel tool calls concurrently, and keeping round trips low, is often the biggest latency win in an agent (CCAR-P-3.3).
- Control points. The loop is where you put human approval, logging, budgets and kill switches (CCAR-P-5.3).
- Cost. Every loop iteration resends the whole conversation. Unbounded loops are unbounded spend.
How it works¶
The stop reasons you must handle¶
Always check stop_reason before you use content.
stop_reason |
Meaning | What your loop does |
|---|---|---|
end_turn |
Claude finished its answer | Exit the loop and return the text |
tool_use |
Claude wants one or more client tools | Run them, append results, call again |
pause_turn |
A server-side tool loop (for example web search) paused a long turn | Append the assistant content unchanged and call again with the same tools; do not add a "Continue" message |
max_tokens |
Output hit your max_tokens cap |
Do not run a truncated tool_use; retry with a higher cap or stop |
refusal |
A safety classifier declined the request | Do not execute any tools from this turn; read stop_details, then fall back or report |
stop_sequence |
A custom stop sequence matched | Handle as your design requires |
Note: On Claude Opus 5.5 and other current models, a
refusalcan arrive with HTTP 200. Anthropic offers a server-sidefallbacksoption (beta) that retries refused requests on another model; check the refusals and fallback guide before relying on it.
Message-ordering rules¶
These rules cause most 400 errors:
- Append Claude's entire
response.contentas theassistantmessage. It carries thetool_useblocks your results refer to (and any thinking blocks, which must be passed back unchanged). - The very next message is a
usermessage containing atool_resultfor everytool_useid in that assistant turn. - Inside that user message, all
tool_resultblocks come first. Text, if any, goes after them. - If the assistant turn also contains a
server_tool_usethat has no result yet (Claude called a server tool and a client tool together), the user message must contain onlytool_resultblocks, and you must send the sametoolsarray again.
Parallel tool calls¶
By default Claude may request several tools in one turn, for example three independent lookups. Run them concurrently if they are independent, then return all results in one user message.
Splitting results across several user messages is not only an error risk: Anthropic's guidance notes it teaches Claude to stop making parallel calls, which costs you latency. If you need strictly one call at a time (for example, tools with side effects that must be sequenced), set "disable_parallel_tool_use": true inside tool_choice with "type": "auto".
Reporting failures with is_error¶
When a tool fails, still return a tool_result, with "is_error": true and a message Claude can act on:
{
"type": "tool_result",
"tool_use_id": "toolu_01...",
"is_error": true,
"content": "Order A-9999 not found. Order IDs look like A-1234; ask the customer to check their confirmation email."
}
Instructive errors let Claude recover: retry with corrected input, try another tool, or ask the user. "Error 500" does not. Anthropic's documentation notes that for invalid or missing parameters Claude will typically retry two or three times with corrections before apologising to the user.
Forced tool use is gone on Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1¶
Older loops sometimes forced the first call with tool_choice: {"type": "tool", ...}. On Claude Opus 5.5, Claude Sonnet 5.5 and Claude Fable 5.1 that returns a 400. Use auto, instruct clearly, and check whether a tool_use came back. See Defining tools.
Manual loop or Tool Runner?¶
| Approach | You write | Good for |
|---|---|---|
| Manual loop | The while loop, dispatch, result formatting |
Full control, no beta dependency, unusual control flow, teaching |
Tool Runner (client.beta.messages.tool_runner, beta) |
Only the tool functions (@beta_tool in Python) |
Most custom-tool agents. The SDK calls the API, runs your functions, feeds results back and stops when Claude is done |
| Claude Agent SDK | A prompt and options | A batteries-included coding agent with built-in file and shell tools; see Module 09 |
| Managed Agents | Agent configuration | Anthropic hosts both the loop and the sandbox; see Module 10 |
The Tool Runner is not a black box: you can inspect each yielded message, gate a call inside the tool function, or modify results. One caution as of 2026-10-24: the Python Tool Runner does not resume pause_turn automatically. If you mix server tools into a runner, handle pause_turn yourself or use a manual loop.
Example¶
A manual loop with parallel execution, validation and approval¶
This loop handles every stop reason in the table, runs parallel calls concurrently, returns errors with is_error, and asks a human before any tool marked as having side effects.
Cost: requires API credits (live mode).
"""Manual tool-use loop for a fictional Acme Logistics support assistant."""
from __future__ import annotations
import json
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY from the environment
MODEL = os.environ.get("CLAUDE_MODEL", "claude-opus-5-5")
MAX_ITERATIONS = 10
# --- Tool implementations (synthetic data) -----------------------------------
ORDERS = {"A-1042": {"status": "shipped", "carrier": "Parcelwise"},
"A-1043": {"status": "packing", "carrier": None}}
def get_order_status(order_id: str) -> dict[str, Any]:
if order_id not in ORDERS:
raise LookupError(f"Order {order_id} not found. IDs look like A-1234.")
return {"order_id": order_id, **ORDERS[order_id]}
def cancel_order(order_id: str, reason: str) -> dict[str, Any]:
if ORDERS.get(order_id, {}).get("status") == "shipped":
raise ValueError("Shipped orders cannot be cancelled; offer a return instead.")
return {"order_id": order_id, "cancelled": True}
REGISTRY: dict[str, tuple[Callable[..., Any], bool]] = {
# name: (function, has_side_effects)
"get_order_status": (get_order_status, False),
"cancel_order": (cancel_order, True),
}
TOOLS = [
{"name": "get_order_status", "strict": True,
"description": "Look up one order's fulfilment status. Call for any 'where is my order' question.",
"input_schema": {"type": "object", "additionalProperties": False, "required": ["order_id"],
"properties": {"order_id": {"type": "string", "description": "Format A-1234"}}}},
{"name": "cancel_order", "strict": True,
"description": "Cancel an order that has not shipped. Only call after the customer explicitly asks to cancel.",
"input_schema": {"type": "object", "additionalProperties": False, "required": ["order_id", "reason"],
"properties": {"order_id": {"type": "string"},
"reason": {"type": "string", "description": "Customer's stated reason"}}}},
]
def human_approves(name: str, args: dict[str, Any]) -> bool:
"""Confirmation gate. Replace with your UI or ticketing workflow."""
answer = input(f"Approve {name}({json.dumps(args)})? [y/N] ")
return answer.strip().lower() == "y"
def run_one(block) -> dict[str, Any]:
"""Execute one tool_use block and always return a tool_result."""
entry = REGISTRY.get(block.name)
if entry is None:
return {"type": "tool_result", "tool_use_id": block.id, "is_error": True,
"content": f"Unknown tool {block.name}."}
func, side_effects = entry
if side_effects and not human_approves(block.name, block.input):
return {"type": "tool_result", "tool_use_id": block.id, "is_error": True,
"content": "The user declined this action. Do not retry it; ask what they would like instead."}
try:
result = func(**block.input) # strict: true guarantees the shape; func checks business rules
return {"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}
except Exception as exc: # report, never crash the loop
return {"type": "tool_result", "tool_use_id": block.id, "is_error": True, "content": str(exc)}
def answer(question: str) -> str:
messages: list[dict[str, Any]] = [{"role": "user", "content": question}]
for _ in range(MAX_ITERATIONS):
response = client.messages.create(
model=MODEL, max_tokens=16000, tools=TOOLS, messages=messages,
system="You are Acme Logistics' order assistant. Use tools; never guess order status.",
)
if response.stop_reason == "end_turn":
return "".join(b.text for b in response.content if b.type == "text")
if response.stop_reason == "refusal":
return "This request can't be completed. A human agent will follow up."
if response.stop_reason == "max_tokens":
raise RuntimeError("Output truncated; raise max_tokens rather than running partial tool calls.")
messages.append({"role": "assistant", "content": response.content}) # full content, always
if response.stop_reason == "pause_turn":
continue # resend unchanged so the server-side loop resumes
if response.stop_reason == "tool_use":
calls = [b for b in response.content if b.type == "tool_use"]
approval_needed = any(REGISTRY.get(c.name, (None, False))[1] for c in calls)
if approval_needed:
results = [run_one(c) for c in calls] # sequential so prompts don't interleave
else:
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(run_one, calls)) # order preserved
messages.append({"role": "user", "content": results}) # ALL results, ONE message
continue
raise RuntimeError(f"Unhandled stop_reason: {response.stop_reason}")
raise RuntimeError("Loop limit reached; escalate to a human.")
if __name__ == "__main__":
print(answer("Where are orders A-1042 and A-1043? If A-1043 hasn't shipped, cancel it, I ordered twice."))
Points worth noticing: the loop is bounded; every tool_use gets exactly one tool_result; failures become is_error results rather than exceptions; and the cancellation tool cannot run without a person saying yes.
The same assistant with the Tool Runner¶
The Tool Runner builds the schema from type hints and the docstring, and drives the loop for you. The approval gate moves inside the tool function.
import json
import os
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def get_order_status(order_id: str) -> str:
"""Look up one order's fulfilment status. Call for any 'where is my order' question.
Args:
order_id: Order reference in the form A-1234.
"""
orders = {"A-1042": "shipped", "A-1043": "packing"}
if order_id not in orders:
return json.dumps({"error": f"Order {order_id} not found. IDs look like A-1234."})
return json.dumps({"order_id": order_id, "status": orders[order_id]})
runner = client.beta.messages.tool_runner(
model=os.environ.get("CLAUDE_MODEL", "claude-opus-5-5"),
max_tokens=16000,
tools=[get_order_status],
messages=[{"role": "user", "content": "Where are orders A-1042 and A-1043?"}],
)
final = runner.until_done() # iterate `for message in runner:` instead to observe each turn
print(final.stop_reason)
print("".join(b.text for b in final.content if b.type == "text"))
Note: The Tool Runner is a beta helper in the Anthropic SDKs (as of 2026-10-24). For streaming requests with client tools, Anthropic recommends
eager_input_streaming: trueon each tool, which means you must validate parsed inputs yourself.@beta_toolfunctions with typed parameters are validated by the SDK.
Enterprise use case¶
Fabrikam Insurance (fictional) built a claims-intake assistant with six read tools and two write tools (claim_create_draft, claim_submit). Early latency was poor: Claude requested policy, customer and prior-claims lookups in parallel, but the team's loop executed them sequentially and, worse, returned them in three separate user messages. Over a week of logs, parallel calls became rarer.
The fix had three parts. The loop now runs read tools concurrently and returns one combined user message; median time to first draft fell noticeably in their load tests. claim_submit is gated: the loop pauses, the human adjuster sees the draft and approves in the claims UI, and only then does the tool run. Finally, a ten-iteration cap and a per-conversation token budget stop runaway loops, with an is_error result telling Claude to summarise progress for a human when the budget is near.
Common mistakes¶
- Only checking for
end_turn. Apause_turnormax_tokenstreated as "done" silently truncates answers. - Splitting parallel results across several user messages, or placing text before
tool_resultblocks. - Raising exceptions out of the loop instead of returning
is_errorresults. The conversation then cannot recover. - Adding "Continue" after
pause_turn. Resend the assistant content unchanged; the API resumes on its own. - No iteration or budget limit. A confused model plus a failing tool can loop for a long time.
- Approving side effects in parallel. Interleaved approval prompts confuse reviewers; sequence calls that need approval.
- Assuming the Python Tool Runner resumes
pause_turn. It does not (as of 2026-10-24); handle it explicitly.
Security considerations¶
- Gate side effects in code, not in the prompt. "Ask before cancelling" in the system prompt is guidance; a confirmation check in the loop is enforcement. See human oversight.
- Bound the loop. Iteration caps, token budgets and wall-clock timeouts limit damage from prompt injection or tool failures.
- Log every call with tool name, validated input, result size, approver and conversation ID for audit (logging and auditability).
- Never run tools from a
refusalor truncated turn. - Sanitise errors. Error messages go back into the model's context; do not include stack traces, secrets or internal hostnames.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
400: tool_use ids were found without tool_result blocks immediately after |
A result is missing, misplaced, or text came first | One user message, every id answered, results first |
400 naming an unresolved web_fetch or other server tool |
You added text after tool_result blocks while a server tool was pending, or dropped it from tools |
Send only tool_result blocks and the same tools array |
| Parallel calls stopped happening | Results returned in separate messages | Return all results together |
| Answer ends mid-research | pause_turn treated as final |
Resend assistant content and continue |
| Loop never ends | Tool keeps failing with an unhelpful error | Make errors instructive; cap iterations |
More cases: tools and agents troubleshooting.
Check your understanding¶
- Claude returns three
tool_useblocks in one turn. How many user messages should you send back, and in what order are the blocks inside? - What is the correct response to
stop_reason: "pause_turn"? - A tool throws a timeout. What should your loop send to Claude?
- Name one reason to keep a manual loop instead of the Tool Runner.
- Why should a side-effecting tool's approval be enforced in code rather than in the system prompt?
Answers
- One user message containing three
tool_resultblocks (one pertool_useid), with thetool_resultblocks first and any text after them. - Append the assistant's content unchanged and call the API again with the same
tools; do not add a "Continue" user message. - A
tool_resultwith"is_error": trueand an instructive message, for example that the service timed out and when to retry. - Any one of: avoiding a beta dependency; unusual control flow; handling
pause_turnexplicitly in Python; a custom transport or request shape the SDK cannot build. - The model's behaviour can be influenced by injected content or mistakes; a code-level gate holds regardless of what the model decides.
Further reading¶
- Handle tool calls (Claude Docs)
- Parallel tool use (Claude Docs)
- Tool Runner (Claude Docs)
- Handling stop reasons (Claude Docs)
- The agent loop in Module 10