Create a Tool Runner
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 runner | Manual loop (Lessons 86–90) | |
|---|---|---|
| Code needed | A few lines | A registry, a runner function, and a loop |
| Running tools and formatting results | Automatic | Your code |
| Exceptions in tools | Converted to is_error results automatically | Your code (run_tool()) |
| Iteration limit | max_iterations | Your step counter |
| Approval before actions, custom result formats | Limited (advanced hooks) | Full control |
| Status | Beta | Stable 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
- The tools are plain functions with
@beta_tool(Lesson 91). There is no registry, no manual schema, and norun_tool()function. client.beta.messages.tool_runner(...)creates the runner with the model, the tools, the system prompt, the first user message, andmax_iterations=6.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.- Usage is summed across all requests. Each message has its own
usage, and an agent run's real cost is the total (Lesson 88). lastkeeps the most recent message. If it still ends withtool_use, the iteration limit stopped the run before Claude finished, and the code says so instead of printing an incomplete answer.- 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_toolfunctions. - Set
max_iterationsfor 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=Truefor interactive interfaces. - Use the async runner in async applications.
- Switch to the manual loop for approvals and custom control.
Exercise
- Run the example with
max_iterations=1. What does the output show? - Use
until_done()instead of iterating, and print only the final text. - Add streaming with
stream=True, printing text as it arrives and tool calls when each turn ends. - Add a
get_weathertool and ask a question that needs all three tools. How many steps does the runner take?
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.