Getting structured, validated output
Return typed Pydantic objects from an agent and enforce business rules with a self-correcting validator loop.
Most of the time you don't want a wall of text back from an agent — you want a typed object you can pass straight into the rest of your program. AgentRoute gives you that in two layers: pass a Pydantic model as output= to get a parsed instance off result.output, then layer an @agent.output_validator on top to enforce rules the schema alone can't express. When a validator rejects an output, the agent gets the hint and tries again, bounded by retries.
This guide covers the typed result, the validator loop, and how output_mode and retries fit together. For the underlying concepts see structured output and errors and retries.
Typed output with a Pydantic model
Define a BaseModel, pass the class as output=, and read the parsed instance from result.output. The model becomes the schema the agent is asked to fill.
from pydantic import BaseModel, Field
from agentroute import Agent
class Invoice(BaseModel):
vendor: str
invoice_number: str
total: float = Field(description="Grand total in the invoice's currency")
currency: str = Field(description="ISO 4217 code, e.g. USD")
agent = Agent(
name="invoice-parser",
model="claude-sonnet-4",
instructions="Extract structured invoice data from the text the user provides.",
output=Invoice,
)
result = agent.run(
"Invoice #A-4471 from Globex Corp. Amount due: 1,250.00 USD."
)
invoice = result.output # an Invoice instance
print(invoice.vendor) # "Globex Corp"
print(invoice.total) # 1250.0
print(type(invoice)) # <class '__main__.Invoice'>When output is a model, result.output is an instance of that model — not a string. str(result) still gives you the raw output text if you need it, and result.usage carries the token and cost accounting for the run.
Use Pydantic Field(description=...) on each attribute. Those descriptions are passed to the model as part of the schema, so they double as inline instructions for how to fill each field.
How output_mode works
output_mode controls how the agent is asked to produce the structured result. It defaults to "auto", which is what you want almost always.
| Parameter | Type | Default | Description |
|---|---|---|---|
auto | str | — | Default. The agent picks the strategy. When output is set, this resolves to tool-style structured output. |
tool | — | — | The model returns the structured result by calling a synthetic output tool. Most reliable for nested or complex schemas. |
text | — | — | The model returns the result as text, which is then parsed into the model. Useful for endpoints or models that don't support tool calling. |
Set it explicitly only when you have a reason to:
agent = Agent(
name="invoice-parser",
model="claude-sonnet-4",
output=Invoice,
output_mode="text", # parse from a text response instead of a tool call
)For the full list of Agent parameters, see the Agent reference.
Validating output with a self-correcting loop
A schema enforces shape — string vs. float, required vs. optional. It can't enforce business rules like "the total must be positive" or "the currency must be one we support". For that, register a validator with @agent.output_validator.
A validator is a function (ctx, output) -> output. It receives the parsed instance, and either returns it (possibly modified) or raises Retry with a hint. When it raises Retry, the loop feeds your message back to the model and asks it to produce a new output. This repeats until the validator passes or retries is exhausted.
from pydantic import BaseModel, Field
from agentroute import Agent, Context, Retry
SUPPORTED_CURRENCIES = {"USD", "EUR", "GBP"}
class Invoice(BaseModel):
vendor: str
invoice_number: str
total: float = Field(description="Grand total in the invoice's currency")
currency: str = Field(description="ISO 4217 code, e.g. USD")
agent = Agent(
name="invoice-parser",
model="claude-sonnet-4",
instructions="Extract structured invoice data from the text the user provides.",
output=Invoice,
retries=3, # bound the validator loop: up to 3 attempts
)
@agent.output_validator
def check_invoice(ctx: Context, output: Invoice) -> Invoice:
"""Enforce rules the schema can't express. Raise Retry to ask for a redo."""
if output.total <= 0:
raise Retry(
f"total was {output.total}; it must be a positive number. "
"Re-read the amount due."
)
if output.currency not in SUPPORTED_CURRENCIES:
raise Retry(
f"currency '{output.currency}' is not supported. "
f"Use one of: {', '.join(sorted(SUPPORTED_CURRENCIES))}."
)
return output
result = agent.run(
"Invoice #A-4471 from Globex Corp. Amount due: 1,250.00 USD."
)
print(result.output)A few things worth knowing:
- The hint string you pass to
Retryis what the model sees. Make it specific and actionable — say what was wrong and what to do instead. - You can mutate and return the output to normalize it (for example, uppercasing
output.currencybefore returning) rather than always asking the model to redo the work. - You can register more than one validator. They run in registration order; the first to raise
Retryshort-circuits the attempt. - The validator receives the
Context, so you can branch onctx.deps, the currentctx.step, or accumulatedctx.usage.
Retry is not part of the ErrorAgent hierarchy — it's a control-flow signal the loop catches on purpose. You raise it to steer the model, not to report a failure. The same signal can be raised from inside a tool body to ask the model to call the tool again with better arguments.
How retries bound the loop
retries is the budget for the self-correction loop. Each time a validator raises Retry, the agent consumes one attempt and asks the model again with your hint. Once the budget is spent and the output still doesn't pass, the loop stops returning the last attempt rather than looping forever.
The agent fills the output schema and parses it into your model instance.
Each @agent.output_validator runs in order. If none raises Retry, the output is accepted and returned as result.output.
The loop formats your hint as a message back to the model and asks for a new output — as long as retries attempts remain.
After retries attempts the loop stops. Keep retries modest (3 is a good default) so a model that keeps producing bad output doesn't run up cost.
retries only bounds the validator loop — it is independent of max_turns (the tool-calling turn limit) and max_cost (the spend ceiling). A run that hits its cost ceiling raises ErrorBudget regardless of how many retries remain.
Putting it together
from pydantic import BaseModel, Field
from agentroute import Agent, Context, Retry
class Sentiment(BaseModel):
label: str = Field(description="One of: positive, neutral, negative")
confidence: float = Field(description="0.0 to 1.0")
agent = Agent(
name="sentiment",
model="claude-sonnet-4",
instructions="Classify the sentiment of the user's message.",
output=Sentiment,
retries=3,
)
@agent.output_validator
def check(ctx: Context, output: Sentiment) -> Sentiment:
if output.label not in {"positive", "neutral", "negative"}:
raise Retry(f"label '{output.label}' is invalid; use positive, neutral, or negative.")
if not 0.0 <= output.confidence <= 1.0:
raise Retry(f"confidence {output.confidence} is out of range; use 0.0 to 1.0.")
return output
result = agent.run("This is the best support experience I've ever had.")
print(result.output.label) # "positive"
print(result.output.confidence) # e.g. 0.97Run it:
python sentiment.pyNext steps
The concept behind typed results and how the schema reaches the model.
Retry vs. the error hierarchy, and how max_turns and max_cost interact.
Every Agent parameter, including output, output_mode, and retries.
What lives on a Result: output, usage, messages, and interrupted.