Tool Runner

no

Original 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
end

The 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=debug

When 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#

Link last verified June 7, 2026. View original ↗
Source: Anthropic Platform Docs
Link last verified: 2026-04-05