Primitive · The Tool-Use Primitive
How tool calls round-trip
The calculator lesson showed one tool, one call, one result. Most real tasks require more than one tool and sometimes more than one call per turn. When Claude emits two tool_use blocks in a single response, both results must come back in a single user turn. Getting this wrong produces a validation error that tells you nothing useful about which rule you broke.
The full message structure, turn by turn
A tool-use exchange requires at minimum two API calls. Knowing the exact shape of each turn — every field, not just the conceptual summary — prevents the class of bugs that manifest as mysterious validation errors or incoherent model responses.
Turn one request:
# scripts/round_trip.py
import anthropic, json
client = anthropic.Anthropic()
tools = [
{
"name": "get_weather",
"description": "Return the current temperature in Celsius for a given city.",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
},
{
"name": "get_humidity",
"description": "Return the current relative humidity percentage for a given city.",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
]
messages = [{"role": "user", "content": "What is the weather and humidity in Oslo right now?"}]
r1 = client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
tools=tools,
messages=messages
)
print(r1.stop_reason) # → tool_use
print(json.dumps([b.model_dump() for b in r1.content], indent=2))
Turn one response content (two tool calls in one turn):
[
{
"type": "tool_use",
"id": "toolu_01ABCxyz",
"name": "get_weather",
"input": {"city": "Oslo"}
},
{
"type": "tool_use",
"id": "toolu_02DEFabc",
"name": "get_humidity",
"input": {"city": "Oslo"}
}
]
Turn two — collect all results, return in one user message:
# scripts/round_trip.py (continued)
def get_weather(city: str) -> str:
return "4" # Celsius — real handler would call a weather API
def get_humidity(city: str) -> str:
return "78" # percent
HANDLERS = {"get_weather": get_weather, "get_humidity": get_humidity}
tool_results = []
for block in r1.content:
if block.type == "tool_use":
result = HANDLERS[block.name](**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "assistant", "content": r1.content})
messages.append({"role": "user", "content": tool_results})
r2 = client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
tools=tools,
messages=messages
)
print(r2.stop_reason) # → end_turn
print(r2.content[0].text) # → "Oslo is currently 4°C with 78% humidity."
The three mistakes this structure prevents
First: discarding the assistant turn. The second request's messages list must include the full assistant response as it arrived — {"role": "assistant", "content": r1.content}. Sending only the tool_result without that preceding assistant turn causes a validation error: messages: roles must alternate between "user" and "assistant".
Second: splitting multi-tool results across multiple user turns. When the model emits two tool_use blocks in one response, both results go into a single user turn with two tool_result blocks. Returning them one at a time violates the alternation rule and will fail.
Third: mismatching tool_use_id. Each tool_result must carry the exact id from the corresponding tool_use block. A typo here produces a validation error on turn two: tool_use_id 'toolu_...' does not match any tool_use block.
What the message list looks like after turn two
After a complete two-call exchange, your messages list is:
[
{"role": "user", "content": "What is the weather and humidity in Oslo?"},
{"role": "assistant", "content": [ToolUseBlock(...get_weather...), ToolUseBlock(...get_humidity...)]},
{"role": "user", "content": [tool_result(get_weather), tool_result(get_humidity)]},
]
This is the canonical state after a two-call exchange. Every subsequent turn appends to this list in the same alternating pattern. The model reads the entire history on each call — there is no session or context object that persists server-side. You are responsible for keeping the list intact.
If you get stuck
400 messages: roles must alternate: You forgot the assistant turn, or you returned multi-tool results in separate user messages. Print messages before the failing call and check the role sequence.
400 tool_use_id does not match: Check that the id you pass in tool_use_id is the exact string from the tool_use block, character for character. These are case-sensitive. Copy the id directly from block.id — do not reconstruct it.
Model makes a second tool call instead of answering: The model decided it needs more information. Handle it the same way — look for stop_reason: "tool_use", run the handlers, append results, call again. This is the beginning of an agent loop, covered in l7.
Cross-reference
The alternating-roles shape is introduced in the api-fundamentals course, lesson l2. Tool-use turns are an extension of that same pattern, not a separate protocol. The agent loop in l7 of this course wraps this same structure in a while loop with a termination condition.
Deliverable
scripts/round_trip.py — a script with two stub handlers and the full two-turn exchange. The final print outputs the model's text answer. Add a third tool (e.g. get_wind_speed), confirm that three results in one user turn still produces a coherent response, and note the output.
Try these prompts
- Write the Python code to collect tool results from a response that contains two tool_use blocks — get_weather and get_humidity — and return both results in a single user turn.
- Explain the three mistakes the two-turn message structure prevents: discarding the assistant turn, splitting multi-tool results, and mismatching tool_use_id.
- Write what the messages list looks like after a complete two-call exchange with one user question and two tool results. Show the role and content shape for each entry.
Sign in to keep your place and tick off lessons. Reading stays open either way.