Tool Runner ↗
noOriginal Documentation
Use the SDK’s Tool Runner abstraction to handle the agentic loop, error wrapping, and type safety automatically.
Tool Runner handles the agentic loop, error wrapping, and type safety so you don’t have to. Use the manual loop only when you need human-in-the-loop approval, custom logging, or conditional execution. Available in Python, TypeScript, and Ruby SDKs.
The tool runner provides an out-of-the-box solution for executing tools with Claude. Instead of manually handling tool calls, tool results, and conversation management, the tool runner automatically:
- Executes tools when Claude calls them
- Handles the request/response cycle
- Manages conversation state
- Provides type safety and validation
Use the tool runner for most tool use implementations.
The tool runner is currently in beta and available in the Python, TypeScript, and Ruby SDKs.
Automatic context management with compaction
The tool runner supports automatic compaction, which generates summaries when token usage exceeds a threshold. This allows long-running agentic tasks to continue beyond context window limits.
Basic usage#
Define tools using the SDK helpers, then use the tool runner to execute them.
Use the @beta_tool decorator to define tools with type hints and docstrings.
If you’re using the async client, replace @beta_tool with @beta_async_tool and define the function with async def.
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-4-6",
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 extract a JSON schema representation. For example, calculate_sum becomes:
{
"name": "calculate_sum",
"description": "Add two numbers together.",
"input_schema": {
"additionalProperties": false,
"properties": {
"a": {
"description": "First number",
"title": "A",
"type": "integer"
},
"b": {
"description": "Second number",
"title": "B",
"type": "integer"
}
},
"required": ["a", "b"],
"type": "object"
}
}
Use betaZodTool() for type-safe tool definitions with Zod validation, or betaTool() for JSON Schema-based definitions.
TypeScript offers two approaches for defining tools:
Using Zod (recommended) - Use betaZodTool() for type-safe tool definitions with Zod validation (requires Zod 3.25.0 or higher):
const client = new Anthropic();
const getWeatherTool = betaZodTool({
name: "get_weather",
description: "Get the current weather in a given location",
inputSchema: z.object({
location: z.string().describe("The city and state, e.g. San Francisco, CA"),
unit: z.enum(["celsius", "fahrenheit"]).default("fahrenheit").describe("Temperature unit")
}),
run: async (input) => {
return JSON.stringify({ temperature: "20°C", condition: "Sunny" });
}
});
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [getWeatherTool],
messages: [{ role: "user", content: "What's the weather like in Paris?" }]
});
for (const block of finalMessage.content) {
if (block.type === "text") {
console.log(block.text);
}
}Using JSON Schema - Use betaTool() for type-safe tool definitions without Zod:
The input generated by Claude will not be validated at runtime. Perform validation inside the run function if needed.
const client = new Anthropic();
const calculateSumTool = betaTool({
name: "calculate_sum",
description: "Add two numbers together",
inputSchema: {
type: "object",
properties: {
a: { type: "number", description: "First number" },
b: { type: "number", description: "Second number" }
},
required: ["a", "b"]
},
run: async (input) => {
return String(input.a + input.b);
}
});
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [calculateSumTool],
messages: [{ role: "user", content: "What's 15 + 27?" }]
});
for (const block of finalMessage.content) {
if (block.type === "text") {
console.log(block.text);
}
}
Use the Anthropic::BaseTool class to define tools with typed input schemas.
require "anthropic"
# Initialize client
client = Anthropic::Client.new
# Define input schema
class GetWeatherInput < Anthropic::BaseModel
required :location, String, doc: "The city and state, e.g. San Francisco, CA"
optional :unit, Anthropic::InputSchema::EnumOf["celsius", "fahrenheit"],
doc: "Temperature unit"
end
# Define tool
class GetWeather < Anthropic::BaseTool
doc "Get the current weather in a given location"
input_schema GetWeatherInput
def call(input)
# In a full implementation, you'd call a weather API here
JSON.generate({temperature: "20°C", condition: "Sunny"})
end
end
class CalculateSumInput < Anthropic::BaseModel
required :a, Integer, doc: "First number"
required :b, Integer, doc: "Second number"
end
class CalculateSum < Anthropic::BaseTool
doc "Add two numbers together"
input_schema CalculateSumInput
def call(input)
(input.a + input.b).to_s
end
end
# Use the tool runner
runner = client.beta.messages.tool_runner(
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [GetWeather.new, CalculateSum.new],
messages: [
{role: "user", content: "What's the weather like in Paris? Also, what's 15 + 27?"}
]
)
runner.each_message do |message|
message.content.each do |block|
puts block.text if block.type == :text
end
endThe Anthropic::BaseTool class uses the doc method for the tool description and input_schema to define the expected parameters. The SDK automatically converts this to the appropriate JSON schema format.
The tool function must return a content block or content block array, including text, images, or document blocks. This allows tools to return rich, multimodal responses. Returned strings will be converted to a text content block. If you want to return a structured JSON object to Claude, encode it to a JSON string before returning it. Numbers, booleans, or other non-string primitives must also be converted to strings.
Iterating over the tool runner#
The tool runner is an iterable that yields messages from Claude. This is often referred to as a “tool call loop”. Each iteration, the runner checks if Claude requested a tool use. If so, it calls 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 will loop until Claude returns a message without a tool use.
If you don’t need intermediate messages, you can get the final message directly:
Use runner.until_done() to get the final message.
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def get_weather(location: str) -> str:
"""Get the current weather in a given location."""
return "20°C, Sunny"
@beta_tool
def calculate_sum(a: int, b: int) -> str:
"""Add two numbers together."""
return str(a + b)
runner = client.beta.messages.tool_runner(
model="claude-opus-4-6",
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()
print(final_message.content[0].text)
Simply await the runner to get the final message.
const client = new Anthropic();
const getWeatherTool = betaZodTool({
name: "get_weather",
description: "Get the current weather in a given location",
inputSchema: z.object({ location: z.string() }),
run: async () => JSON.stringify({ temperature: "20°C", condition: "Sunny" })
});
const runner = client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [getWeatherTool],
messages: [{ role: "user", content: "What's the weather like in Paris?" }]
});
const finalMessage = await runner;
console.log(finalMessage.content[0].text);
Use runner.run_until_finished to get all messages.
class GetWeatherInput < Anthropic::BaseModel
required :location, String
end
class GetWeather < Anthropic::BaseTool
doc "Get the current weather in a given location"
input_schema GetWeatherInput
def call(input)
"Weather in #{input.location}: 20°C, Sunny"
end
end
class CalculateSumInput < Anthropic::BaseModel
required :a, Integer
required :b, Integer
end
class CalculateSum < Anthropic::BaseTool
doc "Add two numbers together"
input_schema CalculateSumInput
def call(input)
(input.a + input.b).to_s
end
end
runner = client.beta.messages.tool_runner(
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [GetWeather.new, CalculateSum.new],
messages: [
{role: "user", content: "What's the weather like in Paris? Also, what's 15 + 27?"}
]
)
all_messages = runner.run_until_finished
all_messages.each { |msg| puts msg.content }
Advanced usage#
Within the loop, you can fully customize the tool runner’s next request to the Messages API. The runner automatically appends tool results to the message history, so you don’t need to manually manage them. You can optionally inspect the tool result for logging or debugging, and modify the request parameters before the next API call.
Use generate_tool_call_response() to optionally inspect the tool result (the runner appends it automatically). Use set_messages_params() and append_messages() to modify the request.
runner = client.beta.messages.tool_runner(
model="claude-opus-4-6",
max_tokens=1024,
tools=[get_weather],
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
)
for message in runner:
# Optional: inspect the tool response (automatically appended by the runner)
tool_response = runner.generate_tool_call_response()
if tool_response:
print(f"Tool result: {tool_response}")
# Customize the next request
runner.set_messages_params(
lambda params: {
**params,
"max_tokens": 2048, # Increase tokens for next request
}
)
# Or add additional messages
runner.append_messages(
{"role": "user", "content": "Please be concise in your response."}
)
Use generateToolResponse() to optionally inspect the tool result (the runner appends it automatically). Use setMessagesParams() and pushMessages() to modify the request.
const runner = client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [getWeatherTool],
messages: [{ role: "user", content: "What's the weather in San Francisco?" }]
});
for await (const message of runner) {
// Optional: inspect the tool result message (automatically appended by the runner)
const toolResultMessage = await runner.generateToolResponse();
if (toolResultMessage) {
console.log("Tool result:", toolResultMessage);
}
// Customize the next request
runner.setMessagesParams((params) => ({
...params,
max_tokens: 2048 // Increase tokens for next request
}));
// Or add additional messages
runner.pushMessages({ role: "user", content: "Please be concise in your response." });
}
Use next_message for step-by-step control. Use feed_messages to inject messages and params to access parameters.
class GetWeatherInput < Anthropic::BaseModel
required :location, String
end
class GetWeather < Anthropic::BaseTool
doc "Get the current weather in a given location"
input_schema GetWeatherInput
def call(input)
"Weather in #{input.location}: 20°C, Sunny"
end
end
runner = client.beta.messages.tool_runner(
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [GetWeather.new],
messages: [{role: "user", content: "What's the weather in San Francisco?"}]
)
# Manual step-by-step control
message = runner.next_message
puts message.content
# Inject follow-up messages
runner.feed_messages([
{role: "user", content: "Also check Boston"}
])
# Access current parameters
puts runner.params
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. By default, only the exception message is included, not the full stack trace.
To view full stack traces and debug information, set the ANTHROPIC_LOG environment variable:
# View info-level logs including tool errors
export ANTHROPIC_LOG=info
# View debug-level logs for more verbose output
export ANTHROPIC_LOG=debugWhen enabled, the SDK logs full exception details (using Python’s logging module, the console in TypeScript, or Ruby’s logger), including the complete stack trace when a tool fails.
Intercepting tool errors#
By default, tool errors are passed back to Claude, which can then respond appropriately. However, you may want to detect errors and handle them differently, for example, to stop execution early or implement custom error handling.
Use the tool response method to intercept tool results and check for errors before they’re sent to Claude:
import anthropic
import json
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def my_tool(query: str) -> str:
"""A sample tool."""
return f"Result for: {query}"
runner = client.beta.messages.tool_runner(
model="claude-opus-4-6",
max_tokens=1024,
tools=[my_tool],
messages=[{"role": "user", "content": "Run the tool"}],
)
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)
const client = new Anthropic();
const myTool = betaZodTool({
name: "my_tool",
description: "A sample tool",
inputSchema: z.object({ query: z.string() }),
run: async (input) => `Result for: ${input.query}`
});
const runner = client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [myTool],
messages: [{ role: "user", content: "Run the tool" }]
});
for await (const message of runner) {
const toolResultMessage = await runner.generateToolResponse();
if (toolResultMessage) {
// Check if any tool result has an error
for (const block of toolResultMessage.content) {
if (block.type === "tool_result" && block.is_error) {
// Option 1: Throw to stop the loop
throw new Error(`Tool failed: ${JSON.stringify(block.content)}`);
// Option 2: Log and continue (let Claude handle it)
// console.error(`Tool error: ${JSON.stringify(block.content)}`);
}
}
}
// Process the message normally
console.log(message.content);
}
class MyToolInput < Anthropic::BaseModel
required :query, String
end
class MyTool < Anthropic::BaseTool
doc "A sample tool"
input_schema MyToolInput
def call(input)
"Result for: #{input.query}"
end
end
runner = client.beta.messages.tool_runner(
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [MyTool.new],
messages: [{role: "user", content: "Run the tool"}]
)
runner.each_message do |message|
# Get the tool response to check for errors
# Note: The runner automatically handles tool execution and appends results
# This is just for error checking/logging purposes
tool_results = runner.params[:messages].last
if tool_results && tool_results[:role] == :user && tool_results[:content].is_a?(Array)
tool_results[:content].each do |block|
if block[:type] == :tool_result && block[:is_error]
# Option 1: Raise an exception to stop the loop
raise "Tool failed: #{block[:content]}"
# Option 2: Log and continue (let Claude handle it)
# logger.error("Tool error: #{block[:content]}")
end
end
end
puts message.content
end
Modifying tool results#
You can modify tool results before they’re sent back to Claude. This is useful for adding metadata like cache_control to enable prompt caching on tool results, or for transforming the tool output.
Use the tool response method to get the tool result, then modify it before the runner proceeds. Whether you explicitly append the modified result or mutate it in place depends on the SDK; see the code comments in each tab.
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def search_documents(query: str) -> str:
"""Search documents for relevant information."""
return f"Found 3 documents matching: {query}"
runner = client.beta.messages.tool_runner(
model="claude-opus-4-6",
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)
const client = new Anthropic();
const searchDocuments = betaZodTool({
name: "search_documents",
description: "Search documents for relevant information",
inputSchema: z.object({ query: z.string() }),
run: async (input) => `Found 3 documents matching: ${input.query}`
});
const runner = client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [searchDocuments],
messages: [
{ role: "user", content: "Search for information about the climate of San Francisco" }
]
});
for await (const message of runner) {
const toolResultMessage = await runner.generateToolResponse();
if (toolResultMessage && typeof toolResultMessage.content !== "string") {
// Modify the tool result to add cache control
for (const block of toolResultMessage.content) {
if (block.type === "tool_result") {
// Add cache_control to cache this tool result
block.cache_control = { type: "ephemeral" };
}
}
// No pushMessages call needed: the runner auto-appends both the assistant
// message and the (now-mutated) cached tool response.
}
console.log(message.content);
}
class SearchDocumentsInput < Anthropic::BaseModel
required :query, String
end
class SearchDocuments < Anthropic::BaseTool
doc "Search documents for relevant information"
input_schema SearchDocumentsInput
def call(input)
"Found 3 documents matching: #{input.query}"
end
end
runner = client.beta.messages.tool_runner(
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [SearchDocuments.new],
messages: [{role: "user", content: "Search for information about the climate of San Francisco"}]
)
loop do
message = runner.next_message
break unless message
# Access the most recent tool results from the messages array
# The runner automatically adds tool results, but we can modify them
tool_results_message = runner.params[:messages].last
if tool_results_message && tool_results_message[:role] == :user
tool_results_message[:content].each do |block|
if block[:type] == :tool_result
# Modify the tool result to add cache control
block[:cache_control] = {type: "ephemeral"}
end
end
end
puts message.content
break if message.stop_reason != :tool_use
end
Adding cache_control to tool results is particularly useful when tools return large amounts of data (like document search results) that you want to cache for subsequent API calls. See Prompt caching for more details on caching strategies.
Streaming#
Enable streaming to receive events as they arrive. 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.
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def calculate_sum(a: int, b: int) -> str:
"""Add two numbers together."""
return str(a + b)
runner = client.beta.messages.tool_runner(
model="claude-opus-4-6",
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())
Set stream: true and use finalMessage() to get the accumulated message.
const client = new Anthropic();
const getWeatherTool = betaZodTool({
name: "get_weather",
description: "Get the current weather in a given location",
inputSchema: z.object({ location: z.string() }),
run: async () => JSON.stringify({ temperature: "20°C", condition: "Sunny" })
});
const runner = client.beta.messages.toolRunner({
model: "claude-opus-4-6",
max_tokens: 1000,
messages: [{ role: "user", content: "What is the weather in San Francisco?" }],
tools: [getWeatherTool],
stream: true
});
// When streaming, the runner returns BetaMessageStream
for await (const messageStream of runner) {
for await (const event of messageStream) {
console.log("event:", event);
}
console.log("message:", await messageStream.finalMessage());
}
console.log(await runner);
Use each_streaming to iterate over streaming events.
class CalculateSumInput < Anthropic::BaseModel
required :a, Integer
required :b, Integer
end
class CalculateSum < Anthropic::BaseTool
doc "Add two numbers together"
input_schema CalculateSumInput
def call(input)
(input.a + input.b).to_s
end
end
runner = client.beta.messages.tool_runner(
model: "claude-opus-4-6",
max_tokens: 1024,
tools: [CalculateSum.new],
messages: [{role: "user", content: "What is 15 + 27?"}]
)
runner.each_streaming do |event|
case event
when Anthropic::Streaming::TextEvent
print event.text
when Anthropic::Streaming::InputJsonEvent
puts "\nTool input: #{event.partial_json}"
end
end
Next steps#
- For manual control over the tool-call loop, see Handle tool calls.
- For running multiple tools concurrently, see Parallel tool use.
- For the full tool-use workflow, see Define tools.