- Step 4 of 17 in the agent-building course
- Step 8 of 10 in the AI Engineer path
Run a Bounded Tool-Calling Loop in Python

A model can ask to call a function. It can't call it. Your code decides whether the call runs, hands back the result, and decides how many more rounds the conversation gets.
Keep those jobs in your code, apart from the model, and the loop gets much easier to understand and test.
This lesson builds that loop around two small tools. One reads a made-up support ticket. The other suggests a label for it. The Python tool functions really run. The model's side is scripted, with fixed responses, so you can step through ten cases without an API key. No ticket is changed and no message is sent.
Download the complete tool loop. It uses the tool_use and tool_result message formats from Anthropic's Messages API, documented in Anthropic's tool-use overview. The script runs locally. It doesn't contact Anthropic or test which tools Claude would pick.
1. Run it and inspect the trace
Use Python 3.10 or newer, save the download as tool_loop.py, and run:
python3 tool_loop.py
Use python if that's your Python 3 command. You don't need to install anything. The September 17, 2026 test run passed all ten cases and confirmed the ticket stayed unchanged.
The first case takes three turns from the scripted model:
Turn 1: get_ticket(DEMO-42) → ticket text and version 3
Turn 2: propose_label(DEMO-42, billing, 3) → applied: false
Turn 3: final text → loop finishes
The output shows the call ID, tool name, error flag and result for every call that ran. This step-by-step record is the trace. Read it next to the final text. A sentence saying the task worked is no substitute for a tool result that shows it.
2. Describe one capability at a time
The script gives the scripted model two tool definitions. The first is:
{
"name": "get_ticket",
"description": "Read the permitted synthetic ticket before proposing a label.",
"input_schema": {
"type": "object",
"properties": {"ticket_id": {"type": "string"}},
"required": ["ticket_id"],
"additionalProperties": False,
},
}
The second tool, propose_label, needs ticket_id, one of three allowed labels, and a whole-number expected_version. Its result always includes applied: false.
The schema describes what the input should look like, but the tool function checks it again anyway. In the downloaded execute function, an extra authorized field gets rejected. It isn't taken as proof of permission. The list of tickets the model may touch comes from whoever calls run_loop, not from the model's arguments.
For this local exercise, that list holds only DEMO-42. A real app has to work out what the caller may access from their login and your current rules. Never let the model supply its own owner ID or decide its own access.
3. Validate, execute and return a matching result
The scripted model's first response looks like this:
{
"stop_reason": "tool_use",
"content": [{
"type": "tool_use",
"id": "call-1",
"name": "get_ticket",
"input": {"ticket_id":"DEMO-42"}
}]
}
The loop checks the response's shape and requires exactly one tool call in that turn. It confirms the call ID isn't empty and hasn't been used before, and it checks there's call budget left. Only then does it hand the call to execute.
The tool function only accepts names from a fixed list. It never runs a string as code or looks up any Python function by name. Unknown tool names, wrong fields, records it can't access, and outdated versions all come back as a ToolRejected result.
After the call runs, the loop keeps the model's original response and adds a user message with the matching result:
messages.extend([
{"role": "assistant", "content": blocks},
{"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": call_id,
"content": json.dumps(result),
"is_error": is_error,
}]},
])
The model's next turn gets that whole history. The scripted model checks that its previous response is still there and that the result points to the right call ID. A result for call-2 must never be attached to call-1.
4. Keep the stop conditions in code
By default, the loop allows at most three model turns and two tool calls. A rejected tool call still uses up one of those calls. So the model can't loop forever by sending slightly different bad arguments.
An end_turn response has to contain text and no tool calls. If the model stops because it hit its output limit, the loop returns incomplete_response. It doesn't pretend a cut-off answer finished the job. A repeated call ID stops the loop before anything else runs.
This example also rejects several tool calls in one response. That's a limit chosen to keep the lesson small, not a claim that the provider can't send several at once. If you add that, make sure every call ID gets a result, and decide how calls that depend on each other or partly fail should work before you run them together.
The budgets limit counts. They don't limit time or money on a real provider connection. Code that calls a real API also needs its own request timeout, token limits, error handling and a spending cap for the account.
5. Compare the ten observed cases
| Fixture | Observed outcome |
|---|---|
| Read, propose, final text | Two successful tools, loop finishes, proposal not applied |
| Ticket outside allowed scope | Error result record_unavailable, then final text |
Tool named send_email | Error result unknown_tool, then final text |
Extra authorized argument | Error result invalid_fields, then final text |
| Proposal for version 2 | Error result stale_or_invalid_version, then final text |
| Three fresh read requests | Third dispatch blocked by tool budget |
| Repeated call ID | Second dispatch blocked as duplicate |
max_tokens stop | Incomplete response, no tool dispatched |
| Two calls in one response | Unsupported batch, neither dispatched |
| Content that is not a block list | Invalid response, no tool dispatched |
"Loop finishes" means the conversation ended with a valid final text response. It doesn't mean every tool call worked. The denied-record case finishes after returning an error, and the trace keeps that difference visible.
The exact successful proposal in the first case is:
{
"ticket_id": "DEMO-42",
"label": "billing",
"expected_version": 3,
"applied": false
}
Afterward, the script checks that the original ticket still has the label other. A proposal needs its own approval step, and separate code to apply it, before it can change a record.
6. Change the budget and inspect the consequence
To try one change on its own, call the existing functions from a second file in the same folder:
from tool_loop import ScriptedAssistant, run_loop, tool
assistant = ScriptedAssistant([
tool("demo-call", "get_ticket", {"ticket_id": "DEMO-42"}),
])
print(run_loop(assistant, max_turns=1))
You should get turn_budget_exhausted, with one successful read in the trace. The loop has a tool result but no model turn left to turn it into a final answer. Decide what your app should do here: show the partial result, ask for another approved run, or stop and explain why.
Then change the outdated-version test case from version 2 to the true/false value true in JSON, or True in Python. It should still return stale_or_invalid_version. The tool function needs a real whole number, and it has to be the current one.
To connect a real provider later, write the model(messages, tools) function using that provider's current API, and return the documented response format. Keep the local test cases as regression tests, checks that catch anything you break later. A real provider can fail in ways these scripted cases don't cover, so write down that new testing work separately.
You should end up with the ten-case output and a trace that explains one rejected call. Next, the MCP server and client lesson puts a tool behind a real protocol connection.



