# 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.

Source: https://www.epifive.com/blog/claude-tool-use-python-agent-loop · Published 2026-10-25 · EpiFive

## Key takeaways

- A Claude agent loop is just a while loop that runs tools while stop_reason is tool_use and stops on any other stop reason.
- Each tool_result must reference the tool_use id and must come first in the user message, before any text.
- Return tool failures with is_error set to true and an instructive message so Claude can recover instead of guessing.
- Always cap the number of loop iterations and treat tool output as untrusted input.
- Forced tool_choice values any and tool are rejected on the newest models, so design for auto.

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.

```bash
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.

| Step | Who sends it | What it contains |
| --- | --- | --- |
| 1 | You | `messages` with the user question and a top-level `tools` array |
| 2 | Claude | An assistant message with `stop_reason: "tool_use"` and one or more `tool_use` blocks, each with `id`, `name` and `input` |
| 3 | You | A user message whose content is `tool_result` blocks, each with the matching `tool_use_id` |
| 4 | Claude | Either 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.

```python
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.

```python
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.

```python
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:

```python
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](https://www.epifive.com/blog/anthropic-prompt-caching-cost-latency) 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](https://www.epifive.com/blog/llm-api-rate-limits-retry-backoff-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:

```python
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

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| 400: tool_use ids were found without tool_result blocks | A tool call wasn't answered, or a message sits between the two turns | Return one `tool_result` per `tool_use` id in the very next user message |
| 400 after adding a text note to the results | Text placed before `tool_result` blocks | Put `tool_result` blocks first, text after |
| 400 on `tool_choice` of `any` or `tool` | Model doesn't support forced tool use | Use `auto`, strict tool use or structured outputs |
| Claude never calls the tool | Vague description | State what it does, when to use it and what it does not return |
| Loop reaches `MAX_TURNS` | Tool keeps failing, or the question can't be answered | Check the `is_error` messages, make them actionable, and fail with a clear error |
| Tool input missing a required field | Weak schema or vague description | Add 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

- Define tools: https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools
- Handle tool calls: https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls
- Prompt caching: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
- Rate limits: https://platform.claude.com/docs/en/api/rate-limits

## Frequently asked questions

### 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.

