Lesson 8.2
Handling Tool Calls and Results
The exact wire format of the tool_use and tool_result cycle, parallel calls, error reporting, and the edge cases that break tool loops in production.
Defining a tool is half the contract; the other half is executing what Claude asks for and reporting back in the format the API expects. Get the shapes slightly wrong and you receive a 400; get the semantics wrong and the model learns bad habits within a single conversation. The exam tests the request and response JSON precisely.
What Claude sends
When Claude decides to use a tool, the response has stop_reason: "tool_use" and one or more tool_use blocks in content, often preceded by a short text block:
{
"role": "assistant",
"stop_reason": "tool_use",
"content": [
{"type": "text", "text": "I'll check that order for you."},
{"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "get_order_status",
"input": {"order_id": "ORD-10422", "include_history": false}}
]
}
Three fields matter: id (unique per call, you will echo it), name (which of your tools), and input (already-parsed JSON matching the schema). Never match on the serialised string of input; read the object.
What you send back
Append the assistant message exactly as received, then a user message whose content is tool_result blocks:
{
"role": "user",
"content": [
{"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "{\"status\": \"shipped\", \"carrier\": \"DHL\", \"eta\": \"2026-10-21\"}"}
]
}
tool_use_id must match the id of the block it answers. content can be a string or an array of text and image blocks, so a screenshot tool can return a picture. Then call the API again with the extended history; Claude continues from the result.
Parallel tool calls
Claude may request several tools in one response. Run them (concurrently if independent) and return all results in a single user message, in any order:
if resp.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": resp.content})
calls = [b for b in resp.content if b.type == "tool_use"]
outputs = await asyncio.gather(*(run_tool(c) for c in calls))
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": c.id, "content": out}
for c, out in zip(calls, outputs)
]})
Splitting results across several user messages, or interleaving text between them, is a common bug: the API requires each tool_use to be answered in the very next user message, and returning results one at a time teaches Claude to stop batching calls.
Reporting failures
When a tool fails, do not throw out of the loop and do not fabricate a result. Return a tool_result with is_error: true and a message that helps the model recover:
results.push({
type: "tool_result",
tool_use_id: call.id,
content: `Order not found. Valid IDs look like ORD-12345.`,
is_error: true,
});
Claude typically apologises, retries with corrected input, or asks the user. For permission denials, return a clear "the user declined this action" result rather than silently skipping, so the model does not assume the action happened.
Edge cases that break loops
| Situation | Symptom | Correct handling |
|---|---|---|
stop_reason: "max_tokens" with a tool_use present | Truncated input | Do not execute; raise max_tokens and retry the turn |
A tool_result with no matching tool_use in the previous assistant turn | 400 | Keep assistant and result messages adjacent and complete |
| Tool returns megabytes | Context blows up, cost spikes | Truncate or summarise inside the tool |
| Model requests an unknown tool name | Your dispatcher crashes | Return is_error: true with "unknown tool" |
stop_reason: "pause_turn" | Server tool paused | Resend the conversation unchanged |
| Input fails validation | Wrong types despite schema | Use strict: true, or validate and return is_error |
Streaming tool calls
In a stream, a tool_use block arrives as content_block_start with the id and name, followed by input_json_delta events carrying partial_json fragments, then content_block_stop. Concatenate the fragments and parse once the block stops. The SDK's get_final_message() / finalMessage() does this for you and yields parsed tool_use blocks.
Letting the SDK run the cycle
The tool runner in each SDK executes this entire cycle: it calls the API, runs your functions, builds tool_result blocks with the right IDs, and loops until end_turn. It is the recommended path when you do not need custom control flow, but the stop-reason checks above remain your responsibility; the runner does not stop on max_tokens or refusal for you.
Caching the cycle
Each turn appends an assistant message and a user message, so an agent conversation grows fast. Put a cache breakpoint on the last block of the newest message (or use automatic caching) so previous turns, including all earlier tool results, are read from cache.
Exam focus
- A tool call is a
tool_useblock withid,nameand parsedinput, and the response hasstop_reason: "tool_use". - The reply is a user message of
tool_resultblocks, each with the matchingtool_use_id; all results for one turn go in one message immediately after the assistant turn. - Failures are returned with
is_error: trueand a helpful message, never by aborting or inventing results. tool_result.contentcan be text or an array of text and image blocks.- Never execute a tool call from a response that stopped on
max_tokens; the input may be truncated. - In streams,
input_json_deltafragments are concatenated and parsed only aftercontent_block_stop.
Check yourself
5 questions
1.Which field in a tool_result block links the result to the call Claude made?
2.A tool handler throws a timeout. The developer returns: {"type": "tool_result", "tool_use_id": id, "content": "Error: upstream timeout"} What improvement does Anthropic recommend?
3.Claude returns three tool_use blocks in one assistant message. The developer sends three consecutive user messages, each containing one tool_result. What happens?
4.A take_screenshot tool needs to return an image to Claude. How can a tool_result carry it?
5.What does tool_choice: {"type": "none"} do?
