Tool runner (SDK)
Use the SDKâs tool runner to handle the agentic loop, error wrapping, and type safety automatically.
The tool runner handles the agentic loop, error wrapping, and type safety so you donât have to. When you need human-in-the-loop approval, custom logging, or conditional execution, use the manual loop instead.
Instead of manually handling tool calls, tool results, and conversation management, the tool runner automatically:
- Runs tools when Claude calls them
- Handles the request/response cycle
- Manages conversation state
- Provides type safety and validation
The tool runner is available in the Python SDK, TypeScript SDK, C# SDK, Go SDK, Java SDK, PHP SDK, and Ruby SDK.
Basic usage
Define tools using the SDK helpers, then use the tool runner to run them.
Depending on the SDKâs tool signature, a tool returns its result as a string or as content blocks (text, image, or document blocks), so a tool can return multimodal results. A returned string becomes a single text content block. To return structured data, such as a JSON object or a number, encode it as a string first.
Use the @beta_tool decorator to define tools with type hints and docstrings.
import json
from anthropic import Anthropic, beta_tool
client = Anthropic()
@beta_tool
def get_weather(location: str, unit: str = "fahrenheit") -> str:
"""Get the current weather in a given location.
Args:
location: The city and state, e.g. San Francisco, CA
unit: Temperature unit, either 'celsius' or 'fahrenheit'
"""
return json.dumps({"temperature": "20°C", "condition": "Sunny"})
@beta_tool
def calculate_sum(a: int, b: int) -> str:
"""Add two numbers together.
Args:
a: First number
b: Second number
"""
return str(a + b)
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
tools=[get_weather, calculate_sum],
messages=[
{
"role": "user",
"content": "What's the weather like in Paris? Also, what's 15 + 27?",
}
],
)
for message in runner:
print(message)The @beta_tool decorator inspects the function arguments and docstring to derive the JSON schema for you.
Iterating over the tool runner
The tool runner is an iterable that yields messages from Claude. On each iteration, the runner checks whether Claude requested a tool use. If so, it runs the tool and sends the result back to Claude automatically, then yields the next message from Claude to continue your loop.
You can end the loop at any iteration with a break statement. The runner loops until Claude returns a message without a tool use, or until it reaches max_iterations if you set it.
If you donât need intermediate messages, you can get the final message directly:
Use runner.until_done() to get the final message.
client = anthropic.Anthropic()
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
tools=[get_weather, calculate_sum],
messages=[
{
"role": "user",
"content": "What's the weather like in Paris? Also, what's 15 + 27?",
}
],
)
final_message = runner.until_done()
for block in final_message.content:
if block.type == "text":
print(block.text)Advanced usage
Within the loop, you can read each response message and modify the runnerâs state before the next API call. Each iteration follows this lifecycle:
- The runner sends a request to the Messages API with its current state.
- The runner yields the response message to your loop body.
- Your loop body runs. You can read the message and optionally modify the runnerâs state.
- When your loop body returns, the runner checks whether you modified its message history.
- If you did not modify message history: If the message contains tool calls, the runner appends the assistant message and the tool results, then continues. If there are no tool calls, the loop exits.
- If you modified message history: The runner skips its automatic append and uses your state unchanged. See Taking over message history.
Taking over message history
By default, the runner manages conversation state for you: after each tool-call turn, it appends the assistant message and any tool results to its own message history. You take over message history when you want to retry a turn (discard the response and resend), inject a follow-up message, or build the tool result yourself.
You take over by modifying the runnerâs messages from inside the loop body. The exact method depends on the SDK. See the per-language tabs that follow.
When you take over for an iteration, the runner does not append the assistant message or tool results from that turn. You become responsible for keeping the conversation valid. Append the assistant message and a tool result yourself if you want the turn to count. Modify state only conditionally, so that the loop can still exit when there are no tool calls. To bound the loop, pass max_iterations.
Use generate_tool_call_response() to inspect or compute the tool result. Calling append_messages() inside the loop tells the runner youâre managing history yourself, so include the assistant message and tool result in what you append.
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
max_iterations=10,
tools=[get_weather],
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
)
for message in runner:
tool_response = runner.generate_tool_call_response()
if tool_response is not None:
# append_messages() flags state as modified, so the runner skips its
# automatic append for this iteration. Append the assistant message and
# tool result yourself, plus any follow-up.
runner.append_messages(
message,
tool_response,
{"role": "user", "content": "Please be concise."},
)
# When there's no tool call, leave state untouched so the loop exits.To change request parameters such as max_tokens without taking over message history, use set_messages_params(). The runner still appends the assistant message and tool result automatically.
for message in runner:
runner.set_messages_params(lambda params: {**params, "max_tokens": 2048})Compacting long conversations
The tool runner supports two kinds of compaction. Choose how to compact compares them.
- Compaction at a token threshold: when the conversation reaches a token threshold that you can set, the API summarizes it. The runner automatically sends the summary back on its later requests, so the task can continue beyond the context window. Add an entry of type
compact_20260112Preview with links to the API reference available â press Tab until focus enters the card, Escape to return. to theeditsarray of the runnerâscontext_managementPreview with links to the API reference available â press Tab until focus enters the card, Escape to return. request parameter. Create the runner with thecompact-2026-01-12beta, because the runner doesnât add it. - Compaction on demand: you choose when to compact. Call the runnerâs
compact_before_next_turn()helper, and the runner compacts before its next turn. Compact in a loop shows the call and names the beta header that it needs.
A runner canât compact on demand while its context_management holds a compact_20260112 entry, so use one kind on a runner.
Debugging tool execution
When a tool throws an exception, the tool runner catches it and returns the error to Claude as a tool result with is_error: true. The tool result carries the exceptionâs message (in Python, its type and message), not the full stack trace.
What the SDK logs is language-specific. The Python SDK logs the full exception, including its stack trace, through the standard logging module whenever a tool raises an unhandled exception. The Python, TypeScript, and Java SDKs read the ANTHROPIC_LOG environment variable to turn on the SDKâs logging, which includes request and response detail:
# Log at info level
export ANTHROPIC_LOG=info
# Log at debug level for more verbose output
export ANTHROPIC_LOG=debugThe Go, Ruby, C#, and PHP SDKs donât read ANTHROPIC_LOG. Outside Python, no SDK logs a failed tool: to see why a tool failed, catch and log the exception inside the tool function before returning or rethrowing it.
Intercepting tool errors
By default, the tool runner passes tool errors back to Claude, which can then respond appropriately. However, you might want to detect errors and handle them differently, for example, to stop execution early or implement custom error handling.
client = anthropic.Anthropic()
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
tools=[my_tool],
messages=[{"role": "user", "content": "Run my_tool with the query 'hello'."}],
)
for message in runner:
tool_response = runner.generate_tool_call_response()
if tool_response is not None:
# tool_response is a dict: {"role": "user", "content": [...]}
# Check if any tool result has an error
for block in tool_response["content"]:
if block.get("is_error"):
# Option 1: Raise an exception to stop the loop
raise RuntimeError(f"Tool failed: {json.dumps(block['content'])}")
# Option 2: Log and continue (let Claude handle it)
# logger.error(f"Tool error: {json.dumps(block['content'])}")
# Process the message normally
print(message.content)Modifying tool results
You can modify tool results before theyâre sent back to Claude. This is useful for adding metadata such as cache_control to enable prompt caching on tool results, or for transforming the tool output.
client = anthropic.Anthropic()
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
tools=[search_documents],
messages=[
{
"role": "user",
"content": "Search for information about the climate of San Francisco",
}
],
)
for message in runner:
tool_response = runner.generate_tool_call_response()
if tool_response is not None:
# tool_response is a dict: {"role": "user", "content": [...]}
# Modify the tool result to add cache control
for block in tool_response["content"]:
if block["type"] == "tool_result":
# Add cache_control to cache this tool result
block["cache_control"] = {"type": "ephemeral"}
# Append the modified response (this prevents auto-append of the original)
runner.append_messages(message, tool_response)
print(message.content)Streaming
Enable streaming to process each turnâs response incrementally. Each iteration yields a stream object that you can iterate for events.
Set stream=True and use get_final_message() to get the accumulated message.
client = anthropic.Anthropic()
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
tools=[calculate_sum],
messages=[{"role": "user", "content": "What is 15 + 27?"}],
stream=True,
)
# When streaming, the runner returns BetaMessageStream
for message_stream in runner:
for event in message_stream:
print("event:", event)
print("message:", message_stream.get_final_message())
print(runner.until_done())Next steps
Enforce JSON Schema compliance on Claudeâs tool inputs with grammar-constrained sampling.
Parse tool_use blocks, format tool_result responses, and handle errors with is_error.
Enable, format, and disable parallel tool calls, with message-history guidance and troubleshooting.
Specify tool schemas, write effective descriptions, and control when Claude calls your tools.
Availability
Claude API
- Status
- Beta
- APIs
- Messages API
- Managed Agents
- Models
- All models
Amazon Bedrock
- Status
- Beta
- API
- Messages API
- Models
- All models
Amazon Bedrock (bedrock-runtime)
- Status
- Beta
- API
- Messages API
- Models
- All models
Claude Platform on AWS
- Status
- Beta
- APIs
- Messages API
- Managed Agents
- Models
- All models
Claude Models on Google Cloud (Anthropic managed)
- Status
- Beta
- API
- Messages API
- Models
- All models
Claude Models on Google Cloud (Google managed)
- Status
- Beta
- API
- Messages API
- Models
- All models
Microsoft Foundry
- Status
- Beta
- API
- Messages API
- Models
- All models
Was this page helpful?