Limit Tool Calls
Why Limit Tool Calls?
A tool runner keeps going as long as Claude keeps calling tools (Lesson 92). Usually Claude stops after a few calls. But without limits, a run can grow unexpectedly:
| Situation | What happens without limits |
|---|---|
| A tool keeps failing | Claude retries again and again |
| A broad request ("check every course") | Dozens of calls, one per item |
| An expensive tool (a paid API, a slow database query) | Costs grow with every call |
| A prompt injection in a tool result (Lesson 40) | The model is pushed to call tools far more than needed |
| A long-running agent | Context, latency, and cost keep growing |
Limits turn these risks into predictable, bounded behavior. This lesson combines several kinds of limits, from simple to fine-grained.
Kinds of Limits
| Limit | Controls | Mechanism |
|---|---|---|
| Iterations | Number of requests to Claude in one run | max_iterations on the runner (Lesson 92) |
| Total tool calls | All tool executions in one run | A call budget checked inside each tool |
| Per-tool calls | Calls to one specific (often expensive) tool | A per-tool limit in the same budget |
| Parallel calls | Tools requested in a single response | tool_choice={"type": "auto", "disable_parallel_tool_use": True} (Lesson 89) |
| Tokens or cost | Total usage of the run | Sum usage while iterating; stop when over budget |
| Time | Wall-clock duration of the run | Check elapsed time while iterating |
max_iterations limits requests, not tool calls. One response can contain several parallel calls, so ten iterations could mean thirty tool executions. That is why a separate call budget is useful.
Telling Claude About Limits
When a limit is reached inside a tool, raise an exception with a clear message. The runner sends it to Claude as an error result (Lesson 91), and Claude can adapt, typically by answering with the information it already has:
Tool call budget used up (6 calls). Do not call more tools; answer with the information you already have.
This is better than silently returning empty data, which Claude might misread as "no results".
You can also mention limits in the system prompt, so Claude plans efficiently from the start:
You can make at most 6 tool calls per question. Prefer fewer, broader calls.
Wrapping Tools with a Budget
A budget check can be added to any tool with a small decorator that runs before the tool's own code. The decorator uses functools.wraps, which copies the function's name, docstring, and type hints onto the wrapper, so @beta_tool still generates the correct definition:
@beta_tool # Outermost: builds the tool from the wrapped function
@budget.limited("get_course_details") # Inner: checks the budget, then calls the function
def get_course_details(slug: str) -> str:
"""..."""
The order matters: @beta_tool must be the outermost decorator, because it must see the final function, including the budget check.
Practical Example: A Budgeted Tool Run
This example limits a run in four ways: iterations, total tool calls, calls per tool, and total output tokens. It uses a deliberately small budget to show what happens when limits are reached.
"""budgeted_tools.py: limit tool calls per run with a call budget and usage checks."""
import functools
import json
from collections import Counter
from anthropic import Anthropic, beta_tool
COURSES = {
"django-101": {"title": "Django for Beginners", "weeks": 6},
"django-apis": {"title": "Advanced Django APIs", "weeks": 4},
"sql-basics": {"title": "SQL Basics", "weeks": 3},
"python-101": {"title": "Python Foundations", "weeks": 5},
}
class CallBudget:
"""Counts tool calls in one run and enforces total and per-tool limits."""
def __init__(self, total: int, per_tool: dict[str, int] | None = None):
self.total = total
self.per_tool = per_tool or {}
self.calls: Counter[str] = Counter()
def charge(self, name: str) -> None:
if sum(self.calls.values()) >= self.total:
raise RuntimeError(f"Tool call budget used up ({self.total} calls). Do not call more tools; "
"answer with the information you already have.")
limit = self.per_tool.get(name)
if limit is not None and self.calls[name] >= limit:
raise RuntimeError(f"{name} may be called at most {limit} times per question. "
"Do not call it again; answer with the information you already have.")
self.calls[name] += 1
def limited(self, name: str):
def decorator(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
self.charge(name)
return function(*args, **kwargs)
return wrapper
return decorator
def make_tools(budget: CallBudget) -> list:
@beta_tool
@budget.limited("list_courses")
def list_courses() -> str:
"""List the slugs of all courses in the GOCOURSE catalog, as JSON.
Use it to discover which courses exist before asking for details.
"""
return json.dumps(sorted(COURSES))
@beta_tool
@budget.limited("get_course_details")
def get_course_details(slug: str) -> str:
"""Get the title and duration in weeks of one course, as JSON.
Use it when you need details for a specific course. Get valid slugs from list_courses.
Args:
slug: Course slug, e.g. 'django-101'
"""
if slug not in COURSES:
raise ValueError(f"No course with slug {slug!r}. Use list_courses to get valid slugs.")
return json.dumps(COURSES[slug])
return [list_courses, get_course_details]
def run(question: str, max_output_tokens: int = 4000) -> None:
budget = CallBudget(total=4, per_tool={"get_course_details": 2})
client = Anthropic()
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=4096,
max_iterations=6,
system="You are the GOCOURSE catalog assistant. You can make at most 4 tool calls per question.",
tools=make_tools(budget),
messages=[{"role": "user", "content": question}],
)
output_tokens = 0
last = None
for message in runner:
output_tokens += message.usage.output_tokens
last = message
if output_tokens > max_output_tokens:
print(f"[stopped: output token budget of {max_output_tokens} exceeded]")
break
print(f"[tool calls: {dict(budget.calls)} | output tokens: {output_tokens}]")
if last is not None and last.stop_reason != "tool_use":
print("".join(b.text for b in last.content if b.type == "text"))
else:
print("[The run ended before a final answer.]")
if __name__ == "__main__":
run("Give me the duration of every course in the catalog.")
Example output (Claude's exact steps and wording vary):
[tool calls: {'list_courses': 1, 'get_course_details': 2} | output tokens: 731]
Here's what I could check: Advanced Django APIs takes 4 weeks and Django for Beginners takes 6 weeks. I reached the limit for course detail lookups, so I couldn't get the durations of Python Foundations and SQL Basics in this answer. Ask me about them directly and I'll look them up.
How It Works
CallBudgetkeeps aCounterof calls per tool.charge()checks the total limit first, then the tool's own limit, and raises aRuntimeErrorwith a message written for Claude when a limit is reached. Only calls that pass the checks are counted.limited(name)returns a decorator. The wrapper charges the budget, then calls the real function.functools.wrapspreserves the name, docstring, and type hints, so@beta_toolgenerates the same definition as without the budget.make_tools(budget)creates tools bound to one budget, the factory pattern from Lesson 95. Each run gets a fresh budget, so limits never leak between questions or users.- Four limits work together:
max_iterations=6caps requests;total=4caps tool executions;per_tool={"get_course_details": 2}caps the expensive lookup;- the token check stops the run if total output tokens exceed the budget.
- The system prompt mentions the limit, so Claude can plan, for example by calling
list_coursesonce instead of guessing slugs. - In the sample run, the per-tool limit stopped the third detail lookup. Claude received the error and answered honestly with what it had, naming the courses it could not check.
- The final check prints the answer only if the last message is complete, and reports clearly if the run ended early (Lesson 92).
Choosing Limit Values
| Limit | Starting point | Adjust when |
|---|---|---|
max_iterations | 5–10 for assistants, more for complex agents | Legitimate tasks are cut off, or runs are too long |
| Total tool calls | 2–3× the calls a typical question needs | Logs show answers missing information |
| Per-tool limits | For expensive or rate-limited tools only | External costs or quotas are at risk |
| Token budget | Based on cost per question you accept (Lesson 45) | Costs exceed targets |
Measure with logging (Lesson 97), then tune. Limits that are too tight produce incomplete answers; limits that are too loose leave costs unbounded.
Common Mistakes
Relying on max_iterations Alone
What happens: parallel calls multiply the number of tool executions within the iteration limit.
Fix: add a call budget that counts executions.
Silent Limits
What happens: a tool that is out of budget returns an empty result, and Claude concludes there is no data.
Fix: raise an error that clearly says a limit was reached.
Budgets Shared Across Runs
What happens: one user's heavy question uses up the budget for everyone.
Fix: create a new budget, and tools bound to it, for every run.
Wrong Decorator Order
What happens: the budget check is not part of the tool, or the definition loses its docstring and types.
Fix: put @beta_tool outermost, and use functools.wraps in wrapping decorators.
Not Reporting Incomplete Runs
What happens: users receive partial answers as if they were complete.
Fix: check the final message, and tell users when limits cut a run short.
Best Practices
- Combine limits: iterations, tool calls, per-tool calls, tokens, and time.
- Explain limits to Claude in error messages and the system prompt.
- Create fresh budgets per run with a factory.
- Limit expensive tools individually.
- Report incomplete results honestly.
- Tune limits from measurements.
Exercise
- Change the budget to
total=10with no per-tool limits, and run the same question. How many calls does Claude make now? - Add a time limit: stop the loop if the run takes longer than 20 seconds.
- Run with
tool_choice={"type": "auto", "disable_parallel_tool_use": True}. How do the number of iterations and tool calls change? - Explain why
charge()checks the limits before incrementing the counter.
Step 2:
import time
start = time.monotonic()
for message in runner:
...
if time.monotonic() - start > 20:
print("[stopped: time limit of 20 seconds reached]")
break
time.monotonic() is the right clock for measuring durations (Lesson 78). The check runs after each message, so a single slow request can still exceed the limit; for a hard deadline, use the async runner with asyncio.timeout().
Step 4: if the counter were incremented first, a refused call would still be counted, and the budget would fill up with calls that never ran. Checking first means only executed calls are counted, so the recorded numbers match what actually happened, and the logs stay accurate.