Chapter 2: Building Tools with FastAPI: Schemas, Orchestration, Streaming, Errors

Learning Objectives

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

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_schema is 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 descriptionStrong 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

LeverEffect
enumMakes out-of-set values unrepresentable (e.g. unit can never be "kelvin").
required vs optionalMark only truly required fields; over-marking forces the model to invent values.
Type / length constraintsmaxLength, stricter types, and explicit constraints give the model less room to drift.
strict: trueConstrains 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

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

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 typeFieldCarries
text_deltatextA fragment of visible response text, e.g. "Hello"
thinking_deltathinkingExtended-thinking text (followed by a signature_delta before the block closes)
input_json_deltapartial_jsonA 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 turnmessage_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.

sequenceDiagram participant Client participant FastAPI participant Claude participant Tool Client->>FastAPI: POST /chat/stream FastAPI->>Claude: messages.stream (turn 1) Claude-->>FastAPI: text_delta ... FastAPI-->>Client: SSE data: delta Claude-->>FastAPI: tool_use + stop_reason=tool_use FastAPI-->>Client: SSE data: tool_call FastAPI->>Tool: dispatch(**input) Tool-->>FastAPI: result FastAPI->>Claude: messages.stream (turn 2, tool_result) Claude-->>FastAPI: text_delta ... end_turn FastAPI-->>Client: SSE data: delta FastAPI-->>Client: SSE data: done
[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)
User-recoverable (ambiguous request, needs clarification)
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

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.

UnhelpfulInstructive
"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."
CategoryWho recoversHandling
Model-recoverableClaudeReturn is_error: true with an instructive message; the model retries or adapts.
User-recoverableEnd userReturn a tool_result phrased so Claude asks the user for clarification.
FatalYour code / operatorDo 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)
User-recoverable (ambiguous request, needs clarification)
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

Your Progress

Answer Explanations