Chapter 2: Building Tools with FastAPI: Schemas, Orchestration, Streaming, Errors
Learning Objectives
Design clear, unambiguous tool input schemas using Pydantic and JSON Schema that Claude reliably calls correctly.
Implement the manual orchestration loop that connects Claude's tool_use output to FastAPI endpoints and back.
Stream Claude responses to a client while still executing tools mid-stream.
Return structured, model-recoverable error results instead of raw exceptions.
Pre-Quiz: Tool Schema Design
1. Which three fields make up the interface that Claude actually reads when deciding whether and how to call a tool?
The function body, its docstring, and its return type
The name, the description, and the input_schema
The HTTP method, the URL path, and the request body
The tool_use id, the name, and the input
2. What is the strongest guarantee that a tool call will match your JSON Schema exactly?
Marking every field as required
Adding an input_examples array
Setting strict: true on the tool definition
Writing a longer description
3. Anthropic's guidance for a growing library of near-duplicate operations like create/review/merge a PR is to:
Create a separate tool for each operation to keep them narrow
Consolidate them into one tool with an action enum parameter
Return the entire database row from each tool
Mark every parameter as required to avoid ambiguity
4. When you author a tool as a Pydantic BaseModel, what becomes the tool's description that Claude reads?
The class name
The model's docstring
The first Field's description
The required array
5. Why should you mark only the truly required fields in a tool schema?
Optional fields are ignored by Claude entirely
Every required field is one Claude must produce, so over-marking forces it to invent values
Required fields cannot have descriptions
The JSON Schema spec forbids more than three required fields
2.1 Tool Schema Design as an API-Design Problem
Key Points
Claude never reads your Python — it reads only the tool's name, description, and input_schema. The description is documentation for a probabilistic reader and the single biggest lever on reliability.
Author schemas as Pydantic BaseModels and emit them with model_json_schema(): the docstring becomes the description, Field(...) marks required, defaults make fields optional, and Literal becomes an enum.
Enums, required-vs-optional, and type/length constraints are guardrails; strict: true is the strongest — it constrains token sampling so inputs are guaranteed schema-valid.
Keep tools narrow but not too numerous: consolidate near-duplicates behind an action enum and namespace tool names to reduce selection ambiguity.
Design responses like inputs — return only high-signal, stable identifiers, since tool-result content is untrusted and wastes context.
The most consequential thing you write for a tool is not the function body but the description and schema — the only interface Claude sees. Tool design is an API-design problem whose consumer is a probabilistic reader that fills gaps with plausible guesses. A tool definition has three primary fields: name (matching ^[a-zA-Z0-9_-]{1,64}$), description (a detailed plaintext explanation, ideally 3–4 sentences), and input_schema (a JSON Schema object with type: "object", properties, and required).
Because input_schemais JSON Schema, you can author inputs as a Pydantic model and emit the schema with model_json_schema(), keeping inputs strongly typed. The docstring becomes the description, Field(description=...) documents each property, a Literal becomes an enum, and the ellipsis Field(...) marks a field required while a default makes it optional.
The description is the API doc
A good description covers what the tool does, when it should and shouldn't be used, what each parameter means, and any caveats — reading like endpoint documentation because that is exactly what it is.
Weak description
Strong description
"Search the database."
"Search the customer records database by email or account ID. Use this when the user references a specific customer. Do NOT use it for aggregate reporting — use run_report instead. Returns up to 20 matches ordered by last activity."
"Send an email."
"Send a transactional email to a single verified recipient. Use only after the user has explicitly confirmed the recipient and subject. The to field must be a full email address; the body supports plain text only."
The levers
Lever
Effect
enum
Makes out-of-set values unrepresentable (e.g. unit can never be "kelvin").
required vs optional
Mark only truly required fields; over-marking forces the model to invent values.
Type / length constraints
maxLength, stricter types, and explicit constraints give the model less room to drift.
strict: true
Constrains token sampling so inputs are guaranteed schema-valid; requires additionalProperties: false.
Figure 2.1: The tool contract as a one-way mirror — what the developer writes versus the narrow slit Claude actually sees.
flowchart LR
subgraph DEV["What the developer writes"]
A["Python function body"]
B["Business logic / DB calls"]
C["Error handling"]
D["name"]
E["description"]
F["input_schema"]
end
subgraph CLAUDE["What Claude sees"]
G["name"]
H["description"]
I["input_schema"]
end
D --> G
E --> H
F --> I
A -.->|"hidden"| CLAUDE
B -.->|"hidden"| CLAUDE
C -.->|"hidden"| CLAUDE
Figure 2.2: From Pydantic model to tool definition — turning one typed BaseModel into the three-field contract.
flowchart LR
A["Pydantic BaseModel"] --> B["docstring"]
A --> C["Field(...) / defaults"]
A --> D["Literal type"]
B --> E["description"]
C --> F["required array"]
C --> G["properties map"]
D --> H["enum constraint"]
E --> I["Tool definition: name + description + input_schema"]
F --> J["input_schema (JSON Schema)"]
G --> J
H --> J
J --> I
I --> K["tools param of messages.create()"]
[Animation slot: fade a "weak" one-line description into a "strong" multi-sentence one and show Claude's tool-call accuracy climbing.]
[Animation slot: morph a Pydantic class — docstring, Field, Literal — into its emitted JSON Schema, highlighting each mapping.]
Post-Quiz: Tool Schema Design
1. Which three fields make up the interface that Claude actually reads when deciding whether and how to call a tool?
The function body, its docstring, and its return type
The name, the description, and the input_schema
The HTTP method, the URL path, and the request body
The tool_use id, the name, and the input
2. What is the strongest guarantee that a tool call will match your JSON Schema exactly?
Marking every field as required
Adding an input_examples array
Setting strict: true on the tool definition
Writing a longer description
3. Anthropic's guidance for a growing library of near-duplicate operations like create/review/merge a PR is to:
Create a separate tool for each operation to keep them narrow
Consolidate them into one tool with an action enum parameter
Return the entire database row from each tool
Mark every parameter as required to avoid ambiguity
4. When you author a tool as a Pydantic BaseModel, what becomes the tool's description that Claude reads?
The class name
The model's docstring
The first Field's description
The required array
5. Why should you mark only the truly required fields in a tool schema?
Optional fields are ignored by Claude entirely
Every required field is one Claude must produce, so over-marking forces it to invent values
Required fields cannot have descriptions
The JSON Schema spec forbids more than three required fields
Pre-Quiz: The Orchestration Loop
1. In the orchestration loop, the loop terminates when:
stop_reason is anything other than "tool_use"
The model returns a text block
Exactly one tool has been called
The client disconnects
2. Which of these is a non-negotiable formatting rule that causes 400 errors if violated?
tool_result blocks must come last in the user message content array
tool_result blocks must come first in the user message, immediately after their tool_use
Each parallel tool result must be sent in its own separate user message
Tool results must be sent with a tool role, not a user role
3. Why does a FastAPI route use AsyncAnthropic() rather than the synchronous client?
The sync client cannot read the API key from the environment
The async client returns awaitables so the loop doesn't block uvicorn's event loop
Only the async client supports tools
The sync client cannot produce tool_use blocks
4. What is "tool dispatch" and how is it idiomatically implemented?
Sending results to Claude; implemented with a background queue
Mapping a tool's name to its Python function, via a dictionary keyed by tool name
Validating inputs; implemented with a Pydantic model
Choosing which model to call; implemented with an if/else chain
5. For long-running agentic work where a human is watching, the recommended default is to:
Run the loop in the request handler and return one JSON response at the end
Push the loop to a background task and have the client poll for a job ID
Stream the output as it happens so the connection stays busy and the user sees progress
Use the SDK Tool Runner to hide the loop entirely
2.2 The Manual Orchestration Loop with FastAPI
Key Points
The model never executes anything — it emits a tool_use request and stops; your backend runs the tool, packages the result, and re-calls the API. That cycle is the orchestration loop.
Because the Messages API is stateless, the backend owns the AsyncAnthropic client and the running messages list; the API key and tool execution stay in your trusted environment.
A tool_use block carries id, name, and input. A single turn may include a leading text block and multiple parallel tool_use blocks.
Tool dispatch maps name to a Python function via a name-keyed dictionary — the choke point for logging, allow-lists, and authorization.
Formatting rules: tool_result blocks come first in the user message and immediately after their tool_use; all parallel results go back in a single user message.
Claude's tool use is a request/response contract your code drives. The four-step cycle: append the assistant's turn (including its tool_use blocks) verbatim; check stop_reason; if it is "tool_use", dispatch each block through the name-keyed table and collect one tool_result per call (each with tool_use_id matching block.id); send all results back in a single user message; then loop. Termination happens when stop_reason is anything other than "tool_use" — typically "end_turn".
Note the structural difference from other function-calling APIs: the Messages API integrates tools into the user/assistant message structure — assistant messages carry tool_use blocks; user messages carry tool_result blocks — rather than using a separate tool role. The SDK does offer a higher-level Tool Runner, but hand-rolling the loop is what buys custom control: iteration caps, structured logging, per-tool authorization, and the streaming we turn to next.
Figure 2.3: The orchestration loop as a state machine — call the API, check stop_reason, dispatch tools, assemble results, and loop or exit.
flowchart TD
A["Call Messages API"] --> B["Append assistant turn"]
B --> C{"stop_reason == tool_use?"}
C -->|"no (end_turn)"| D["Return final answer"]
C -->|"yes"| E["Dispatch each tool_use via name-keyed table"]
E --> F["Assemble tool_result blocks (results first)"]
F --> G["Append single user message"]
G --> A
Where the loop lives is an architectural decision. Running it inside the request handler and returning one JSON response is fine for short, bounded loops but does not scale to long agentic work — proxies time out and users see only a spinner. The two alternatives are to stream the output as it happens (Section 2.3), or to push the loop into a background task and return a job ID. Streaming is the right default for interactive chat.
[Animation slot: animate the four-step cycle as a spinning wheel — append assistant, check stop_reason, dispatch, send tool_result — exiting on end_turn.]
Post-Quiz: The Orchestration Loop
1. In the orchestration loop, the loop terminates when:
stop_reason is anything other than "tool_use"
The model returns a text block
Exactly one tool has been called
The client disconnects
2. Which of these is a non-negotiable formatting rule that causes 400 errors if violated?
tool_result blocks must come last in the user message content array
tool_result blocks must come first in the user message, immediately after their tool_use
Each parallel tool result must be sent in its own separate user message
Tool results must be sent with a tool role, not a user role
3. Why does a FastAPI route use AsyncAnthropic() rather than the synchronous client?
The sync client cannot read the API key from the environment
The async client returns awaitables so the loop doesn't block uvicorn's event loop
Only the async client supports tools
The sync client cannot produce tool_use blocks
4. What is "tool dispatch" and how is it idiomatically implemented?
Sending results to Claude; implemented with a background queue
Mapping a tool's name to its Python function, via a dictionary keyed by tool name
Validating inputs; implemented with a Pydantic model
Choosing which model to call; implemented with an if/else chain
5. For long-running agentic work where a human is watching, the recommended default is to:
Run the loop in the request handler and return one JSON response at the end
Push the loop to a background task and have the client poll for a job ID
Stream the output as it happens so the connection stays busy and the user sees progress
Use the SDK Tool Runner to hide the loop entirely
Pre-Quiz: Streaming Responses
1. What is the wire format of a single Server-Sent Event?
event: {json} followed by one newline
data: {json}\n\n — a data prefix and two newlines
A raw JSON object with no prefix
A WebSocket frame with a binary opcode
2. Which delta type carries the partial JSON fragments of a tool call's arguments?
text_delta
thinking_delta
input_json_delta
message_delta
3. Why can't you run a tool as soon as its content_block_start arrives?
The tool's input arrives as partial JSON fragments that only form a valid object at content_block_stop
Tools may only run after message_stop
The dispatch table is not loaded until the stream ends
SSE forbids executing code during a stream
4. What does the X-Accel-Buffering: no header accomplish?
It compresses the stream to reduce bandwidth
It tells Nginx and other proxies not to buffer the stream and release tokens in one batch
It authenticates the SSE connection
It forces the browser to reconnect on error
5. When a client closes the tab mid-stream, how does FastAPI let you stop generating?
It automatically kills the coroutine with no code needed
The generator checks await request.is_disconnected() and breaks, letting async with unwind and close the upstream stream
You must poll the browser for a heartbeat every second
You send a done event and hope the client acknowledges it
2.3 Streaming Responses to the Client
Key Points
Server-Sent Events (SSE) is a one-directional HTTP streaming protocol (data: {json}\n\n, double-newline separated), served in FastAPI via StreamingResponse with media type text/event-stream.
The Anthropic stream flow is fixed: message_start → content blocks (content_block_start → content_block_deltas → content_block_stop) → message_delta → message_stop, with ping/error events interspersed.
Delta types: text_delta (visible text), thinking_delta (extended thinking), and input_json_delta (partial JSON of tool arguments).
A tool's input only completes at content_block_stop; pass text/thinking through, then run tools when the stream ends with stop_reason == "tool_use". stream.get_final_message() assembles the parsed input for you.
Anti-buffering headers (Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no) and a disconnect check (request.is_disconnected()) keep streaming responsive and stop wasted work.
Streaming makes an assistant feel responsive and keeps the HTTP connection alive through long loops. SSE fits LLM streaming: server-to-client only, over ordinary HTTP, with automatic reconnect. Use AsyncAnthropic — the sync client's messages.stream() is a synchronous context manager that would block the event loop.
Delta type
Field
Carries
text_delta
text
A fragment of visible response text, e.g. "Hello"
thinking_delta
thinking
Extended-thinking text (followed by a signature_delta before the block closes)
input_json_delta
partial_json
A partial JSON string — a fragment of a tool call's arguments
The pattern for tools: stream text and thinking deltas straight through, and when the stream ends with stop_reason == "tool_use", execute the tools and open a new stream for Claude's next turn. Because you own the wire format, you can invent your own event types — emitting a tool_call event lets the browser render "Searching the database..." turning an invisible pause into visible progress. Guard against disconnects with await request.is_disconnected(); the async with context managers guarantee the upstream stream is released whether the stream ends normally, the client vanishes, or the task is cancelled.
Figure 2.4: Anatomy of a streamed turn — message_start, a text block, a tool_use block, message_delta, then message_stop.
flowchart LR
A["message_start"] --> B["content_block_start (text)"]
B --> C["text_delta x N"]
C --> D["content_block_stop"]
D --> E["content_block_start (tool_use)"]
E --> F["input_json_delta x N"]
F --> G["content_block_stop"]
G --> H["message_delta (stop_reason, usage)"]
H --> I["message_stop"]
P["ping / error events interspersed"] -.-> C
P -.-> F
Figure 2.5: SSE streaming with mid-stream tool execution — the choreography across two streamed turns.
[Animation slot: type tokens into a chat bubble as text_deltas arrive, then flip to a "Searching the database..." pill when a tool_call event fires, then resume typing.]
[Animation slot: show input_json_delta fragments concatenating into a valid JSON object only at content_block_stop, unlocking the tool run.]
Post-Quiz: Streaming Responses
1. What is the wire format of a single Server-Sent Event?
event: {json} followed by one newline
data: {json}\n\n — a data prefix and two newlines
A raw JSON object with no prefix
A WebSocket frame with a binary opcode
2. Which delta type carries the partial JSON fragments of a tool call's arguments?
text_delta
thinking_delta
input_json_delta
message_delta
3. Why can't you run a tool as soon as its content_block_start arrives?
The tool's input arrives as partial JSON fragments that only form a valid object at content_block_stop
Tools may only run after message_stop
The dispatch table is not loaded until the stream ends
SSE forbids executing code during a stream
4. What does the X-Accel-Buffering: no header accomplish?
It compresses the stream to reduce bandwidth
It tells Nginx and other proxies not to buffer the stream and release tokens in one batch
It authenticates the SSE connection
It forces the browser to reconnect on error
5. When a client closes the tab mid-stream, how does FastAPI let you stop generating?
It automatically kills the coroutine with no code needed
The generator checks await request.is_disconnected() and breaks, letting async with unwind and close the upstream stream
You must poll the browser for a heartbeat every second
You send a done event and hope the client acknowledges it
Pre-Quiz: Error Handling and Resilience
1. When a client-side tool fails, the idiomatic move is to:
Raise the exception so the request crashes cleanly
Return a tool_result with is_error: true and an instructive message
Silently retry the tool up to ten times
Inject the error into the system prompt
2. Which error message is most useful to Claude for self-correction?
"Error"
"failed"
"Error: Missing required 'location' parameter. Provide a city name."
The full Python stack trace
3. In the failure taxonomy, which category should NOT be fed back to the model?
Model-recoverable (missing parameter, transient API error)
Fatal (invalid API key, exhausted iteration budget)
All errors should always be fed back to the model
4. Why is every tool_result treated as an injection surface?
Because tool results are encrypted and must be decoded
Because they carry untrusted content (web pages, emails) an attacker may control
Because Claude executes tool results as code
Because they are stored in the system prompt by default
5. What turns "the while True loop could run forever" into a guaranteed-to-terminate loop?
Setting strict: true on every tool
An iteration cap paired with per-tool timeouts
Returning only high-signal fields
Using the synchronous Anthropic client
2.4 Error Handling and Resilience
Key Points
When a client-side tool fails, report, don't raise: return a tool_result with is_error: true and a human-readable message so Claude can incorporate the error and recover.
Message quality matters as much as description quality — instructive errors ("Rate limit exceeded. Retry after 60 seconds.") name the specific problem so the model self-corrects; surface only the exception message, never the stack trace.
Pydantic ValidationErrors are a first-class recovery path: validate the model's input inside dispatch and hand the (already descriptive) error back as is_error.
Classify failures as model-recoverable, user-recoverable, or fatal — feed only the first two to the model; abort and log fatal ones.
Every tool result is untrusted input and an injection vector; keep it inside tool_result blocks, return only high-signal fields, and always bound the loop with an iteration cap and per-tool timeouts. (Anthropic-hosted server tools are the exception — you never set is_error for them.)
A production tool loop is defined by how it behaves when things break. The governing principle: when a tool fails, you usually do not raise — you report the error back to the model as a result, so it can recover. A tool_result block has tool_use_id (required), content, and the optional boolean is_error. Faced with an instructive missing-parameter error, Claude will typically retry two or three times with corrections before apologizing to the user.
Unhelpful
Instructive
"Error"
"Rate limit exceeded. Retry after 60 seconds."
"Invalid input"
"Error: Missing required 'location' parameter. Provide a city name."
"Not found"
"No customer found with email 'x@y.com'. Ask the user to confirm the address."
Category
Who recovers
Handling
Model-recoverable
Claude
Return is_error: true with an instructive message; the model retries or adapts.
User-recoverable
End user
Return a tool_result phrased so Claude asks the user for clarification.
Fatal
Your code / operator
Do not feed to the model. Abort the loop cleanly, log, and alert.
Every tool_result is untrusted input — a vector for indirect prompt injection. The defense is placement (keep untrusted content inside tool_result blocks, not the system prompt), design discipline (return only high-signal fields), and the dispatch table as an authorization choke point. Finally, the while True loop is always bounded by an iteration cap and per-tool timeouts — together they turn "could run forever" into "guaranteed to terminate," the property you need before production.
Figure 2.6: Error-classification decision — routing a caught tool failure to the right handling based on who can recover.
flowchart TD
A["Tool raises / fails"] --> B{"Who can recover?"}
B -->|"Claude"| C["Return tool_result is_error: true + instructive message"]
B -->|"End user"| D["Return tool_result phrased to ask user for clarification"]
B -->|"Operator / code"| E["Abort loop cleanly, log + alert operator"]
C --> F["Model retries or adapts"]
D --> G["Model relays to user"]
E --> H["Request terminates"]
[Animation slot: drop a caught exception into a router that sorts it into model-recoverable, user-recoverable, or fatal bins, showing the different outcomes.]
Post-Quiz: Error Handling and Resilience
1. When a client-side tool fails, the idiomatic move is to:
Raise the exception so the request crashes cleanly
Return a tool_result with is_error: true and an instructive message
Silently retry the tool up to ten times
Inject the error into the system prompt
2. Which error message is most useful to Claude for self-correction?
"Error"
"failed"
"Error: Missing required 'location' parameter. Provide a city name."
The full Python stack trace
3. In the failure taxonomy, which category should NOT be fed back to the model?
Model-recoverable (missing parameter, transient API error)