Read Tool Calls
What a Tool Call Looks Like
When Claude decides to use a tool, the response contains one or more tool_use content blocks, and stop_reason is "tool_use":
response.stop_reason # "tool_use"
response.content
# [ThinkingBlock(...), ← optional
# TextBlock(text="Let me check the weather."), ← optional preamble
# ToolUseBlock(id="toolu_01A09q...", name="get_weather", input={"city": "Chennai"})]
Each tool_use block has three important fields:
| Field | Type | Meaning |
|---|---|---|
id | str | A unique ID for this call, starting with toolu_. Your tool result must reference it (Lesson 87). |
name | str | Which tool to run, matching a name from your tool definitions |
input | dict | The arguments, following the tool's input_schema |
Reading tool calls correctly means finding all of these blocks, checking that each one is something your application can and should run, and turning its input into arguments for your Python function.
Finding Tool Calls
Filter the content blocks by type, as with text (Lesson 42):
tool_calls = [block for block in response.content if block.type == "tool_use"]
for call in tool_calls:
print(call.id, call.name, call.input)
toolu_01A09q90qw90lq917835lq9 get_weather {'city': 'Chennai'}
Several Calls in One Response
Claude can request several tools in one response, for example the weather in two cities, or enrollments plus a course search (Lesson 84). Always loop over all tool_use blocks. Taking only the first one leaves the others unanswered, and the next request will fail, because every call needs a result (Lesson 87).
Text Alongside Tool Calls
A response may contain text before its tool calls, such as "Let me look that up for you." You can show this text to the user as a status message, but never treat it as the final answer when stop_reason is "tool_use".
Check the Stop Reason First
stop_reason | Meaning for tool calls |
|---|---|
tool_use | Claude is waiting for tool results. Read and run the calls. |
end_turn | Claude finished, so there are no pending tool calls. |
max_tokens | The response was cut off. If a tool_use block was being generated, its input may be incomplete. Retry with a higher max_tokens. Do not run a possibly incomplete call. |
refusal | Claude declined. Do not run anything. |
Only run tool calls when stop_reason is "tool_use".
Validate Before Running
A tool call is a request from the model. Your application decides whether to honor it. Before running anything, check:
- Is the tool known? The name must exist in your registry. With
strict: true, the name is always one of your tools (Lesson 83), but defensive code still checks. - Is the input valid? Validate it against the expected structure: types, required fields, and allowed values. Pydantic models do this cleanly (Lesson 83).
- Is the call allowed? Check permissions and business rules: may this user perform this action, on this data, right now? Lessons 176–178 cover tool permissions.
Validation problems are not fatal: you can send an error result back to Claude, which then corrects its call (Lessons 87 and 90).
Turning Input into Function Arguments
block.input is a dictionary whose keys match your schema's properties. If your Python function's parameters use the same names, you can pass the dictionary as keyword arguments:
result = get_weather(**call.input) # Equivalent to get_weather(city="Chennai")
Validating the input with a Pydantic model first gives you a typed object with defaults applied:
arguments = WeatherInput.model_validate(call.input)
result = get_weather(**arguments.model_dump())
Tool Calls in Streams
When streaming (Lessons 53 and 54), tool inputs arrive as partial JSON fragments. Use the complete block from content_block_stop, or from the final message, before running a tool. Never act on a partial input.
Practical Example: Reading and Validating Tool Calls
This module extracts every tool call from a response, checks that the tool is known, validates its input with a Pydantic model, and reports problems without crashing. An offline test with a hand-made response shows all three outcomes: a valid call, an invalid input, and an unknown tool.
"""read_tool_calls.py: extract, check, and validate tool calls from a response."""
from dataclasses import dataclass
from types import SimpleNamespace
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class WeatherInput(BaseModel):
model_config = ConfigDict(extra="forbid")
city: str = Field(description="City name, e.g. Chennai")
class CourseSearchInput(BaseModel):
model_config = ConfigDict(extra="forbid")
topic: str = Field(description="Subject, e.g. 'Django'")
level: str | None = Field(default=None, description="Optional level filter")
INPUT_MODELS: dict[str, type[BaseModel]] = {
"get_weather": WeatherInput,
"search_courses": CourseSearchInput,
}
@dataclass
class ToolCall:
id: str
name: str
raw_input: dict
arguments: BaseModel | None # Validated, typed input; None if there is a problem
problem: str | None # Why the call cannot run; None if it can
def read_tool_calls(response) -> list[ToolCall]:
calls = []
for block in response.content:
if block.type != "tool_use":
continue
raw = dict(block.input or {})
model = INPUT_MODELS.get(block.name)
if model is None:
calls.append(ToolCall(block.id, block.name, raw, None, f"unknown tool {block.name!r}"))
continue
try:
calls.append(ToolCall(block.id, block.name, raw, model.model_validate(raw), None))
except ValidationError as error:
first = error.errors()[0]
location = ".".join(str(part) for part in first["loc"]) or "input"
calls.append(ToolCall(block.id, block.name, raw, None, f"invalid {location}: {first['msg']}"))
return calls
if __name__ == "__main__":
fake_response = SimpleNamespace(
stop_reason="tool_use",
content=[
SimpleNamespace(type="text", text="Let me check that for you."),
SimpleNamespace(type="tool_use", id="toolu_01", name="get_weather", input={"city": "Chennai"}),
SimpleNamespace(type="tool_use", id="toolu_02", name="search_courses", input={"level": "beginner"}),
SimpleNamespace(type="tool_use", id="toolu_03", name="delete_course", input={"id": 7}),
],
)
if fake_response.stop_reason == "tool_use":
for call in read_tool_calls(fake_response):
status = "OK" if call.problem is None else f"PROBLEM: {call.problem}"
print(f"{call.id} {call.name}({call.raw_input}) -> {status}")
if call.arguments is not None:
print(f" typed: {call.arguments!r}")
Output:
toolu_01 get_weather({'city': 'Chennai'}) -> OK
typed: WeatherInput(city='Chennai')
toolu_02 search_courses({'level': 'beginner'}) -> PROBLEM: invalid topic: Field required
toolu_03 delete_course({'id': 7}) -> PROBLEM: unknown tool 'delete_course'
How It Works
INPUT_MODELSmaps each tool name to the Pydantic model for its input. It is the reading side of the registry from Lesson 82.read_tool_calls()visits every block and keeps onlytool_useblocks, all of them, not just the first.dict(block.input or {})makes a plain copy of the input, and treats a missing input as an empty dictionary.- Unknown tools are reported as problems instead of raising exceptions. With well-defined tools this is rare, but defensive code never runs a tool it does not recognize.
model.model_validate(raw)checks types, required fields, and unknown fields. A valid input becomes a typed object such asWeatherInput(city='Chennai'). An invalid input produces a short problem message built from the first validation error: its location, joined with dots for nested fields, and its message.ToolCallkeeps the call's ID with its outcome. Every call, even a problematic one, needs a matching result in the next request: a real result for valid calls, and an error result explaining the problem for invalid ones (Lessons 87 and 90).- The offline test uses
SimpleNamespaceobjects with the same attributes as real SDK blocks, so the reading logic can be tested without any API calls (Lesson 42).
Common Mistakes
Reading Only the First Tool Call
What happens: parallel calls go unanswered, and the next request fails because results are missing.
Fix: loop over all tool_use blocks.
Running Calls When the Stop Reason Is Not tool_use
What happens: a truncated (max_tokens) response can contain an incomplete tool input, and running it may do the wrong thing.
Fix: only run calls when stop_reason == "tool_use", and retry truncated responses with a higher max_tokens.
Trusting Inputs Blindly
What happens: invalid, unexpected, or unauthorized inputs reach your systems.
Fix: validate structure with Pydantic, and check permissions and business rules.
Parsing the Preamble Text for Instructions
What happens: code depends on wording that varies.
Fix: use tool_use blocks as the only source of tool calls.
Losing the Call ID
What happens: results cannot be matched to calls.
Fix: keep id together with every call through validation and execution.
Best Practices
- Check
stop_reasonbefore reading tool calls. - Handle every
tool_useblock in a response. - Validate names and inputs before running anything.
- Use Pydantic models to turn inputs into typed arguments with defaults.
- Report problems as data, so they can become error results for Claude.
- Keep each call's
idwith its outcome. - Test call reading offline with fake responses.
Exercise
- Add a
get_enrollmentsentry toINPUT_MODELSwith a model that has no fields. Test it withinput={}and withinput={"user_id": "123"}. What happens in each case, and why is the second one a good thing to reject? - Run a real request from Lesson 84 that produces two parallel tool calls, and pass the response to
read_tool_calls(). - Change
read_tool_calls()to collect all validation errors for a call, not just the first one. - Write a function
pending_ids(response)that returns the IDs of all tool calls in a response, which Lesson 87 uses to check that every call received a result.
Step 1:
class EnrollmentsInput(BaseModel):
model_config = ConfigDict(extra="forbid")
INPUT_MODELS["get_enrollments"] = EnrollmentsInput
{} is valid. {"user_id": "123"} fails with Extra inputs are not permitted, because of extra="forbid". Rejecting it is good: your application decides whose enrollments to read, based on the logged-in user. Claude must not be able to choose another user's ID, whether by mistake or because of a prompt injection (Lessons 40 and 176).
Step 3:
except ValidationError as error:
messages = [
f"invalid {'.'.join(str(p) for p in e['loc']) or 'input'}: {e['msg']}"
for e in error.errors()
]
calls.append(ToolCall(block.id, block.name, raw, None, "; ".join(messages)))
Reporting every problem at once lets Claude fix all of them in a single retry.