Building a Multi-Turn Tool Use Loop
Loop tool_use and tool_result exchanges until Claude returns an end_turn.
Summary
The Messages API is stateless - each call is a fresh request with no memory of prior turns except what you send in messages. When Claude decides a tool is needed, it doesn't run the tool itself. It stops generating, returns a tool_use block describing what it wants to call, and hands control back to your code.
Your job is to execute the tool, package the result as a tool_result block, and send the whole conversation back. Claude then either asks for another tool call or produces its final answer. Many real tasks (looking something up, then acting on what was found, then verifying the result) take several such round trips before Claude is done.
This back-and-forth is the "agentic loop": call the API, inspect stop_reason, execute tools if asked, append results, call again. It ends when response.stop_reason == "end_turn". Get the loop wrong - drop content, forget a stop_reason, or skip the iteration guard - and you get either a crash on the next call or an agent that spins forever.
This page builds that loop by hand in Python with the official anthropic SDK, then shows the higher-level Tool Runner shortcut once the manual version is understood.
Recipe
Quick-reference recipe card - copy-paste ready.
import anthropic
client = anthropic.Anthropic()
messages = [{"role": "user", "content": user_input}]
while True:
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
break
if response.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": response.content})
continue
tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in tool_use_blocks:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
final_text = "".join(b.text for b in response.content if b.type == "text")When to reach for this:
- Claude needs to call one or more tools before it can give a final answer (lookups, calculations, side effects).
- The task may require multiple dependent steps - e.g. search, then fetch a specific record, then summarize.
- You're building an agent that runs unattended and must keep going until Claude is actually finished, not just after one tool call.
- You want full control over how tools execute (concurrency, retries, sandboxing) rather than delegating that to a framework.
Working Example
import json
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Denver'"},
},
"required": ["city"],
},
},
{
"name": "convert_temperature",
"description": "Convert a temperature between Celsius and Fahrenheit.",
"input_schema": {
"type": "object",
"properties": {
"value": {"type": "number"},
"from_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["value", "from_unit"],
},
},
]
def get_weather(city: str) -> str:
# Stand-in for a real weather API call.
fake_data = {"Denver": 8.0, "Miami": 29.0}
celsius = fake_data.get(city, 20.0)
return json.dumps({"city": city, "celsius": celsius})
def convert_temperature(value: float, from_unit: str) -> str:
if from_unit == "celsius":
result = value * 9 / 5 + 32
return json.dumps({"fahrenheit": round(result, 1)})
result = (value - 32) * 5 / 9
return json.dumps({"celsius": round(result, 1)})
def execute_tool(name: str, tool_input: dict) -> str:
if name == "get_weather":
return get_weather(tool_input["city"])
if name == "convert_temperature":
return convert_temperature(tool_input["value"], tool_input["from_unit"])
return json.dumps({"error": f"unknown tool: {name}"})
def run_agent(user_input: str, max_iterations: int = 12) -> str:
messages = [{"role": "user", "content": user_input}]
response = None
for _ in range(max_iterations):
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
break
if response.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": response.content})
continue
if response.stop_reason == "max_tokens":
raise RuntimeError("Response truncated at max_tokens; increase the limit.")
if response.stop_reason == "refusal":
raise RuntimeError(f"Claude declined the request: {response.stop_reason}")
tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
messages.append({"role": "assistant", "content": response.content})
if not tool_use_blocks:
# No tool calls and no end_turn - nothing left to do safely.
break
tool_results = []
for block in tool_use_blocks:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
else:
raise RuntimeError("Exceeded max_iterations without reaching end_turn.")
return "".join(b.text for b in response.content if b.type == "text")
if __name__ == "__main__":
answer = run_agent(
"What's the weather in Denver right now, in Fahrenheit?"
)
print(answer)What this demonstrates:
- A bounded loop (
for _ in range(max_iterations)) that always terminates, with aRuntimeErrorraised via thefor...elseclause if Claude never reachesend_turn. - The full
response.content- not just extracted text - appended as the assistant turn so thetool_useblocks Claude emitted are visible on the next call. - A
tool_use_id-matchedtool_resultfor everytool_useblock, batched into a singleusermessage per turn (required when Claude requests parallel tool calls). - Explicit handling for
pause_turn,max_tokens, andrefusalalongside the two "keep looping" paths (tool_useandend_turn). - A single
execute_tooldispatcher that keeps tool routing in one place instead of scatteredif/elifchains near the API call.
Deep Dive
How It Works
- Each
client.messages.create()call is independent. The API has no session state - everything Claude "remembers" is whatever you included inmessageson that call. - When Claude wants a tool, it stops generating output and returns content blocks - some
text(rare mid-loop, but possible), one or moretool_useblocks, and astop_reason. - Your code executes the requested tool(s) locally (or via a connector) and reports back with
tool_resultblocks, matched to the originating call viatool_use_id. - The next
messages.create()call re-sends the entire growing conversation - original user turn, Claude's assistant turn withtool_useblocks, youruserturn withtool_resultblocks - so Claude has full context to continue or conclude. - The loop is not "call tool, get answer" one-shot; Claude may chain several tool calls (look something up, then act on it, then verify) before it has enough information to answer, which is why looping - not a single request/response - is the right mental model.
stop_reason Values and What To Do
stop_reason | Meaning | Loop action |
|---|---|---|
end_turn | Claude is done; no more tool calls pending | Break out of the loop, return the text |
tool_use (implicit whenever tool_use blocks are present and no other terminal reason applies) | Claude wants one or more tools executed | Execute each tool, append tool_result blocks, continue |
pause_turn | A server-side tool (e.g. web search, code execution) hit its own internal iteration limit mid-turn | Append response.content as-is and resend; the API resumes automatically - do not add an extra "Continue" user message |
max_tokens | Output was truncated at the token cap | Raise/log an error; consider raising max_tokens or streaming instead of silently continuing |
refusal | Claude declined to continue for safety reasons | Inspect stop_details, surface it to the caller; do not blindly retry the same prompt |
Message Shape at Each Step
# After turn 1 (Claude requests a tool call):
messages == [
{"role": "user", "content": "What's the weather in Denver?"},
{"role": "assistant", "content": [ToolUseBlock(id="toolu_01...", name="get_weather", input={"city": "Denver"})]},
]
# After you execute the tool and append the result:
messages == [
{"role": "user", "content": "What's the weather in Denver?"},
{"role": "assistant", "content": [ToolUseBlock(...)]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01...", "content": "..."}]},
]Python Notes
# Prefer a dispatch dict over long if/elif chains as the tool count grows:
TOOL_HANDLERS = {
"get_weather": lambda i: get_weather(i["city"]),
"convert_temperature": lambda i: convert_temperature(i["value"], i["from_unit"]),
}
def execute_tool(name: str, tool_input: dict) -> str:
handler = TOOL_HANDLERS.get(name)
if handler is None:
return json.dumps({"error": f"unknown tool: {name}"})
try:
return handler(tool_input)
except Exception as exc:
# Report failures back to Claude as data, not a crashed process.
return json.dumps({"error": str(exc)})Parameters & Return Values
| Field | Type | Description |
|---|---|---|
response.stop_reason | str | Why generation stopped: end_turn, tool_use-implying (blocks present), pause_turn, max_tokens, refusal, etc. |
response.content | list[ContentBlock] | Ordered blocks Claude returned this turn - text and/or tool_use. Append verbatim as the assistant message. |
block.id | str | Unique id on a tool_use block; echo it back as tool_use_id in the matching tool_result. |
block.input | dict | Parsed arguments for the tool call, validated against the tool's input_schema. |
tool_result["content"] | str | list | The tool's output, as a string or content block list; keep it small and structured (e.g. JSON). |
Gotchas
- Appending only the extracted text, not
response.content. If you push{"role": "assistant", "content": final_text}instead of the raw content blocks, Claude's next request loses thetool_useblocks it needs to see, and the API will reject the follow-uptool_resultmessage because it doesn't match a preceding tool call. Fix: always appendresponse.contentunmodified as the assistant turn. - No iteration cap. A loop that only exits on
end_turnwill spin forever if a tool keeps returning ambiguous data or the model gets stuck re-requesting the same call. Fix: wrap the loop infor _ in range(max_iterations)(10-15 is a reasonable default) and raise/log a timeout when it's exhausted. - Treating
pause_turnlike a normal tool_use turn. Sending a fresh "Continue" user message afterpause_turn(instead of resending the paused assistant turn as-is) confuses the resumed server-side tool state. Fix: appendresponse.contentand call the API again immediately, with no extra user message. - Mismatched or missing
tool_use_id. Everytool_resultmust reference theidof thetool_useblock it answers; skipping a block (e.g. when Claude requests two parallel calls but you only handle one) leaves the API waiting on an unresolved tool call. Fix: iterate over alltool_useblocks in the response and produce exactly onetool_resultper block, batched into a singleusermessage. - Letting a tool exception crash the loop. An uncaught exception inside
execute_toolkills the whole request instead of giving Claude a chance to recover (e.g. retry with different arguments). Fix: catch exceptions per-tool and return them astool_resultcontent (e.g.{"error": "..."}) so Claude can react. - Ignoring
max_tokensas a stop reason. Treatingmax_tokenslikeend_turnsilently ships a truncated answer to the user. Fix: check for it explicitly and either raisemax_tokenson the next call or switch to streaming. - Blindly retrying on
refusal. Looping the same prompt again after a safety refusal wastes calls and won't change the outcome. Fix: inspectstop_details, adjust the request or escalate to a human instead of retrying verbatim.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
client.beta.messages.tool_runner(...) with @beta_tool-decorated functions, then runner.until_done() | You want the call -> execute -> feed-back -> repeat cycle automated and don't need fine-grained control over execution (concurrency, sandboxing, custom retry logic) | You need to intercept every turn - e.g. streaming partial results to a UI, custom logging per tool call, or non-standard result formatting |
Single-shot request with tool_choice forcing exactly one tool call, no loop | You know in advance the answer needs exactly one tool call and nothing more | The task may need follow-up calls based on what the first tool returns |
| A framework/orchestrator (e.g. LangChain, a custom agent runtime) that wraps its own loop | You're already standardized on that framework across the codebase and want consistency over minimalism | You want to understand and control exactly what's sent to the API on each turn, or you want zero extra dependencies |
FAQs
Why can't I just make one API call and expect the final answer?
Because the API is stateless and Claude can't execute tools itself. If Claude needs external data, it returns a tool_use block and stops - your code must run the tool, send back a tool_result, and call the API again for Claude to continue.
How do I know when the loop is finished?
Check response.stop_reason == "end_turn". That is the only reliable signal that Claude has no more tool calls pending and has produced its final answer.
What happens if I only append the text Claude generated instead of the full content blocks?
The next request will be missing the tool_use blocks that your tool_result message is supposed to answer, and the API will reject it. Always append response.content as-is.
Do I need a separate tool_result message for each tool call?
No - batch all tool_result blocks for a given turn into a single user message, one block per tool_use_id from that turn's tool_use blocks.
What's the difference between tool_use and pause_turn?
tool_use: Claude is asking your code to run a tool; you execute it and send back atool_result.pause_turn: a server-side tool (like web search) hit its own internal iteration limit; you just resend the conversation unchanged and the API resumes that tool on its own.
Why do I need a max_iterations guard if the loop already exits on end_turn?
Because end_turn isn't guaranteed to arrive - a misbehaving tool, ambiguous data, or a model stuck re-requesting the same call can keep the loop going indefinitely. The guard turns an infinite hang into a bounded failure you can detect and report.
What should execute_tool return when the tool itself fails?
A string (often JSON) describing the error, e.g. json.dumps({"error": str(exc)}), sent back as normal tool_result content. Catch the exception yourself; don't let it crash the loop, and don't stay silent - Claude needs to see that the call failed to react appropriately.
Should I retry automatically when stop_reason is refusal?
No. A refusal means Claude declined for safety reasons - retrying the identical prompt won't change that. Inspect stop_details, adjust the request, or route to a human reviewer instead.
What does stop_reason == "max_tokens" mean for my loop?
The response was cut off because it hit the max_tokens cap - it is not a completed answer and not a tool request. Treat it as an error condition: raise max_tokens, switch to streaming, or otherwise handle it explicitly rather than treating it like end_turn.
Can Claude request more than one tool call in a single turn?
Yes. response.content can contain multiple tool_use blocks in one turn (parallel tool calls). Loop over all of them and return one tool_result per block in the same follow-up user message.
When should I use the Tool Runner instead of a manual loop?
Once you understand the manual loop and don't need low-level control over execution - the Tool Runner (client.beta.messages.tool_runner(...) with @beta_tool-decorated functions and runner.until_done()) automates the same call -> execute -> feed-back -> repeat cycle with less boilerplate.
What model should example code target?
claude-sonnet-5 - the current default model in the Claude lineup (alongside Claude Fable 5, Claude Opus 4.8, and Claude Haiku 4.5) - is a solid default for tool-use loops that balances capability and cost.
Related
- Handling tool_use Requests and Returning tool_result Blocks - the single-turn mechanics this loop repeats on every iteration.
- Executing Parallel Tool Calls in a Single Turn - how to handle multiple
tool_useblocks returned in one turn. - Tool Use Basics - the foundational concepts (tools,
tool_choice, schemas) this page builds on. - Best Practices - broader guidance for reliable, production-ready tool use.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
anthropicPython SDK (latest 0.x release). Model names, SDK versions, and pricing move quickly - verify current specifics at platform.claude.com/docs before relying on them.