Building a Multi-Turn Tool Use Loop
Loop tool_use and tool_result exchanges until Claude returns an end_turn.
Search across all documentation pages
Loop tool_use and tool_result exchanges until Claude returns an end_turn.
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.
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:
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:
for _ in range(max_iterations)) that always terminates, with a RuntimeError raised via the for...else clause if Claude never reaches end_turn.response.content - not just extracted text - appended as the assistant turn so the tool_use blocks Claude emitted are visible on the next call.tool_use_id-matched tool_result for every tool_use block, batched into a single user message per turn (required when Claude requests parallel tool calls).pause_turn, max_tokens, and refusal alongside the two "keep looping" paths (tool_use and end_turn).execute_tool dispatcher that keeps tool routing in one place instead of scattered if/elif chains near the API call.client.messages.create() call is independent. The API has no session state - everything Claude "remembers" is whatever you included in messages on that call.text (rare mid-loop, but possible), one or more tool_use blocks, and a stop_reason.tool_result blocks, matched to the originating call via tool_use_id.messages.create() call re-sends the entire growing conversation - original user turn, Claude's assistant turn with tool_use blocks, your user turn with tool_result blocks - so Claude has full context to continue or conclude.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 |
# 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": "..."}]},
]# 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)})| 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). |
response.content. If you push {"role": "assistant", "content": final_text} instead of the raw content blocks, Claude's next request loses the tool_use blocks it needs to see, and the API will reject the follow-up tool_result message because it doesn't match a preceding tool call. Fix: always append response.content unmodified as the assistant turn.end_turn will spin forever if a tool keeps returning ambiguous data or the model gets stuck re-requesting the same call. Fix: wrap the loop in for _ in range(max_iterations) (10-15 is a reasonable default) and raise/log a timeout when it's exhausted.pause_turn like a normal tool_use turn. Sending a fresh "Continue" user message after pause_turn (instead of resending the paused assistant turn as-is) confuses the resumed server-side tool state. Fix: append response.content and call the API again immediately, with no extra user message.tool_use_id. Every tool_result must reference the id of the tool_use block 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 all tool_use blocks in the response and produce exactly one tool_result per block, batched into a single user message.execute_tool kills 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 as tool_result content (e.g. {"error": "..."}) so Claude can react.max_tokens as a stop reason. Treating max_tokens like end_turn silently ships a truncated answer to the user. Fix: check for it explicitly and either raise max_tokens on the next call or switch to streaming.refusal. Looping the same prompt again after a safety refusal wastes calls and won't change the outcome. Fix: inspect stop_details, adjust the request or escalate to a human instead of retrying verbatim.| 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 |
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.
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.
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.
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.
tool_use: Claude is asking your code to run a tool; you execute it and send back a tool_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.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.
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.
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.
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.
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.
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.
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.
tool_use blocks returned in one turn.tool_choice, schemas) this page builds on.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.
Reviewed by Chris St. John·Last updated Jul 19, 2026