Limit Tool Calls

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

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:

SituationWhat happens without limits
A tool keeps failingClaude 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 agentContext, 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

LimitControlsMechanism
IterationsNumber of requests to Claude in one runmax_iterations on the runner (Lesson 92)
Total tool callsAll tool executions in one runA call budget checked inside each tool
Per-tool callsCalls to one specific (often expensive) toolA per-tool limit in the same budget
Parallel callsTools requested in a single responsetool_choice={"type": "auto", "disable_parallel_tool_use": True} (Lesson 89)
Tokens or costTotal usage of the runSum usage while iterating; stop when over budget
TimeWall-clock duration of the runCheck 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

  1. CallBudget keeps a Counter of calls per tool. charge() checks the total limit first, then the tool's own limit, and raises a RuntimeError with a message written for Claude when a limit is reached. Only calls that pass the checks are counted.
  2. limited(name) returns a decorator. The wrapper charges the budget, then calls the real function. functools.wraps preserves the name, docstring, and type hints, so @beta_tool generates the same definition as without the budget.
  3. 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.
  4. Four limits work together:
    • max_iterations=6 caps requests;
    • total=4 caps 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.
  5. The system prompt mentions the limit, so Claude can plan, for example by calling list_courses once instead of guessing slugs.
  6. 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.
  7. 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

LimitStarting pointAdjust when
max_iterations5–10 for assistants, more for complex agentsLegitimate tasks are cut off, or runs are too long
Total tool calls2–3× the calls a typical question needsLogs show answers missing information
Per-tool limitsFor expensive or rate-limited tools onlyExternal costs or quotas are at risk
Token budgetBased 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

  1. Change the budget to total=10 with no per-tool limits, and run the same question. How many calls does Claude make now?
  2. Add a time limit: stop the loop if the run takes longer than 20 seconds.
  3. Run with tool_choice={"type": "auto", "disable_parallel_tool_use": True}. How do the number of iterations and tool calls change?
  4. Explain why charge() checks the limits before incrementing the counter.
Show answers for steps 2 and 4

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.

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 Limit Tool Calls and get answers drawn from it.

Signed-in readers only.