Structured Outputs & Tool Use
Turning a text generator into a component your code can actually call.
Introduction
Every agent lesson in this course has quietly assumed this one. ReAct loops, planner executors, and multi-agent topologies all rest on two API mechanics: constraining what the model outputs, and letting it call your functions. Get these wrong and no amount of clever agent architecture saves you, because the layer underneath is returning prose where your code expected a record. This lesson covers structured outputs and tool use properly, so the patterns in Lesson 13 have solid ground to stand on.
1. Why Free Text Is the Wrong Interface
Ask a model to extract structured data and describe the result in prose, and you get something helpful to a human and hostile to a parser. Here is a real support ticket run through a plain request:
# extract_freeform.py - asking nicely and hoping for structure
import anthropic
client = anthropic.Anthropic()
TICKET = (
"Hi, this is Dana Reyes (dana.reyes@northwind.example). Our checkout page "
"has been throwing 500s since this morning and we're losing sales. "
"We're on the Enterprise plan. Please escalate."
)
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=300,
messages=[{
"role": "user",
"content": f"Extract the customer details from this ticket:\n\n{TICKET}",
}],
)
print(next(b.text for b in response.content if b.type == "text"))Expected Output:
# Customer Details | Field | Value | |-------|-------| | **Name** | Dana Reyes | | **Email** | dana.reyes@northwind.example | | **Company** | Northwind | | **Plan** | Enterprise | | **Issue** | Checkout page returning 500 errors | | **Severity** | High (Revenue impact) | | **Action Needed** | Escalation required |
That answer is correct and completely unusable. It is a Markdown table. Nothing promised a table: the same prompt on another day returns a bulleted list, or prose, or JSON wrapped in a code fence. It invented a Company field (assumed from email) you never asked for, and rendered severity as High (Revenue impact) rather than one of your enum values. Writing a regex against this is how teams end up with a parser that breaks every few weeks for reasons nobody can reproduce.
The fix is not a better prompt. Prompts are requests; you need a constraint. The API gives you two, and picking between them is the first decision:
Two Ways to Constrain the Same Model
Figure 1: Structured outputs shape what comes back. Tool use lets the model reach out mid-answer. Many real systems use both in the same request.
2. Structured Outputs
The cleanest form is client.messages.parse() with a Pydantic model. You describe the shape once as a class, and get back a validated instance instead of a string:
# extract_parse.py - the same request, constrained by a Pydantic model
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
TICKET = (
"Hi, this is Dana Reyes (dana.reyes@northwind.example). Our checkout page "
"has been throwing 500s since this morning and we're losing sales. "
"We're on the Enterprise plan. Please escalate."
)
class Ticket(BaseModel):
customer_name: str
email: str
plan: str
severity: str
summary: str
tags: list[str]
response = client.messages.parse(
model="claude-haiku-4-5",
max_tokens=500,
messages=[{"role": "user", "content": f"Extract the ticket details:\n\n{TICKET}"}],
output_format=Ticket,
)
ticket = response.parsed_output # a validated Ticket instance
print(type(ticket).__name__)
print(ticket.customer_name)
print(ticket.email)
print(ticket.plan)
print(ticket.severity)
print(ticket.tags)Expected Output:
Ticket Dana Reyes dana.reyes@northwind.example Enterprise critical ['checkout', '500-error', 'critical-issue', 'sales-impact']
parsed_output is a real Ticket, not a dict that looks like one. Your editor autocompletes it, your type checker checks it, and a malformed response raises at the boundary instead of surfacing three functions later as a KeyError. Note the model chose Critical for severity: the schema constrained the shape, not the vocabulary. If you need a fixed set of values, say so with an enum.
Under the hood, Pydantic generates the JSON Schema that gets sent to the API. It is worth looking at once, because the schema is the actual contract:
# the JSON Schema Pydantic sends on your behalf
import json print(json.dumps(Ticket.model_json_schema(), indent=2))
Expected Output:
{
"properties": {
"customer_name": {
"title": "Customer Name",
"type": "string"
},
"email": {
"title": "Email",
"type": "string"
},
"plan": {
"title": "Plan",
"type": "string"
},
"severity": {
"title": "Severity",
"type": "string"
},
"summary": {
"title": "Summary",
"type": "string"
},
"tags": {
"items": {
"type": "string"
},
"title": "Tags",
"type": "array"
}
},
"required": [
"customer_name",
"email",
"plan",
"severity",
"summary",
"tags"
],
"title": "Ticket",
"type": "object"
}You can skip Pydantic and pass a schema yourself. This is the right call when the shape is dynamic, or when you are not in Python:
# the same constraint without Pydantic, for dynamic shapes or other languages
# Same constraint without Pydantic, using output_config directly.
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=500,
messages=[{"role": "user", "content": f"Extract the ticket details:\n\n{TICKET}"}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"customer_name": {"type": "string"},
"email": {"type": "string"},
"severity": {"type": "string", "enum": ["low", "normal", "high", "critical"]},
},
"required": ["customer_name", "email", "severity"],
"additionalProperties": False,
},
}
},
)
# The format guarantees the first text block is valid JSON.
import json
data = json.loads(next(b.text for b in response.content if b.type == "text"))Not every JSON Schema keyword is supported, and the split is not the one most people guess. String constraints are honored; numeric and array bounds are not:
| Keyword | Status | Note |
|---|---|---|
enum, const, anyOf, $ref | Supported | $ref only when it is not recursive |
format, pattern | Supported | String formats and regex patterns both apply |
minLength, maxLength | Supported | String length bounds work, unlike numeric ones |
additionalProperties: false | Supported | Required for strict tools |
minimum, maximum | Rejected | 400: for 'integer' type, properties maximum, minimum are not supported |
minItems, maxItems | Rejected | Array length has to be checked in your code |
Recursive $ref | Rejected | A self-referencing node type cannot be expressed |
minimum in a raw output_config schema and the API rejects the request outright. Express the same bound as a Pydantic Field(ge=1, le=5) and the call succeeds, because the SDK strips the constraint before sending and Pydantic re-checks it when parsing the reply. Convenient, and worth knowing precisely: the model was never told about that bound. It was not steered away from violating it, you simply get a ValidationError if it does.{ and using stop sequences is on its way out. On the 5 series it is already gone: claude-sonnet-5 and claude-opus-5 reject a prefilled assistant turn with a 400. On claude-haiku-4-5, the model used throughout this lesson, prefill still works, which is why you will keep meeting it in code written against 4.x (see Lesson 12 for a judge built that way). Write new code against structured outputs: they replace prefill and the retry-on-parse-failure loop that used to sit around it, and they work on every model.3. Defining a Tool
A tool is a name, a description, and a JSON Schema for its inputs. The model never runs anything: it emits a request to run something, and your code decides what happens next.
# tools.py - a name, a description, and an input schema
TOOLS = [
{
"name": "get_weather",
# The description is the model's only documentation. Say WHEN to call
# it, not just what it does - that is what drives the decision.
"description": (
"Get the current weather for a city. Call this whenever the user asks "
"about current conditions, temperature, or whether to bring an umbrella."
),
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Madrid'"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city", "unit"],
"additionalProperties": False,
},
}
]Two fields carry more weight than they look like they should.
The description is the whole user manual. It is the only thing the model reads when deciding whether this tool applies. Descriptions that state only what a tool does leave the decision to inference; descriptions that state when to call it measurably raise the rate of calling it at the right moment. The most common tool-use bug is not a broken schema, it is a tool that never gets called because nothing told the model it was relevant.
strict: True guarantees the input you receive validates against your schema exactly. It requires additionalProperties: false and a required list. Without it you are writing defensive parsing around a payload that is usually right.
"description": "Gets weather data."
"description": (
"Get the current weather for a city. "
"Call this whenever the user asks about "
"current conditions, temperature, or "
"whether to bring an umbrella."
)4. The Tool Loop
The API is stateless, so tool use is a conversation you drive. Claude replies with stop_reason: "tool_use", you execute, you send results back, and you repeat until it stops asking.
One Tool Call, End to End
Figure 2: The model never touches your function. It asks; your code decides whether to comply.
# tool_loop.py - the hand-written loop, driven by stop_reason
import json
FAKE = {"Madrid": 34, "Oslo": 12, "Cairo": 41}
def get_weather(city: str, unit: str) -> str:
if city not in FAKE:
raise KeyError(f"no station for {city}")
c = FAKE[city]
return f"{c}C" if unit == "celsius" else f"{round(c * 9 / 5 + 32)}F"
messages = [{"role": "user", "content": "What's the weather in Madrid in celsius?"}]
turn = 0
while True:
turn += 1
resp = client.messages.create(
model="claude-haiku-4-5", max_tokens=512, tools=TOOLS, messages=messages,
)
print(f"turn {turn}: stop_reason = {resp.stop_reason}")
# Anything other than tool_use means Claude is done talking to your code.
if resp.stop_reason != "tool_use":
break
# Append the WHOLE assistant message, tool_use blocks included.
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
print(f" tool = {block.name}({json.dumps(block.input)})")
out = get_weather(**block.input)
print(f" result= {out}")
results.append({
"type": "tool_result",
"tool_use_id": block.id, # must match the tool_use block
"content": out,
})
# All results go back in ONE user message.
messages.append({"role": "user", "content": results})
print("final :", next(b.text for b in resp.content if b.type == "text"))Expected Output:
turn 1: stop_reason = tool_use
tool = get_weather({"city": "Madrid", "unit": "celsius"})
result= 34C
turn 2: stop_reason = end_turn
final : The current weather in Madrid is **34°C** (which is approximately 93°F). It's quite warm!Three details in that loop are load bearing, and each one is a bug if you get it wrong:
- Append the whole assistant message,
resp.contentand not just the text. Thetool_useblocks have to be in the history or thetool_resultyou send next refers to nothing. tool_use_idmust match the id from the block you are answering. This is how results pair with calls when several are in flight.- Break on the stop reason, not on a turn count. Do put a ceiling on iterations as a safety net, but the loop's actual exit condition is Claude no longer asking for tools.
If you would rather not hand write this, the SDK ships a tool runner that drives the loop and generates schemas from your function signatures:
# tool_runner.py - the same loop, driven by the SDK
from anthropic import beta_tool
@beta_tool
def get_weather(city: str, unit: str = "celsius") -> str:
"""Get the current weather for a city.
Args:
city: City name, e.g. Madrid.
unit: Either "celsius" or "fahrenheit".
"""
return f"{FAKE[city]}{'C' if unit == 'celsius' else 'F'}"
runner = client.beta.messages.tool_runner(
model="claude-haiku-4-5",
max_tokens=512,
tools=[get_weather],
messages=[{"role": "user", "content": "Weather in Madrid?"}],
)
for message in runner: # the loop above, handled for you
print(message.stop_reason)Expected Output:
tool_use end_turn
The runner still yields each message before tools execute, so approval gates, logging, and result rewriting are all available without dropping back to a manual loop. Write the manual loop when you genuinely need control the runner does not expose, not by default.
5. Parallel Tool Calls
One assistant message can contain several tool_use blocks. Ask about three cities and you get three calls in a single turn, not three round trips:
# parallel_calls.py - three tool_use blocks in one assistant message
messages = [{
"role": "user",
"content": "Compare the weather in Madrid, Oslo and Cairo in celsius.",
}]
resp = client.messages.create(
model="claude-haiku-4-5", max_tokens=800, tools=TOOLS, messages=messages,
)
calls = [b for b in resp.content if b.type == "tool_use"]
print(f"tool_use blocks in ONE assistant message: {len(calls)}")
for b in calls:
print(" ->", b.name, json.dumps(b.input))
# Execute them (concurrently, in real code), then return EVERY result
# in a single user message. Splitting them across messages teaches the
# model to stop asking for parallel calls.
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": get_weather(**b.input)}
for b in calls
]})Expected Output:
tool_use blocks in ONE assistant message: 3
-> get_weather {"city": "Madrid", "unit": "celsius"}
-> get_weather {"city": "Oslo", "unit": "celsius"}
-> get_weather {"city": "Cairo", "unit": "celsius"}Three Calls, One Turn
Figure 3: Fan out, then fan back in to a single message. The shape of your reply teaches the model what to ask for next time.
6. When a Tool Fails
Tools fail: the API is down, the record does not exist, the argument was nonsense. Report it with is_error: true rather than dropping the result or raising through the loop. The model can then adapt, which is the entire point of having it in the loop:
# tool_error.py - reporting failure with is_error instead of dropping it
messages = [{"role": "user", "content": "What's the weather in Lisbon in celsius?"}]
resp = client.messages.create(
model="claude-haiku-4-5", max_tokens=512, tools=TOOLS, messages=messages,
)
block = next(b for b in resp.content if b.type == "tool_use")
try:
out, err = get_weather(**block.input), False
except KeyError as exc:
out, err = f"Error: {exc}", True # Lisbon is not in FAKE
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": out,
"is_error": err, # tell the model it failed, do not drop the result
}]})
resp = client.messages.create(
model="claude-haiku-4-5", max_tokens=512, tools=TOOLS, messages=messages,
)
print(next(b.text for b in resp.content if b.type == "text"))Expected Output:
I apologize, but I'm unable to retrieve the weather for Lisbon at the moment - it appears the weather service doesn't have a station available for that location. This could be a temporary service issue. You might want to try: - Checking a weather website directly (like weather.com or your local weather service) - Trying a nearby city if one is available - Trying again in a few moments Is there another city you'd like me to check the weather for instead?
Notice what did not happen: no crash, no empty answer, no confident invention of a temperature. The model was told the lookup failed and said so.
tool_choice.7. Designing a Tool Surface
Once you have more than a couple of tools, the design of the set matters more than the design of any one tool.
| Decision | Guidance | Why |
|---|---|---|
| How many tools? | Few, with clear boundaries | Overlapping tools make the choice ambiguous, and every schema costs context on every request |
One bash tool or many specific ones? | Bash for breadth, dedicated tools for anything you must gate, render, audit, or parallelise | Your harness can intercept send_email(to=...); it cannot meaningfully intercept an opaque shell string |
| Parameter design | Expressive enums over free strings | A well named enum carries intent and removes a whole class of invalid input |
| Return payload | High signal, trimmed | Tool results land in the context window and are re-sent on every later turn |
| Destructive actions | Gate behind confirmation | The model proposes; irreversible things should need a human or a policy to dispose |
That last row is the bridge to Lesson 15. A tool surface is an authority surface: whatever you expose, an attacker who controls the model's input is also trying to reach. Least privilege applies to tools exactly as it applies to service accounts.
Key Takeaways
- Prompts request, schemas constrain - if your code consumes the output, describe it with a schema instead of asking nicely for JSON.
messages.parse()returns validated objects - failures surface at the boundary, not three functions downstream.- The schema constrains shape, not vocabulary - use
enumwhen the set of allowed values matters. - Tool descriptions should say when to call - the most common tool bug is a tool that is never invoked.
- Append the full assistant message and match
tool_use_id- the two mistakes that break every hand-written loop. - All parallel results go back in one user message - splitting them silently suppresses future parallel calls.
- Report failures with
is_error: true- a model that knows a tool failed will adapt; one that gets silence will guess. - Prefill is gone - structured outputs replace it, along with the retry loop that surrounded it.