AI engineering

Claude Tool Use in Python: Build a Simple Agent Loop Step by Step

Build a tool-calling agent loop with the Claude Messages API in Python: define tools, run tool_use blocks, return tool_result blocks and stop safely.

By EpiFive Editorial TeamReviewed/updated 11 min readHow we check articles
On this page

A Claude agent loop is a short Python while loop: send the conversation and your tool definitions to the Messages API, and if the response has stop_reason equal to tool_use, run the requested tools yourself, send back tool_result blocks, and call the API again. When the stop reason is anything else, usually end_turn, the model has finished and you read its final text. This article builds that loop in about 70 lines of Python, with error handling and an iteration cap.

Who this is for and what you will have

This guide is for developers who can call an HTTP API from Python and want a model to use their own functions, such as looking up an order, querying an internal service or doing arithmetic. By the end you will have a small, dependency-light agent: two tools, a loop that handles parallel tool calls, a way to report failures with is_error, and a hard stop so a confused model can't loop forever.

Claude does not run your tools. For client tools, the ones you define, the model only emits a structured request, and your code executes it and returns the result. That is why the loop lives in your code. The Anthropic SDK also offers a tool runner that manages this loop for you; this article writes the loop by hand so you can see every message that crosses the wire, which is also what you need when you want custom control over execution.

Prerequisites

You need a current Python 3 release, an Anthropic API key from the Claude Console, and the official SDK.

python -m venv .venv
source .venv/bin/activate
pip install anthropic
export ANTHROPIC_API_KEY="your-key-here"

The SDK reads ANTHROPIC_API_KEY from the environment, so you don't pass the key in code. The examples use the model ID claude-sonnet-5-5. Check the current model list in the documentation before you copy an ID into production, because models are retired on a published schedule.

How tool use works on the wire

Before writing code, it helps to know the shape of the messages. Claude's API does not use a separate tool role. Tool requests and results live inside the normal assistant and user messages as content blocks.

StepWho sends itWhat it contains
1Youmessages with the user question and a top-level tools array
2ClaudeAn assistant message with stop_reason: "tool_use" and one or more tool_use blocks, each with id, name and input
3YouA user message whose content is tool_result blocks, each with the matching tool_use_id
4ClaudeEither more tool_use blocks (go to step 3) or a final text answer

A tool definition has three required parts: a name that matches ^[a-zA-Z0-9_-]{1,128}$, a description, and an input_schema written as JSON Schema. The documentation calls the description the most important factor in tool performance and recommends explaining what the tool does, when to use it, what each parameter means and what the tool does not return. Aim for at least three or four sentences per tool.

Step 1: Define the tools

Create agent.py. The two tools below are deliberately simple so the loop stays in focus. One is a fake order lookup and one is a calculator.

import json
import anthropic
 
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5-5"
 
TOOLS = [
    {
        "name": "get_order_status",
        "description": (
            "Looks up the status of a customer order by its order ID. "
            "Use it whenever the user asks where an order is or whether it "
            "has shipped. Returns a JSON object with status and carrier. "
            "It does not return prices or customer details."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID, for example CONTOSO-1042.",
                }
            },
            "required": ["order_id"],
        },
    },
    {
        "name": "add_numbers",
        "description": (
            "Adds two numbers and returns the sum. Use it for any arithmetic "
            "the user asks for instead of calculating mentally."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "a": {"type": "number", "description": "First number."},
                "b": {"type": "number", "description": "Second number."},
            },
            "required": ["a", "b"],
        },
    },
]

Step 2: Implement the tool functions and a dispatcher

Each tool is an ordinary Python function. The dispatcher maps the name in a tool_use block to a function, runs it with the input object, and turns the outcome into a string. Keeping errors inside the dispatcher is what lets the loop report failures to the model instead of crashing.

ORDERS = {
    "CONTOSO-1042": {"status": "shipped", "carrier": "Fabrikam Freight"},
    "CONTOSO-1043": {"status": "processing", "carrier": None},
}
 
 
def get_order_status(order_id: str) -> str:
    order = ORDERS.get(order_id)
    if order is None:
        raise KeyError(f"No order with ID {order_id}")
    return json.dumps(order)
 
 
def add_numbers(a: float, b: float) -> str:
    return str(a + b)
 
 
HANDLERS = {
    "get_order_status": get_order_status,
    "add_numbers": add_numbers,
}
 
 
def run_tool(name: str, tool_input: dict) -> tuple[str, bool]:
    """Return (content, is_error)."""
    handler = HANDLERS.get(name)
    if handler is None:
        return f"Unknown tool: {name}", True
    try:
        return handler(**tool_input), False
    except Exception as exc:  # report any failure back to the model
        return f"{type(exc).__name__}: {exc}", True

Writing instructive errors matters. The documentation advises against generic messages such as "failed" and suggests including what went wrong and what Claude should try next, so the model can recover without guessing.

Step 3: Write the loop

The loop below keeps a messages list, calls the API, appends the assistant message exactly as returned, executes every tool_use block and sends the results back in a single user message.

MAX_TURNS = 8
 
 
def run_agent(question: str) -> str:
    messages = [{"role": "user", "content": question}]
 
    for _ in range(MAX_TURNS):
        response = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            tools=TOOLS,
            messages=messages,
        )
 
        if response.stop_reason != "tool_use":
            return "".join(
                block.text for block in response.content if block.type == "text"
            )
 
        # Keep the assistant turn, including any text and the tool_use blocks.
        messages.append({"role": "assistant", "content": response.content})
 
        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            content, is_error = run_tool(block.name, block.input)
            result = {
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": content,
            }
            if is_error:
                result["is_error"] = True
            results.append(result)
 
        messages.append({"role": "user", "content": results})
 
    raise RuntimeError(f"Agent did not finish within {MAX_TURNS} turns")
 
 
if __name__ == "__main__":
    print(run_agent("Where is order CONTOSO-1042, and what is 19.5 + 22.5?"))

Run it with python agent.py. Claude can ask for both tools in one response, which is called parallel tool use. Because the loop handles every tool_use block in the assistant message and returns all results together, it works whether Claude calls tools one at a time or several at once.

Why the message order matters

The API enforces three formatting rules that cause most first-time failures:

  • A tool_result message must immediately follow the assistant message that contains the matching tool_use blocks. Nothing can sit between them.
  • In the user message, the tool_result blocks must come first in the content array. Any text must come after all of them.
  • Every tool_use id needs a corresponding tool_result.

Breaking these rules returns a 400 error such as "tool_use ids were found without tool_result blocks immediately after". The loop above avoids the problem by building one results list per assistant turn and never adding text before it.

Step 4: Handle tool errors properly

The run_tool function returns an is_error flag. When it is true, the loop sets "is_error": true on the tool_result. Try asking about an order that doesn't exist:

print(run_agent("What is the status of order CONTOSO-9999?"))

The tool raises KeyError, the loop sends the message back with is_error set, and Claude explains that the order was not found rather than inventing a status. For invalid tool calls, such as a missing required parameter, the documentation says Claude will usually retry two or three times with corrections before apologising to the user. If you want to eliminate malformed inputs altogether, tool definitions accept strict: true, which the documentation describes as guaranteeing that inputs match your schema.

Step 5: Control when tools are called

By default tool_choice is auto, and Claude decides whether to call a tool. The parameter has four options: auto, any (must use one of the tools), tool (must use a named tool) and none (no tools). Two details are worth knowing before you rely on forcing:

  • On Claude Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1, the documentation says any and tool return a 400 error. Use auto together with strict tool use, or use structured outputs when you need a fixed JSON shape.
  • With manual extended thinking enabled, any and tool also fail, while auto and none still work.

If you need Claude to call a tool, say so in the prompt instead of forcing it, for example "Use the get_order_status tool to answer."

Step 6: Production hardening

A loop that works on a demo needs a few more guards before it faces real traffic.

Cap iterations

MAX_TURNS is the most important safety control. Without it, a model that keeps calling a failing tool can burn tokens indefinitely. Decide on a limit that fits the longest legitimate task, raise an error when it is hit, and log the full messages list for debugging.

Treat tool output as untrusted

Tool results often carry content you don't control, such as web pages, emails, uploaded files or third-party APIs. The documentation warns that an attacker who can influence that content may embed instructions that try to redirect the model, which is indirect prompt injection. Keep untrusted content inside tool_result blocks, not in the system prompt, give tools the least privilege they need, and require human approval before any action that can't be undone, such as sending money or deleting data.

Return high-signal results

Return only the fields the model needs, with stable identifiers. Large, noisy results waste context and make the next decision harder. If a tool returns thousands of rows, filter or summarise in your code first.

Use prompt caching for long tool lists

Tool definitions come first in the cached prefix (tools, then system, then messages). If your agent has many tools or a long system prompt, adding a cache_control breakpoint lets later turns of the loop read the prefix from the cache. See Anthropic Prompt Caching: Cut Cost and Latency on Repeated Context for how it works and what invalidates it. Note that changing tool_choice between calls invalidates cached message blocks, so keep it stable inside one loop.

Expect rate limits

Long agent loops make many calls in a short time. Handle 429 responses with a retry that honours the retry-after header; the pattern is covered in Handling LLM API Rate Limits: Retry, Backoff and Queues in Python.

Verify the loop works

Add a quick trace so you can see each turn. Print stop_reason and the tool names inside the loop:

print(response.stop_reason, [b.type for b in response.content])

For the example question you should see a first turn ending in tool_use with two tool_use blocks, followed by a second turn ending in end_turn with a single text block. If you see only one turn, the model answered without tools, so make the tool description more specific about when to use it.

Troubleshooting

SymptomLikely causeFix
400: tool_use ids were found without tool_result blocksA tool call wasn't answered, or a message sits between the two turnsReturn one tool_result per tool_use id in the very next user message
400 after adding a text note to the resultsText placed before tool_result blocksPut tool_result blocks first, text after
400 on tool_choice of any or toolModel doesn't support forced tool useUse auto, strict tool use or structured outputs
Claude never calls the toolVague descriptionState what it does, when to use it and what it does not return
Loop reaches MAX_TURNSTool keeps failing, or the question can't be answeredCheck the is_error messages, make them actionable, and fail with a clear error
Tool input missing a required fieldWeak schema or vague descriptionAdd parameter descriptions and consider strict: true

Checklist

  • Tool names match the allowed pattern and descriptions explain when and when not to use each tool.
  • Every tool_use id gets a tool_result, in one user message, with results first.
  • Failures are returned with is_error and an instructive message.
  • The loop has an iteration cap and handles rate limits.
  • Tool output is treated as untrusted, and risky actions need approval.
  • A cache breakpoint covers long, stable tool and system prefixes.

References

Questions people ask

How does an agent loop work with the Claude API?

You send a request with a tools list. If the response has a stop_reason of tool_use, you run each requested tool in your own code, append the assistant message and a user message containing tool_result blocks, and call the API again. You repeat until the stop_reason is something else, usually end_turn.

Why do I get a 400 error saying tool_use ids were found without tool_result blocks?

Every tool_use block in an assistant message must be answered by a tool_result block with the matching tool_use_id in the very next user message. The tool_result blocks must also come first in that message's content array, with any text after them. Check that you answer every tool call and that no message sits between the two.

How should I report a failing tool to Claude?

Return a tool_result with is_error set to true and a content string that says what went wrong and what to try next. Claude then adapts, for example by retrying with different input or explaining the failure to the user.

Can I force Claude to call a specific tool?

The tool_choice parameter supports auto, any, tool and none, but forced tool use is not supported on every model. The documentation lists Claude Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 as returning a 400 error for any and tool, so use auto with strict tool use or structured outputs there.

Claude APIPythonTool UseAI Agents
  1. LLM Model Routing: Send Easy Requests to a Cheaper Model

    Build a simple Python router that sends each request to a small or large Claude model, then measure cost and quality against an eval set before you ship it.

    AI engineering12 min read
  2. Getting Started with LLM Evals: A Small-Team Setup with Code

    Build your first LLM eval in an afternoon: write 30 test cases in a JSONL file, grade them with simple code, add an LLM grader for tone, and track the score between prompt changes.

    AI engineering9 min read
  3. Claude Message Batches API: Cut Bulk Job Costs in Python

    Run large asynchronous Claude jobs at 50 percent of standard API prices with the Message Batches API: Python create, poll and results code, limits and fixes.

    AI engineering9 min read
  4. Anthropic Prompt Caching: Cut Cost and Latency on Repeated Context

    Use Claude prompt caching to cut input cost on repeated prompts: cache_control placement, 5-minute and 1-hour TTLs, pricing maths, minimum lengths and fixes.

    AI engineering9 min read
  5. Claude Structured Outputs: Get Valid JSON from the API in Python

    Use output_config with a JSON schema, or messages.parse with Pydantic, to get schema-valid JSON from Claude in Python, plus the schema limits and failure cases to handle.

    AI engineering9 min read
  6. LLM API Timeouts, Retries and Rate Limits: A Python Reliability Guide

    Make Claude and OpenAI API calls in Python resilient: set timeouts, retry only the right errors, honor retry-after on 429, and tell spend limits from rate limits.

    AI engineering10 min read