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.
| 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.
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}", TrueWriting 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_resultmessage must immediately follow the assistant message that contains the matchingtool_useblocks. Nothing can sit between them. - In the user message, the
tool_resultblocks must come first in the content array. Any text must come after all of them. - Every
tool_useid needs a correspondingtool_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
anyandtoolreturn a 400 error. Useautotogether with strict tool use, or use structured outputs when you need a fixed JSON shape. - With manual extended thinking enabled,
anyandtoolalso fail, whileautoandnonestill 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
| 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_useid gets atool_result, in one user message, with results first. - Failures are returned with
is_errorand 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
Comments
Questions and tips are welcome. Comments are checked by the EpiFive team before they appear; please do not share passwords, phone numbers or other private details.