All guides
Claude

Log at info level

Checked 09/15/2026View original
On this page

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

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.

<Note>
  If you're using the async client, replace `@beta_tool` with `@beta_async_tool` and define the function with `async def`.
</Note>

```python
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",
    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.
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):

```typescript
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";

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-5",
  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:

<Note>
  The input generated by Claude is not validated at runtime. Perform validation inside the `run` function if needed.
</Note>

```typescript
import Anthropic from "@anthropic-ai/sdk";
import { betaTool } from "@anthropic-ai/sdk/helpers/beta/json-schema";

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-5",
  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);
  }
}
```
```csharp
using System.Text.Json;
using Anthropic;
using Anthropic.Helpers.Beta;
using Anthropic.Models.Beta.Messages;
using MessageCreateParams = Anthropic.Models.Beta.Messages.MessageCreateParams;
using InputSchema = Anthropic.Models.Beta.Messages.InputSchema;
using Role = Anthropic.Models.Beta.Messages.Role;
using Model = Anthropic.Models.Messages.Model;

var client = new AnthropicClient();

var getWeatherTool = new BetaRunnableTool
{
    Name = "get_weather",
    Definition = new BetaTool
    {
        Name = "get_weather",
        Description = "Get the current weather in a given location.",
        InputSchema = new InputSchema
        {
            Properties = new Dictionary<string, JsonElement>
            {
                ["location"] = JsonSerializer.SerializeToElement(
                    new { type = "string", description = "The city and state, e.g. San Francisco, CA" }
                ),
            },
            Required = ["location"],
        },
    },
    Run = (toolUse, _) =>
    {
        var location = toolUse.Input["location"].GetString();
        return Task.FromResult<BetaToolResultBlockParamContent>(
            $"Weather in {location}: 20°C, sunny"
        );
    },
};

var calculateSumTool = new BetaRunnableTool
{
    Name = "calculate_sum",
    Definition = new BetaTool
    {
        Name = "calculate_sum",
        Description = "Add two numbers together.",
        InputSchema = new InputSchema
        {
            Properties = new Dictionary<string, JsonElement>
            {
                ["a"] = JsonSerializer.SerializeToElement(new { type = "number" }),
                ["b"] = JsonSerializer.SerializeToElement(new { type = "number" }),
            },
            Required = ["a", "b"],
        },
    },
    Run = (toolUse, _) =>
    {
        var a = toolUse.Input["a"].GetDouble();
        var b = toolUse.Input["b"].GetDouble();
        return Task.FromResult<BetaToolResultBlockParamContent>($"{a + b}");
    },
};

var runner = client.Beta.Messages.ToolRunner(
    new MessageCreateParams
    {
        Model = Model.ClaudeOpus5,
        MaxTokens = 1024,
        Messages =
        [
            new()
            {
                Role = Role.User,
                Content = "What's the weather like in Paris? Also, what's 15 + 27?",
            },
        ],
    },
    [getWeatherTool, calculateSumTool]
);

await foreach (var message in runner)
{
    Console.WriteLine(message);
}
```
```go
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/toolrunner"
)

type GetWeatherInput struct {
	Location string `json:"location" jsonschema:"required,description=The city and state, e.g. San Francisco, CA"`
	Unit     string `json:"unit,omitempty" jsonschema:"enum=celsius,enum=fahrenheit,description=Temperature unit"`
}

type CalculateSumInput struct {
	A int `json:"a" jsonschema:"required,description=First number"`
	B int `json:"b" jsonschema:"required,description=Second number"`
}

func main() {
	client := anthropic.NewClient()
	ctx := context.Background()

	getWeather, err := toolrunner.NewBetaToolFromJSONSchema(
		"get_weather",
		"Get the current weather in a given location.",
		func(ctx context.Context, input GetWeatherInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
			return anthropic.BetaToolResultBlockParamContentUnion{
				OfText: &anthropic.BetaTextBlockParam{Text: "20°C, Sunny"},
			}, nil
		},
	)
	if err != nil {
		log.Fatal(err)
	}

	calculateSum, err := toolrunner.NewBetaToolFromJSONSchema(
		"calculate_sum",
		"Add two numbers together.",
		func(ctx context.Context, input CalculateSumInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
			return anthropic.BetaToolResultBlockParamContentUnion{
				OfText: &anthropic.BetaTextBlockParam{Text: fmt.Sprintf("%d", input.A+input.B)},
			}, nil
		},
	)
	if err != nil {
		log.Fatal(err)
	}

	runner := client.Beta.Messages.NewToolRunner(
		[]anthropic.BetaTool{getWeather, calculateSum},
		anthropic.BetaToolRunnerParams{
			BetaMessageNewParams: anthropic.BetaMessageNewParams{
				Model:     anthropic.ModelClaudeOpus5,
				MaxTokens: 1024,
				Messages: []anthropic.BetaMessageParam{
					anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(
						"What's the weather like in Paris? Also, what's 15 + 27?",
					)),
				},
			},
		},
	)

	for message, err := range runner.All(ctx) {
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(message)
	}
}
```

The `jsonschema:` struct tags generate the input schema. For example, `CalculateSumInput` becomes:

```json
{
  "name": "calculate_sum",
  "description": "Add two numbers together.",
  "input_schema": {
    "type": "object",
    "properties": {
      "a": { "type": "integer", "description": "First number" },
      "b": { "type": "integer", "description": "Second number" }
    },
    "required": ["a", "b"]
  }
}
```
```java
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.helpers.BetaToolRunner;
import com.anthropic.models.beta.messages.BetaMessage;
import com.anthropic.models.beta.messages.MessageCreateParams;
import com.anthropic.models.messages.Model;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.util.function.Supplier;

@JsonClassDescription("Get the current weather in a given location")
static class GetWeather implements Supplier<String> {
    @JsonPropertyDescription("The city and state, e.g. San Francisco, CA")
    public String location;

    @JsonPropertyDescription("Temperature unit, either 'celsius' or 'fahrenheit'")
    public String unit;

    @Override
    public String get() {
        return "{\"temperature\": \"20°C\", \"condition\": \"Sunny\"}";
    }
}

@JsonClassDescription("Add two numbers together")
static class CalculateSum implements Supplier<String> {
    @JsonPropertyDescription("First number")
    public double a;

    @JsonPropertyDescription("Second number")
    public double b;

    @Override
    public String get() {
        return String.valueOf(a + b);
    }
}

void main() {
    AnthropicClient client = AnthropicOkHttpClient.fromEnv();

    BetaToolRunner runner = client.beta()
            .messages()
            .toolRunner(MessageCreateParams.builder()
                    .model(Model.CLAUDE_OPUS_5)
                    .maxTokens(1024)
                    .addBeta("structured-outputs-2025-11-13")
                    .addUserMessage("What's the weather like in Paris? Also, what's 15 + 27?")
                    .addTool(GetWeather.class)
                    .addTool(CalculateSum.class)
                    .build());

    for (BetaMessage message : runner) {
        IO.println(message);
    }
}
```

The class name `CalculateSum` becomes the tool name `calculate_sum`, and the SDK generates a JSON schema from the annotated fields:

```json
{
  "name": "calculate_sum",
  "description": "Add two numbers together",
  "input_schema": {
    "type": "object",
    "properties": {
      "a": { "description": "First number", "type": "number" },
      "b": { "description": "Second number", "type": "number" }
    },
    "required": ["a", "b"],
    "additionalProperties": false
  },
  "strict": true
}
```
```php
<?php

use Anthropic\Client;
use Anthropic\Lib\Tools\BetaRunnableTool;
use Anthropic\Messages\Model;

$client = new Client();

$getWeather = new BetaRunnableTool(
    definition: [
        'name' => 'get_weather',
        'description' => 'Get the current weather in a given location.',
        'input_schema' => [
            'type' => 'object',
            'properties' => [
                'location' => [
                    'type' => 'string',
                    'description' => 'The city and state, e.g. San Francisco, CA',
                ],
                'unit' => [
                    'type' => 'string',
                    'enum' => ['celsius', 'fahrenheit'],
                ],
            ],
            'required' => ['location'],
        ],
    ],
    run: fn (array $input): string => json_encode([
        'temperature' => '20°C',
        'condition' => 'Sunny',
    ]),
);

$calculateSum = new BetaRunnableTool(
    definition: [
        'name' => 'calculate_sum',
        'description' => 'Add two numbers together.',
        'input_schema' => [
            'type' => 'object',
            'properties' => [
                'a' => ['type' => 'number', 'description' => 'First number'],
                'b' => ['type' => 'number', 'description' => 'Second number'],
            ],
            'required' => ['a', 'b'],
        ],
    ],
    run: fn (array $input): string => (string) ($input['a'] + $input['b']),
);

$runner = $client->beta->messages->toolRunner(
    maxTokens: 1024,
    messages: [
        ['role' => 'user', 'content' => "What's the weather like in Paris? Also, what's 15 + 27?"],
    ],
    model: Model::CLAUDE_OPUS_5,
    tools: [$getWeather, $calculateSum],
);

foreach ($runner as $message) {
    foreach ($message->content as $block) {
        if ($block->type === 'text') {
            echo $block->text, "\n";
        } elseif ($block->type === 'tool_use') {
            echo "[Tool call: {$block->name}]\n";
        }
    }
}
```
```ruby
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-5",
  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.

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:

```python
client = anthropic.Anthropic()
# ...
runner = client.beta.messages.tool_runner(
    model="claude-opus-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)
```
```typescript
const client = new Anthropic();
// ...
const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 1024,
  tools: [getWeatherTool],
  messages: [{ role: "user", content: "What's the weather like in Paris?" }]
});

const finalMessage = await runner;
for (const block of finalMessage.content) {
  if (block.type === "text") {
    console.log(block.text);
  }
}
```
```csharp
var client = new AnthropicClient();
// ...
var runner = client.Beta.Messages.ToolRunner(
    new MessageCreateParams
    {
        Model = Model.ClaudeOpus5,
        MaxTokens = 1024,
        Messages =
        [
            new()
            {
                Role = Role.User,
                Content = "What's the weather like in Paris?",
            },
        ],
    },
    [getWeatherTool]
);

var finalMessage = await runner.RunUntilDoneAsync();
foreach (var block in finalMessage.Content)
{
    if (block.TryPickText(out var textBlock))
    {
        Console.WriteLine(textBlock.Text);
    }
}
```
```go
client := anthropic.NewClient()
ctx := context.Background()
// ...
runner := client.Beta.Messages.NewToolRunner(
	[]anthropic.BetaTool{getWeather},
	anthropic.BetaToolRunnerParams{
		BetaMessageNewParams: anthropic.BetaMessageNewParams{
			Model:     anthropic.ModelClaudeOpus5,
			MaxTokens: 1024,
			Messages: []anthropic.BetaMessageParam{
				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(
					"What's the weather like in Paris?",
				)),
			},
		},
	},
)

finalMessage, err := runner.RunToCompletion(ctx)
if err != nil {
	log.Fatal(err)
}
for _, block := range finalMessage.Content {
	if textBlock, ok := block.AsAny().(anthropic.BetaTextBlock); ok {
		fmt.Println(textBlock.Text)
	}
}
```
```java
AnthropicClient client = AnthropicOkHttpClient.fromEnv();

BetaToolRunner runner = client.beta()
        .messages()
        .toolRunner(MessageCreateParams.builder()
                .model(Model.CLAUDE_OPUS_5)
                .maxTokens(1024)
                .addBeta("structured-outputs-2025-11-13")
                .addUserMessage("What's the weather like in Paris? Also, what's 15 + 27?")
                .addTool(GetWeather.class)
                .addTool(CalculateSum.class)
                .build());

BetaMessage finalMessage = null;
for (BetaMessage message : runner) {
    finalMessage = message;
}
for (BetaContentBlock block : finalMessage.content()) {
    block.text().ifPresent(textBlock -> IO.println(textBlock.text()));
}
```
```php
$client = new Client();
// ...
$runner = $client->beta->messages->toolRunner(
    maxTokens: 1024,
    messages: [
        ['role' => 'user', 'content' => "What's the weather like in Paris? Also, what's 15 + 27?"],
    ],
    model: Model::CLAUDE_OPUS_5,
    tools: [$getWeather, $calculateSum],
);

$finalMessage = $runner->runUntilDone();
foreach ($finalMessage->content as $block) {
    if ($block->type === 'text') {
        echo $block->text, "\n";
    }
}
```
```ruby
client = Anthropic::Client.new
# ...
runner = client.beta.messages.tool_runner(
  model: "claude-opus-5",
  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 read each response message and modify the runner's state before the next API call. Each iteration follows this lifecycle:

  1. The runner sends a request to the Messages API with its current state.

  2. The runner yields the response message to your loop body.

  3. Your loop body runs. You can read the message and optionally modify the runner's state.

  4. 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.
sequenceDiagram
  participant U as Your code
  participant TR as ToolRunner
  participant API as Messages API

  loop For each iteration
    TR->>API: Send request with current state
    API-->>TR: Response message
    TR-->>U: Yield message
    note over U: Your loop body runs
    U->>TR: Resume
    alt Message history unchanged
      TR->>TR: If tool calls, append assistant<br/>message + tool results and continue.<br/>If none, exit the loop
    else Message history changed
      TR->>TR: Use your state unchanged
    end
  end

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 conditionally so the loop can still exit when there are no tool calls, and pass max_iterations to bound the loop. All seven SDKs support max_iterations.

```python
runner = client.beta.messages.tool_runner(
    model="claude-opus-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.

```python
for message in runner:
    runner.set_messages_params(lambda params: {**params, "max_tokens": 2048})
```
The following example retries a truncated response with a larger `max_tokens` budget.

```typescript
const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 1024,
  max_iterations: 10,
  tools: [getWeatherTool],
  messages: [
    {
      role: "user",
      content: "Give me a detailed weather report for every major US city."
    }
  ]
});

const MAX_TOKEN_CEILING = 8192;

for await (const message of runner) {
  if (message.stop_reason === "max_tokens") {
    const current = runner.params.max_tokens;
    if (current >= MAX_TOKEN_CEILING) {
      console.warn(`Hit ceiling (${MAX_TOKEN_CEILING}); stopping.`);
      break;
    }
    const doubled = Math.min(current * 2, MAX_TOKEN_CEILING);
    console.log(`Response truncated at ${current} tokens; retrying with ${doubled}.`);
    // Bump the budget. setMessagesParams() flags state as modified, so the
    // runner does NOT append the truncated message. The next iteration retries
    // the same turn with the larger budget.
    runner.setMessagesParams((params) => ({ ...params, max_tokens: doubled }));
  }
  // Otherwise leave state untouched so the runner auto-appends and continues.
}
```
```csharp
var runner = client.Beta.Messages.ToolRunner(
    new MessageCreateParams
    {
        Model = Model.ClaudeOpus5,
        MaxTokens = 1024,
        Messages = [new() { Role = Role.User, Content = "What's the weather in San Francisco?" }],
    },
    [getWeatherTool],
    maxIterations: 10
);

await foreach (var message in runner)
{
    var toolUseBlock = message
        .Content.Select(block => block.TryPickToolUse(out var toolUse) ? toolUse : null)
        .FirstOrDefault(toolUse => toolUse is not null);

    if (toolUseBlock is null)
    {
        // No tool call: leave state untouched so the loop exits normally.
        continue;
    }

    // Run the tool yourself and build the result block.
    var toolResult = new BetaToolResultBlockParam(toolUseBlock.ID)
    {
        Content = await getWeatherTool.ExecuteAsync(toolUseBlock, default),
    };

    // PushMessages() flags state as modified; the runner skips its auto-append.
    // Supply the assistant turn and the tool result yourself, then add a follow-up.
    runner.PushMessages(
        new()
        {
            Role = Role.Assistant,
            Content = new BetaMessageParamContent(
                JsonSerializer.SerializeToElement(
                    message.Content.Select(block => block.Json).ToArray()
                )
            ),
        },
        new()
        {
            Role = Role.User,
            Content = new List<BetaContentBlockParam> { toolResult },
        },
        new() { Role = Role.User, Content = "Please be concise in your response." }
    );
}
```
```go
runner := client.Beta.Messages.NewToolRunner(
	[]anthropic.BetaTool{getWeather},
	anthropic.BetaToolRunnerParams{
		BetaMessageNewParams: anthropic.BetaMessageNewParams{
			Model:     anthropic.ModelClaudeOpus5,
			MaxTokens: 1024,
			Messages: []anthropic.BetaMessageParam{
				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(
					"What's the weather in San Francisco?",
				)),
			},
		},
		MaxIterations: 10,
	},
)

for {
	message, err := runner.NextMessage(ctx)
	if err != nil {
		log.Fatal(err)
	}
	if message == nil {
		break // conversation complete
	}

	// The Go runner always appends the assistant message and tool results.
	// Param changes here apply to the next iteration.
	runner.Params.MaxTokens = 2048
}
```
The following example retries a turn that hit the token limit by doubling `max_tokens`. Mutating only on the `max_tokens` branch keeps the loop converging: turns that complete normally fall through, and the runner auto-appends and exits when there are no more tool calls.

```java
BetaToolRunner runner = client.beta()
        .messages()
        .toolRunner(ToolRunnerCreateParams.builder()
                .initialMessageParams(MessageCreateParams.builder()
                        .model(Model.CLAUDE_OPUS_5)
                        .maxTokens(1024)
                        .addBeta("structured-outputs-2025-11-13")
                        .addUserMessage("Give me a detailed weather report for every major US city.")
                        .addTool(GetWeather.class)
                        .build())
                .maxIterations(10L)
                .build());

long ceiling = 8192;

for (BetaMessage message : runner) {
    if (BetaStopReason.MAX_TOKENS.equals(message.stopReason().orElse(null))) {
        long current = runner.params().maxTokens();
        if (current >= ceiling) {
            IO.println("Hit ceiling (" + ceiling + "), accepting truncated response.");
            break;
        }
        long doubled = Math.min(current * 2, ceiling);
        IO.println("Response truncated at " + current + " tokens, retrying with " + doubled + ".");

        // Calling setNextParams() flags this turn as user-managed: the runner
        // does NOT auto-append the truncated message, so the next iteration
        // re-sends the same conversation prefix with the larger budget.
        runner.setNextParams(runner.params().toBuilder().maxTokens(doubled).build());
    }
    // No mutation on a normal turn: the runner auto-appends and continues.
}
```
The following example doubles `max_tokens` and retries when a response is cut off.

```php
use Anthropic\Beta\Messages\BetaStopReason;

$runner = $client->beta->messages->toolRunner(
    maxTokens: 1024,
    messages: [
        ['role' => 'user', 'content' => 'Give a detailed weather report for every major US city.'],
    ],
    model: Model::CLAUDE_OPUS_5,
    tools: [$getWeather],
    maxIterations: 10,
);

$maxTokenCeiling = 8192;

foreach ($runner as $message) {
    if ($message->stopReason === BetaStopReason::MAX_TOKENS->value) {
        $current = $runner->getParams()['maxTokens'];

        if ($current >= $maxTokenCeiling) {
            echo "Hit ceiling ({$maxTokenCeiling}), accepting truncated response.\n";
            break;
        }

        $doubled = min($current * 2, $maxTokenCeiling);
        echo "Response truncated at {$current} tokens, retrying with {$doubled}.\n";

        // Calling setMessagesParams() inside the loop tells the runner to skip
        // its automatic append. The truncated message is discarded; the next
        // iteration retries with the larger budget.
        // Keys are camelCase, matching the toolRunner() named parameters.
        $runner->setMessagesParams(['maxTokens' => $doubled]);
    }
}
```
You take over message history when, from inside an `each_message` or `each_streaming` block, you reassign `runner.params[:messages]` or call `feed_messages`. The following pattern calls `feed_messages` between `next_message` calls, which does not take over.

```ruby
runner = client.beta.messages.tool_runner(
  model: "claude-opus-5",
  max_tokens: 1024,
  max_iterations: 10,
  tools: [GetWeather.new],
  messages: [{role: "user", content: "What's the weather in San Francisco?"}]
)

# Step the runner once. The assistant message and tool result are appended
# to runner.params[:messages] before next_message returns.
message = runner.next_message
puts message.content

# Inject a follow-up before continuing. feed_messages takes a splat, not an array.
runner.feed_messages({role: "user", content: "Also check Boston."})

# Change parameters in place. Reassigning runner.params[:messages] takes over
# message history only when it happens inside an each_message or each_streaming block.
runner.params.update(max_tokens: 2048)

runner.run_until_finished
```

Automatic context management

For long-running agentic tasks, the TypeScript and Ruby tool runners support automatic compaction, which generates summaries when token usage exceeds a threshold so the conversation can continue beyond context window limits. Both SDKs have deprecated this client-side option in favor of server-side compaction, which works with every SDK's tool runner through the context_management request parameter. The Python SDK (v1.0 and later) and the Go, Java, C#, and PHP tool runners don't include client-side compaction.

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

The 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, tool errors are passed 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.

In the Python and TypeScript SDKs, use the tool response method (generate_tool_call_response() in Python, generateToolResponse() in TypeScript) to intercept tool results and check for errors before they're sent to Claude. The other SDKs don't expose that hook. Their tabs describe the closest alternative:

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)
```
for await (const message of runner) {
  const toolResultMessage = await runner.generateToolResponse();

  if (toolResultMessage && typeof toolResultMessage.content !== "string") {
    // 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);
}
```
```csharp
var client = new AnthropicClient();

var getWeatherTool = new BetaRunnableTool
{
    Name = "get_weather",
    Definition = new BetaTool
    {
        Name = "get_weather",
        Description = "Get the current weather in a given location.",
        InputSchema = new InputSchema
        {
            Properties = new Dictionary<string, JsonElement>
            {
                ["location"] = JsonSerializer.SerializeToElement(new { type = "string" }),
            },
            Required = ["location"],
        },
    },
    Run = async (toolUse, cancellationToken) =>
    {
        try
        {
            return await CallExternalWeatherService(
                toolUse.Input["location"].GetString()!,
                cancellationToken
            );
        }
        catch (HttpRequestException ex)
        {
            // Log here if you need to inspect the failure before Claude sees it.
            throw new BetaToolError($"Weather service unavailable: {ex.Message}");
        }
    },
};

var runner = client.Beta.Messages.ToolRunner(
    new MessageCreateParams
    {
        Model = Model.ClaudeOpus5,
        MaxTokens = 1024,
        Messages =
        [
            new() { Role = Role.User, Content = "What's the weather in San Francisco?" },
        ],
    },
    [getWeatherTool]
);

Console.WriteLine(await runner.RunUntilDoneAsync());
```
loop do
  message = runner.next_message
  break unless message

  # By the time next_message returns, the runner has run this turn's tools and
  # appended their results as the last (user-role) message. Inspect them here,
  # before the next request sends them to Claude.
  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
  break if message.stop_reason != :tool_use
end
```

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.

In the Python and TypeScript SDKs, 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.

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)
```
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);
}
```
```go
client := anthropic.NewClient()
ctx := context.Background()

searchDocuments, err := toolrunner.NewBetaToolFromJSONSchema(
	"search_documents",
	"Search documents for relevant information.",
	func(ctx context.Context, input SearchDocumentsInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
		return anthropic.BetaToolResultBlockParamContentUnion{
			OfText: &anthropic.BetaTextBlockParam{
				Text: fmt.Sprintf("Found 3 documents matching: %s", input.Query),
				// Set cache_control on the inner content block. The outer
				// tool_result block's cache_control is not currently
				// settable through the Go runner.
				CacheControl: anthropic.NewBetaCacheControlEphemeralParam(),
			},
		}, nil
	},
)
if err != nil {
	log.Fatal(err)
}

runner := client.Beta.Messages.NewToolRunner(
	[]anthropic.BetaTool{searchDocuments},
	anthropic.BetaToolRunnerParams{
		BetaMessageNewParams: anthropic.BetaMessageNewParams{
			Model:     anthropic.ModelClaudeOpus5,
			MaxTokens: 1024,
			Messages: []anthropic.BetaMessageParam{
				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(
					"Search for information about the climate of San Francisco",
				)),
			},
		},
	},
)

finalMessage, err := runner.RunToCompletion(ctx)
if err != nil {
	log.Fatal(err)
}
fmt.Println(finalMessage)
```
```java
@JsonClassDescription("Look up reference documentation for a topic")
static class SearchDocuments implements Supplier<BetaToolResultBlockParam.Content> {
    @JsonPropertyDescription("The search query")
    public String query;

    @Override
    public BetaToolResultBlockParam.Content get() {
        String largeResult = "..."; // a long document worth caching
        return BetaToolResultBlockParam.Content.ofBlocks(List.of(
                BetaToolResultBlockParam.Content.Block.ofText(
                        BetaTextBlockParam.builder()
                                .text(largeResult)
                                .cacheControl(BetaCacheControlEphemeral.builder().build())
                                .build())));
    }
}
```
```php
$client = new Client();
// ...
$runner = $client->beta->messages->toolRunner(
    maxTokens: 1024,
    messages: [
        ['role' => 'user', 'content' => 'Search for information about the climate of San Francisco.'],
    ],
    model: Model::CLAUDE_OPUS_5,
    tools: [$searchDocuments],
);

foreach ($runner as $message) {
    $toolResults = [];
    foreach ($message->content as $block) {
        if ($block instanceof BetaToolUseBlock) {
            $toolResults[] = [
                'type' => 'tool_result',
                'tool_use_id' => $block->id,
                'content' => $searchDocuments->run($block->input),
                // Add cache_control to cache this tool result
                'cache_control' => ['type' => 'ephemeral'],
            ];
        }
    }

    if ($toolResults !== []) {
        // pushMessages() flags state as mutated, so the runner skips its
        // automatic append. Push the assistant message and tool results.
        $runner->pushMessages(
            ['role' => 'assistant', 'content' => $message->content],
            ['role' => 'user', 'content' => $toolResults],
        );
    }
    // No tool call: leave state untouched so the loop exits.
}
```
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 you can modify them
  tool_results_message = runner.params[:messages].last

  if tool_results_message && tool_results_message[:role] == :user && tool_results_message[:content].is_a?(Array)
    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
```

Streaming

Enable streaming to process each turn's response incrementally. Each iteration yields a stream object that you can iterate for events.

```python
client = anthropic.Anthropic()
# ...
runner = client.beta.messages.tool_runner(
    model="claude-opus-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())
```
```typescript
const client = new Anthropic();
// ...
const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 1024,
  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);
```
```csharp
var client = new AnthropicClient();
// ...
var runner = client.Beta.Messages.ToolRunner(
    new MessageCreateParams
    {
        Model = Model.ClaudeOpus5,
        MaxTokens = 1024,
        Messages =
        [
            new() { Role = Role.User, Content = "What is 15 + 27?" },
        ],
    },
    [calculateSumTool]
);

await foreach (var stream in runner.Streaming())
{
    await foreach (var streamEvent in stream)
    {
        if (
            streamEvent.TryPickContentBlockDelta(out var deltaEvent)
            && deltaEvent.Delta.TryPickText(out var textDelta)
        )
        {
            Console.Write(textDelta.Text);
        }
    }
    Console.WriteLine();
}
```
```go
client := anthropic.NewClient()
ctx := context.Background()
// ...
runner := client.Beta.Messages.NewToolRunnerStreaming(
	[]anthropic.BetaTool{calculateSum},
	anthropic.BetaToolRunnerParams{
		BetaMessageNewParams: anthropic.BetaMessageNewParams{
			Model:     anthropic.ModelClaudeOpus5,
			MaxTokens: 1024,
			Messages: []anthropic.BetaMessageParam{
				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("What is 15 + 27?")),
			},
		},
	},
)

for events, err := range runner.AllStreaming(ctx) {
	if err != nil {
		log.Fatal(err)
	}
	for event, err := range events {
		if err != nil {
			log.Fatal(err)
		}
		switch eventVariant := event.AsAny().(type) {
		case anthropic.BetaRawContentBlockDeltaEvent:
			switch deltaVariant := eventVariant.Delta.AsAny().(type) {
			case anthropic.BetaTextDelta:
				fmt.Print(deltaVariant.Text)
			case anthropic.BetaInputJSONDelta:
				fmt.Print(deltaVariant.PartialJSON)
			}
		case anthropic.BetaRawMessageStopEvent:
			fmt.Println()
		}
	}
}
```
```java
void main() {
    AnthropicClient client = AnthropicOkHttpClient.fromEnv();

    BetaToolRunner runner = client.beta()
            .messages()
            .toolRunner(MessageCreateParams.builder()
                    .model(Model.CLAUDE_OPUS_5)
                    .maxTokens(1024)
                    .addBeta("structured-outputs-2025-11-13")
                    .addUserMessage("What is 15 + 27?")
                    .addTool(CalculateSum.class)
                    .build());

    for (StreamResponse<BetaRawMessageStreamEvent> stream : runner.streaming()) {
        try (stream) {
            stream.stream().forEach(event -> IO.println("event: " + event));
        }
    }
}
```
```ruby
client = Anthropic::Client.new
# ...
runner = client.beta.messages.tool_runner(
  model: "claude-opus-5",
  max_tokens: 1024,
  tools: [CalculateSum.new],
  messages: [{role: "user", content: "What is 15 + 27?"}]
)

runner.each_streaming do |stream|
  stream.each do |event|
    case event
    when Anthropic::Streaming::TextEvent
      print event.text
    when Anthropic::Streaming::InputJsonEvent
      print event.partial_json
    end
  end
  puts
end
```

Next steps