Structured output

Get a typed Pydantic model back from an agent instead of free-form text, and validate it with output validators that can ask the model to retry.


By default an agent returns text. result.output is a string and print(result) shows what the model said. That is fine for chat, but most real workflows want a typed object: a parsed report, an extraction, a decision with named fields.

Pass output=SomePydanticModel to Agent and the agent returns an instance of that model. The fields are validated by Pydantic, so once you read result.output you are holding a fully-typed object — not a blob of JSON you have to parse yourself.

Asking for a typed result

Define a Pydantic BaseModel, hand the class to output=, and read the fields off result.output.

report.py
from pydantic import BaseModel
from agentroute import Agent
 
 
class WeatherReport(BaseModel):
    city: str
    temp_c: float
    conditions: str
 
 
agent = Agent(
    name="weather",
    model="claude-sonnet-4",
    output=WeatherReport,
)
 
result = agent.run("What's the weather in Paris right now?")
 
print(type(result.output))   # <class '__main__.WeatherReport'>
print(result.output.city)    # "Paris"
print(result.output.temp_c)  # 18.0

result.output is a WeatherReport, so result.output.temp_c is a float and your editor autocompletes the fields. The rest of the Result is unchanged — result.usage still reports tokens and cost, result.messages still holds the full transcript.

result.output is None until the agent finishes

While the agentic loop runs, the model may call tools several times before it produces a final answer. result.output is only populated once the loop returns. If the agent is interrupted (for example, an approval is denied), result.interrupted is True and result.output may be None.

How output_mode works

output_mode controls how the agent asks the model for the final answer. It has three values:

ParameterTypeDefaultDescription
output_modestr"auto"

"auto" (the default) uses tool mode when output is set and text mode otherwise. "tool" always forces structured tool mode. "text" always returns free-form text.

In tool mode, the agent exposes your output model to the model as a tool whose arguments are the model's fields. The model "calls" that tool to deliver the final answer, and the arguments are validated into your BaseModel. This is what you get with output_mode="auto" whenever output= is set.

In text mode, the agent just returns the model's text and result.output is a str. This is the default when no output is set.

Because the default is "auto", you almost never set output_mode yourself — passing output=WeatherReport is enough to switch the agent into structured tool mode. Set it explicitly only when you want to override that behavior.

# These two agents behave the same way.
Agent(name="a", model="claude-sonnet-4", output=WeatherReport)
Agent(name="b", model="claude-sonnet-4", output=WeatherReport, output_mode="tool")
 
# Force plain text even though an output type is declared.
Agent(name="c", model="claude-sonnet-4", output=WeatherReport, output_mode="text")

Validating output with retries

Pydantic guarantees the shape of the output, but not that the values make sense. A temp_c of 500.0 parses fine — it is still a float. To catch implausible values, register an output validator.

An output validator is a function (ctx: Context, output: OutputModel) -> OutputModel. Register it with the @agent.output_validator decorator. Return the output to accept it, or raise Retry with a hint to send the model back for another attempt.

weather.py
from pydantic import BaseModel
from agentroute import Agent, Context, Retry
 
 
class WeatherReport(BaseModel):
    city: str
    temp_c: float
    conditions: str
 
 
agent = Agent(
    name="weather",
    model="claude-sonnet-4",
    output=WeatherReport,
    retries=2,
)
 
 
@agent.output_validator
def sanity(ctx: Context, output: WeatherReport) -> WeatherReport:
    """Reject physically implausible temperatures."""
    if not -90.0 <= output.temp_c <= 60.0:
        raise Retry(
            f"temp_c={output.temp_c} is implausible for {output.city}. "
            "Provide a realistic surface temperature in Celsius."
        )
    return output
 
 
result = agent.run("What's the weather in Paris right now?")
print(result.output.city)    # "Paris"
print(result.output.temp_c)  # 18.0  — guaranteed within range

When sanity raises Retry, the loop catches the signal, formats the message as a tool-role message, and continues so the model can correct itself. The hint text is what the model sees, so make it specific and actionable.

Retry is a signal, not an error

Retry is not an ErrorAgent — it is deliberately not a user-visible failure. Raising it asks the model to try again; it does not surface to your caller unless retries run out. You can raise Retry from a tool body too, with the same effect.

How Retry and the retries parameter interact

The retries parameter on the agent bounds how many times a Retry can be honored before the loop gives up. Each Retry consumes one of the agent's retry budget; once retries is exhausted, the loop stops retrying.

ParameterTypeDefaultDescription
retriesint1

How many times a Retry signal (from an output validator or a tool) is honored before the loop stops retrying.

Validators run in registration order. You can register more than one with repeated @agent.output_validator decorators; the output must pass every validator. The first one to raise Retry short-circuits the round and sends the hint back to the model.

Distinguish Retry from a hard limit

Retry is for "the answer is wrong, try again." It is bounded by retries. That is separate from the loop's own ceilings: ErrorMaxTurns (too many tool-calling turns, max_turns) and ErrorBudget (cost over max_cost) are real failures that propagate to your caller. See Errors and retries for the full picture.

Validators can read context

Because the validator receives the Context, it can check the output against your dependencies or the current retry count. Context is never sent to the model, so this is purely server-side logic.

@agent.output_validator
def must_match_requested_city(ctx: Context, output: WeatherReport) -> WeatherReport:
    requested = ctx.deps["city"]
    if output.city.lower() != requested.lower():
        raise Retry(f"Report the weather for {requested}, not {output.city}.")
    return output
 
 
result = agent.run("Weather please", deps={"city": "Paris"})

Next steps