The tool-call loop
Trace a model-requested tool call through schema validation, execution and a returned observation.
THE IDEA
The tool-call loop
The host runs a loop. It sends the conversation and the tool definitions; the model answers with text, or with one or more requested calls; the host validates each call, executes the allowed ones and returns the results; the model continues. The model only proposes. For "Which is warmer, Oslo or Cairo?" two lookups are needed: one at a time that is three model turns, but if both are requested together it is two.
Predict first. Does the model itself necessarily perform the external operation?
Chapter context
A tool call is a structured request for the application to perform an operation. The model proposes arguments; the surrounding application decides whether the operation is valid and permitted and is responsible for executing it.
TRY IT YOURSELF
Live lab
The host runs a loop: send tools, receive requested calls, validate, execute, return results, repeat. A task about two cities needs two lookups.
TRY IT YOURSELF
Run the loop
The host runs a loop: send tools, receive requested calls, validate, execute, return results, repeat. A task about two cities needs two lookups.
Finish with a correct answer using two valid get_weather calls in three model turns or fewer. Fewer turns beats par.
Task given to the model: Which is warmer right now, Oslo or Cairo? Answer in one sentence.
This loop as code
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
TOOLS = [ # name, description and input_schema are the "Tool definition"
{"name": t["name"], "description": t["description"], "input_schema": t["schema"]}
for t in [
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name"
},
"unit": {
"type": "string",
"enum": [
"celsius",
"fahrenheit"
]
}
},
"required": [
"city"
]
}
}
]
]
messages = [{"role": "user", "content": "Which is warmer right now, Oslo or Cairo? Answer in one sentence."}]
for step in range(6): # Step budget
response = client.messages.create(model="claude-haiku-4-5", max_tokens=600, tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": response.content})
calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
break # final answer
results = []
for call in calls: # the model only PROPOSES these
problems = validate(call.input, schema_for(call.name)) # your host-side check
if problems:
output, is_error = "Invalid arguments: " + "; ".join(problems), True
else:
output, is_error = run_tool(call.name, call.input) # your code runs it
results.append({"type": "tool_result", "tool_use_id": call.id, "content": output, "is_error": is_error})
messages.append({"role": "user", "content": results})Update the controls above and this code changes with them. Run it yourself with the key in an environment variable.Exercises
Part 1. Do it hands-on
Count model turns by hand, then write the host loop that validates, executes and stops at a budget.
- To do: "Which is warmer, Oslo or Cairo?" needs two lookups. If the model asks for them one at a time, how many model turns until the final answer (call, call, answer)?
- To do: If the model requests both lookups in the same turn, how many model turns?
- To do: In both cases, how many times does the host execute get_weather?
- To do: Does the model itself necessarily perform the external operation? Type yes or no
Part 2. Do it in code
- validate_args(schema, args) is PROVIDED below (missing / unexpected / type / enum problem codes).
- run_loop(script, tools, schemas, max_steps): script is the list of model turns {"text": str, "calls": [{"id", "name", "input"}]}. For step 1, 2, … up to max_steps take the next turn. If it has no calls the loop is finished: return {"status": "finished", "steps": step, "results": all tool results so far, "final": that turn's text}.
- Otherwise handle every call in order: an unknown tool name gives is_error True and content "unknown tool: <name>"; arguments with problems give is_error True and content "invalid arguments: " + ", ".join(problems); otherwise call tools[name](**input) and use str(result) as content with is_error False; if the tool raises, is_error True and content str(exception). Each result is {"id", "content", "is_error"}. Then continue with the next turn.
- If max_steps turns are used up and the last turn still had calls, return {"status": "budget", "steps": max_steps, "results": ..., "final": ""}.
- turns_needed(n_lookups, parallel): model turns until the final answer: 1 when there are no lookups; otherwise 2 if parallel (one turn with all calls, then the answer) or n_lookups + 1 if sequential.
Both the live lab and the exercises must be solved to finish this lesson.
Stuck, or something not working? You can come back to it any time.
CHECK YOUR UNDERSTANDING
Does the model itself necessarily perform the external operation?
Chapter reference: math, worked example & code
This section belongs to the whole chapter, so it is the same on every lesson in it and may cover a different lesson's topic. The code for the experiment you just ran is inside the experiment itself.
Math and notation
A useful execution model is: requested operation + validated arguments + permission decision → execution result. Schema validation establishes a data-shape contract; authorization establishes whether this caller may perform this operation. Neither implies the other.
Worked example
A calculator tool requires numeric a and b and returns a+b. For a=2,b=3, its result is 5. If the request instead asks for an unknown field or omits b, reject or repair the request according to the contract before executing. A retry of this pure calculation is harmless; that does not establish the same property for a side-effecting tool.
Reference code
The following example uses the Python standard library and does not call a remote service.
def add(a,b):
if type(a) not in (int,float) or type(b) not in (int,float):
raise ValueError('numeric inputs required')
return a+b
assert add(2,3) == 5
try:
add('2',3)
except ValueError:
pass
else:
raise AssertionError('invalid input accepted')
Python examples are provided for study and local execution; this page does not execute Python.
Common mistakes
A model proposal is not permission. JSON schema shape validation is not business-rule validation. Tool outputs must not silently become trusted instructions.