Structured output: a typed ticket
Structured output is an output_type you give an agent so the run ends with a validated instance of it instead of text you would parse.
Last updated: 28 Sep, 2026 · Pydantic AI 2.51
Every run so far returned a string. A support queue needs fields: a category and a priority. Give the agent a Pydantic model as its output_type and Pydantic checks the model filled it correctly.
Declaring the output type with a Pydantic model
from typing import Literal
from pydantic import BaseModel, Field
class Ticket(BaseModel):
category: Literal["billing", "shipping", "other"]
priority: int = Field(ge=1, le=5, description="1 is low, 5 is urgent")View the code here
import re
from pydantic_ai import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
def sort_ticket(text):
text = text.lower()
if "charged" in text or "refund" in text:
return "billing", 4
if "parcel" in text or "arrived" in text:
return "shipping", 3
return "other", 1
def shop_reply(messages, info: AgentInfo) -> ModelResponse:
prompts = [p.content for m in messages for p in m.parts if p.part_kind == "user-prompt"]
ticket = prompts[-1]
last = messages[-1].parts[-1]
order = re.search(r"A-\d{4}", ticket)
# 1. The ticket names an order and the agent has a tool: ask for it.
if order and info.function_tools and last.part_kind == "user-prompt":
tool = info.function_tools[0].name
return ModelResponse(parts=[ToolCallPart(tool, {"order_id": order.group()})])
# 2. A tool answered: write the reply from what it said.
if last.part_kind == "tool-return" and info.allow_text_output:
return ModelResponse(parts=[TextPart(f"Order {order.group()}: {last.content}.")])
# 3. The agent wants a typed answer: fill in its output tool.
category, priority = sort_ticket(ticket)
if info.output_tools:
args = {"category": category, "priority": priority}
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args)])
# 4. Otherwise, plain text.
return ModelResponse(parts=[TextPart(f"Sorted as {category}.")])
shop_model = FunctionModel(shop_reply, model_name="shop")
Getting a typed Ticket from a run
agent = Agent(shop_model, output_type=Ticket)
result = agent.run_sync("I was charged twice for one order")
print(result.output)
print(type(result.output).__name__)
print(result.output.priority + 1)category='billing' priority=4 Ticket 5
result.output is a Ticket, so result.output.priority is an int you can do arithmetic with, and your editor knows its fields.
Seeing the output tool the model is given
A model only produces text and tool calls. Pydantic AI turns Ticket into a tool the model must call to finish, whose arguments are the ticket's fields. A model function can print what it was handed:
def peek(messages, info):
tool = info.output_tools[0]
print("output tool:", tool.name)
print("text allowed:", info.allow_text_output)
print(json.dumps(tool.parameters_json_schema, indent=2))
return ModelResponse(parts=[ToolCallPart(tool.name, {"category": "billing", "priority": 4})])
Agent(FunctionModel(peek), output_type=Ticket).run_sync("I was charged twice")output tool: final_result
text allowed: False
{
"properties": {
"category": {
"enum": [
"billing",
"shipping",
"other"
],
"type": "string"
},
"priority": {
"description": "1 is low, 5 is urgent",
"maximum": 5,
"minimum": 1,
"type": "integer"
}
},
"required": [
"category",
"priority"
],
"title": "Ticket",
"type": "object"
}Reading the output tool
- The output tool is called
final_result, and calling it ends the run. - text allowed: False: with an
output_typethat is notstr, a plain text answer does not end the run. - The schema is Pydantic's JSON Schema for
Ticket: theLiteralbecame anenum, andField's limits and description are there for the model to read.
Tool output vs native and prompted output
| Mode | How the data comes back | Use when |
|---|---|---|
| Tool output (default) | An output tool call with the fields | Almost any model, tools supported |
NativeOutput | The provider's JSON mode | The provider validates JSON server-side |
PromptedOutput | Plain instructions to return JSON | No tools or JSON mode available |
Using types other than models
Any type Pydantic can validate works: int, list[str], a TypedDict. A type that is not an object is wrapped in an object with one field named response. The stand-in knows nothing of that and fills in category and priority, so validation fails:
agent = Agent(shop_model, output_type=list[str])
print(agent.run_sync("hello").output)pydantic_core._pydantic_core.ValidationError: 1 validation error for response_validation_typed_dict
response
Field required [type=missing, input_value={'category': 'other', 'priority': 1}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.13/v/missing
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "main.py", line 2, in <module>
print(agent.run_sync("hello").output)
pydantic_ai.exceptions.UnexpectedModelBehavior: Exceeded maximum output retries (1)The wrapped field response was missing, so the run raised after its retry ran out. What happens between the failure and the raise is Validation retries: when the model gets it wrong.
list[str] is validated under a wrapper field named response, not under the name you might expect. Give the model a named model class when you want the field names to be yours.Related
- Previous: Real models: providers, keys and model names
- Next: Validation retries: when the model gets it wrong
- Reference: Output
- Add
summary: strtoTicket. What does the stand-in's output fail on? - Change
categoryto a plainstrand print the schema again. - Print
result.all_messages()[-1]after a typed run.
You understood something today that you didn't yesterday.