Create a Tool Runner

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 92 of 200

What the Tool Runner Does

Lesson 88 built the tool-use loop by hand: send a request, check stop_reason, run the tools, append the assistant message and the results, and repeat. The SDK's tool runner implements that loop for you. According to the documentation, it automatically:

  • runs tools when Claude calls them,
  • handles the request/response cycle,
  • manages the conversation state, and
  • provides type safety and validation.
runner = client.beta.messages.tool_runner(
    model="claude-opus-5-5",
    max_tokens=4096,
    tools=[get_weather, search_courses],          # Functions decorated with @beta_tool (Lesson 91)
    messages=[{"role": "user", "content": "Is it a nice day for a walk in Bengaluru?"}],
)
final_message = runner.until_done()

tool_runner() accepts the same main parameters as messages.create(): model, max_tokens, messages, system, and so on, plus tools containing decorated functions, and runner-specific options such as max_iterations.


Two Ways to Use a Runner

1. Get Only the Final Message

final_message = runner.until_done()
print("".join(b.text for b in final_message.content if b.type == "text"))

until_done() runs the loop to completion and returns Claude's final message. Use it when you only care about the answer.

2. Iterate Over Every Message

The runner is an iterable that yields each message from Claude. On each iteration, if Claude requested tools, the runner runs them and sends the results back automatically, then yields Claude's next message:

for message in runner:
    for block in message.content:
        if block.type == "tool_use":
            print(f"Claude called {block.name}({block.input})")
        elif block.type == "text":
            print(f"Claude: {block.text}")

Iterating lets you observe progress: show status messages, log tool calls, or track token usage. The loop ends when Claude returns a message without tool calls.

You can stop early at any iteration with break.


Limiting Iterations

Without a limit, the runner continues as long as Claude keeps calling tools. Set max_iterations to cap the number of requests, for the same reasons as the step limit in Lesson 88 (cost, and protection against loops):

runner = client.beta.messages.tool_runner(
    model="claude-opus-5-5",
    max_tokens=4096,
    max_iterations=8,
    tools=[...],
    messages=[...],
)

If the limit is reached while Claude is still calling tools, the last message still contains tool_use blocks, and there is no final answer. Check for this, as the practical example does.


Changing Parameters Mid-Run

set_messages_params() changes request parameters for the following requests, for example raising max_tokens, while the runner keeps managing the conversation:

for message in runner:
    if message.stop_reason == "max_tokens":
        runner.set_messages_params(lambda params: {**params, "max_tokens": 8192})

The function you pass receives the current parameters and returns the new ones. {**params, "max_tokens": 8192} copies them and replaces one value.

For advanced control, such as inspecting or replacing tool results before they are sent, the runner offers generate_tool_call_response() and append_messages() (Lessons 94 and 97). When you take over the message history that way, you become responsible for keeping the conversation valid.


Streaming with the Runner

Set stream=True to stream each turn. The runner then yields a stream per turn instead of a message, and each stream yields events (Lessons 53–55):

runner = client.beta.messages.tool_runner(
    model="claude-opus-5-5",
    max_tokens=4096,
    tools=[get_weather],
    messages=[{"role": "user", "content": "Is it raining in Chennai?"}],
    stream=True,
)

for message_stream in runner:
    for event in message_stream:
        if event.type == "text":
            print(event.text, end="", flush=True)
    message = message_stream.get_final_message()
print()

Async Runners

With AsyncAnthropic, define tools with @beta_async_tool (Lesson 91) and use the async runner with async for and await:

import asyncio

from anthropic import AsyncAnthropic


async def main() -> None:
    client = AsyncAnthropic()
    runner = client.beta.messages.tool_runner(
        model="claude-opus-5-5",
        max_tokens=4096,
        tools=[get_weather_async],                 # Defined with @beta_async_tool
        messages=[{"role": "user", "content": "Is it raining in Chennai?"}],
    )
    async for message in runner:
        print(message.stop_reason)


asyncio.run(main())

Runner vs. Manual Loop

Tool runnerManual loop (Lessons 86–90)
Code neededA few linesA registry, a runner function, and a loop
Running tools and formatting resultsAutomaticYour code
Exceptions in toolsConverted to is_error results automaticallyYour code (run_tool())
Iteration limitmax_iterationsYour step counter
Approval before actions, custom result formatsLimited (advanced hooks)Full control
StatusBetaStable API

The documentation recommends the manual loop when you need human-in-the-loop approval, custom logging, or conditional execution. For most other cases, the runner is simpler and less error-prone.


Practical Example: An Observable Tool Runner

This example runs a multi-step question through the tool runner, prints every step as it happens, sums token usage across all requests, and detects whether the run ended with a real answer or hit the iteration limit.

"""observable_runner.py: run tools with the SDK tool runner and report each step."""

import json

from anthropic import Anthropic, beta_tool

ENROLLMENTS = [{"course": "django-101", "progress": 65}, {"course": "sql-basics", "progress": 100}]
COURSES = [
    {"slug": "django-101", "title": "Django for Beginners", "level": "beginner", "weeks": 6},
    {"slug": "django-apis", "title": "Advanced Django APIs", "level": "advanced", "weeks": 4},
]


@beta_tool
def get_enrollments() -> str:
    """Return the current student's enrolled courses with progress percentages, as JSON.

    Use it for questions about the student's own courses or progress.
    """
    return json.dumps(ENROLLMENTS)


@beta_tool
def search_courses(topic: str) -> str:
    """Search the GOCOURSE catalog for courses on a topic.

    Use it when a student asks which courses exist or what to take next.
    Returns matching courses as JSON with slug, title, level, and duration in weeks.

    Args:
        topic: Subject to search for, e.g. 'Django'
    """
    matches = [c for c in COURSES if topic.lower() in c["slug"]]
    return json.dumps(matches) if matches else f"No courses found for {topic!r}."


def run(question: str, max_iterations: int = 6) -> None:
    client = Anthropic()
    runner = client.beta.messages.tool_runner(
        model="claude-opus-5-5",
        max_tokens=4096,
        max_iterations=max_iterations,
        system="You are the GOCOURSE study assistant. Use the tools; never guess what they can tell you.",
        tools=[get_enrollments, search_courses],
        messages=[{"role": "user", "content": question}],
    )

    input_tokens = output_tokens = 0
    last = None
    for step, message in enumerate(runner, start=1):
        input_tokens += message.usage.input_tokens
        output_tokens += message.usage.output_tokens
        calls = [f"{b.name}({b.input})" for b in message.content if b.type == "tool_use"]
        print(f"[step {step}] stop_reason={message.stop_reason}"
              + (f" | tools: {', '.join(calls)}" if calls else ""))
        last = message

    if last is None or last.stop_reason == "tool_use":
        print("\nThe run stopped before Claude finished (iteration limit reached).")
    else:
        print("\n" + "".join(b.text for b in last.content if b.type == "text"))
    print(f"\n[usage: {input_tokens} input, {output_tokens} output tokens]")


if __name__ == "__main__":
    run("Based on my progress, which Django course should I take next, and how long is it?")

Example output:

[step 1] stop_reason=tool_use | tools: get_enrollments({}), search_courses({'topic': 'Django'})
[step 2] stop_reason=end_turn

You're 65% through Django for Beginners, so finish it first. After that, take Advanced Django APIs, which takes 4 weeks.

[usage: 1873 input, 412 output tokens]

How It Works

  1. The tools are plain functions with @beta_tool (Lesson 91). There is no registry, no manual schema, and no run_tool() function.
  2. client.beta.messages.tool_runner(...) creates the runner with the model, the tools, the system prompt, the first user message, and max_iterations=6.
  3. for step, message in enumerate(runner, start=1) iterates over Claude's messages. After each message that contains tool calls, the runner runs the tools and sends the results back before yielding the next message. Your loop only observes.
  4. Usage is summed across all requests. Each message has its own usage, and an agent run's real cost is the total (Lesson 88).
  5. last keeps the most recent message. If it still ends with tool_use, the iteration limit stopped the run before Claude finished, and the code says so instead of printing an incomplete answer.
  6. In the sample run, Claude called both tools in parallel in step 1, and answered in step 2. Two requests in total.

Common Mistakes

No Iteration Limit

What happens: a run that keeps calling tools continues, and costs more, until it decides to stop.

Fix: set max_iterations.

Treating the Last Message as an Answer Without Checking

What happens: when the limit is reached, the "answer" is just a preamble before tool calls.

Fix: check that the last message's stop_reason is not tool_use.

Mixing Sync Tools with the Async Runner

What happens: blocking tools stall the event loop.

Fix: use @beta_async_tool with AsyncAnthropic.

Using the Runner Where You Need Approval Steps

What happens: actions run automatically, before a person can approve them.

Fix: use the manual loop for tools that need confirmation (Lesson 177).

Ignoring Total Usage

What happens: costs per question are underestimated.

Fix: sum usage across all messages from the runner.


Best Practices

  • Use the tool runner for standard tool loops with @beta_tool functions.
  • Set max_iterations for every runner.
  • Iterate when you need progress, logs, or usage; use until_done() otherwise.
  • Check the final stop reason before treating a message as the answer.
  • Stream with stream=True for interactive interfaces.
  • Use the async runner in async applications.
  • Switch to the manual loop for approvals and custom control.

Exercise

  1. Run the example with max_iterations=1. What does the output show?
  2. Use until_done() instead of iterating, and print only the final text.
  3. Add streaming with stream=True, printing text as it arrives and tool calls when each turn ends.
  4. Add a get_weather tool and ask a question that needs all three tools. How many steps does the runner take?
Show answers for steps 1 and 3

Step 1: only one request is made. Claude responds with tool calls (stop_reason=tool_use), and the iteration limit stops the run before the results can be sent back. The example prints "The run stopped before Claude finished (iteration limit reached)."

Step 3:

runner = client.beta.messages.tool_runner(
    model="claude-opus-5-5", max_tokens=4096, max_iterations=6,
    tools=[get_enrollments, search_courses],
    messages=[{"role": "user", "content": question}],
    stream=True,
)

for message_stream in runner:
    for event in message_stream:
        if event.type == "text":
            print(event.text, end="", flush=True)
    message = message_stream.get_final_message()
    for block in message.content:
        if block.type == "tool_use":
            print(f"\n[calling {block.name}({block.input})]")
print()

Each turn is a stream. Text appears live, and the complete message from get_final_message() shows which tools the turn called.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Create a Tool Runner and get answers drawn from it.

Signed-in readers only.