You'll build a support-triage agent: it takes a customer message, calls a tool to look up that customer's account, and returns a validated Triage object — category, priority, whether to escalate — not a blob of text you have to reparse. The punchline of V2: the loop, tools, dependency injection, and typed output you already know are unchanged; what moved is everything around the agent. V1's grab-bag of constructor kwargs (history_processors, instrument, mcp_servers, event_stream_handler) collapsed into one composable list: capabilities=[...].
Pydantic AI reached V1 in September 2025 and promised no breaking changes until V2. V2.0 shipped stable on 2026-06-23 after seven betas; this tutorial targets the 2.x line. (For what V2 changes at the framework level rather than in your code, see our earlier report, Pydantic AI V2 Is Out: What 'Capabilities' and the Harness Actually Change.)
Install#
pip install pydantic-ai
One V2 gotcha up front: the default install no longer bundles bedrock, groq, mistral, cohere, xai, or huggingface. OpenAI, Anthropic, and Google work out of the box; for the others, install the extra explicitly. Set your provider key (export OPENAI_API_KEY=...) and you're ready.
Define the typed output#
The whole point of a typed agent is that the model's answer is a Python object you can trust. Declare it as a normal Pydantic model:
from pydantic import BaseModel, Field
class Triage(BaseModel):
category: str = Field(description="billing, technical, account, or other")
priority: int = Field(ge=1, le=5, description="1 = trivial, 5 = urgent")
escalate: bool = Field(description="true if a human should take over now")
summary: str
You pass this to the agent as output_type, and Pydantic AI turns it into the schema the model must fill, then validates the model's output against it before handing it back. If the model returns priority: 9, the ge=1, le=5 constraint fails and the agent asks the model to fix it.
V1 → V2: the argument isoutput_type, notresult_type.result_typewas removed. Likewiseresult.usage()andresult.timestamp()are now the propertiesresult.usageandresult.timestamp.
Wire up dependencies#
Dependency injection is how your tools reach the outside world — a database handle, an HTTP client, the current customer's ID — without globals. Declare the shape once as a dataclass and give it to the agent as deps_type:
from dataclasses import dataclass
@dataclass
class SupportDeps:
customer_id: int
db: "AccountDB" # your own connection object
Build the agent#
from pydantic_ai import Agent, RunContext
agent = Agent(
'openai:gpt-5.2',
deps_type=SupportDeps,
output_type=Triage,
instructions=(
"You triage inbound support messages. "
"Look up the customer's account before deciding priority."
),
)
instructions is the static system guidance. Need it to depend on runtime data? Add a dynamic instructions function — it receives the typed RunContext so you can read deps:
@agent.instructions
def customer_context(ctx: RunContext[SupportDeps]) -> str:
return f"You are helping customer #{ctx.deps.customer_id}."
Register tools#
A tool is just a function the model can call. Decorate it with @agent.tool and the framework generates the call schema from your type hints and docstring. The first parameter, RunContext[SupportDeps], carries your dependencies via ctx.deps; every other parameter becomes an argument the model fills in.
@agent.tool
async def account_status(ctx: RunContext[SupportDeps]) -> str:
"""Fetch the customer's plan, balance, and open-ticket count."""
acct = await ctx.deps.db.lookup(ctx.deps.customer_id)
return f"plan={acct.plan} balance={acct.balance} open_tickets={acct.tickets}"
If a tool needs no context, use @agent.tool_plain. Pydantic validates the model's chosen arguments against your signature before your function runs, so a bad call never reaches your code.
Run it three ways#
Sync — the simplest entry point:
deps = SupportDeps(customer_id=42, db=AccountDB())
result = agent.run_sync("I was double-charged this month!", deps=deps)
print(result.output) # Triage(category='billing', priority=4, escalate=True, ...)
print(result.output.escalate) # True — a real bool, fully typed
Async — inside an event loop, await it:
result = await agent.run("My login is broken", deps=deps)
Streaming — get partial output as the model produces it. Because output_type is a model, stream_output() yields progressively-complete Triage objects:
async with agent.run_stream("Everything is down!!", deps=deps) as stream:
async for partial in stream.stream_output():
print(partial) # Triage(...) filling in field by field
final = await stream.get_output()
For a text-only agent (no output_type), stream tokens with stream.stream_text() instead.
The end-to-end agent#
Here's the whole thing — runnable, ~50 lines, with a fake DB so you can run it today.
import asyncio
from dataclasses import dataclass
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
class Triage(BaseModel):
category: str = Field(description="billing, technical, account, or other")
priority: int = Field(ge=1, le=5)
escalate: bool
summary: str
class AccountDB:
async def lookup(self, customer_id: int) -> dict:
# stand-in for a real query
return {"plan": "pro", "balance": -120.00, "tickets": 3}
@dataclass
class SupportDeps:
customer_id: int
db: AccountDB
agent = Agent(
'openai:gpt-5.2',
deps_type=SupportDeps,
output_type=Triage,
instructions=(
"Triage the customer's message. Always call account_status first. "
"A negative balance or 3+ open tickets means escalate=True."
),
)
@agent.tool
async def account_status(ctx: RunContext[SupportDeps]) -> str:
"""Return the customer's plan, balance, and open-ticket count."""
a = await ctx.deps.db.lookup(ctx.deps.customer_id)
return f"plan={a['plan']} balance={a['balance']} open_tickets={a['tickets']}"
async def main():
deps = SupportDeps(customer_id=42, db=AccountDB())
result = await agent.run(
"I've been charged twice and can't reach anyone. Furious.",
deps=deps,
)
t = result.output
print(f"[{t.category}] p{t.priority} escalate={t.escalate} — {t.summary}")
print("tokens:", result.usage)
if __name__ == "__main__":
asyncio.run(main())
The model calls account_status, sees the negative balance and three open tickets, and returns a Triage your router can act on directly — if result.output.escalate: page_oncall() — with zero string parsing.
What "capabilities" actually change#
In V1 you extended an agent by piling optional kwargs onto the constructor. V2 replaces that with one primitive: a capability — a single composable unit that bundles tools, lifecycle hooks, instruction additions, and model settings. You pass a list, and they compose with middleware semantics (like Django or Starlette): instructions concatenate, model settings merge, toolsets combine.
Concretely, this is the migration:
# V1
Agent(
history_processors=procs,
event_stream_handler=handler,
instrument=settings,
mcp_servers=[server],
)
# V2
Agent(capabilities=[
ProcessHistory(...),
ProcessEventStream(...),
Instrumentation(...),
])
# and: mcp_servers=[...] -> toolsets=[...]
Why care as a builder? Because a capability is reusable and shareable. A guardrail, a memory system, or a coding toolkit is now one object you drop into any agent — and there are ~20 hook points (five lifecycle stages × before/after/wrap/on-error) to intercept model requests and tool calls. The prebuilt ones (memory, guardrails, sandboxed code execution) live in a separate package, pydantic-ai-harness; the core stays small. You don't need the Harness to ship the agent above — reach for it when you want batteries instead of building your own.
V1 → V2 migration cheat-sheet#
result_type→output_typeresult.usage()/result.timestamp()→result.usage/result.timestamp(properties)mcp_servers=[...]→toolsets=[...]- Scattered extension kwargs →
capabilities=[...] builtin_tools→native_tools;Usage→RunUsage- Bare
openai:now uses the Responses API — useopenai-chat:for the old Chat Completions behavior - Model names require a provider prefix:
Agent('gpt-5')raises; useAgent('openai:gpt-5')
That's the whole loop: a typed schema in, a validated object out, tools and deps in between — and a cleaner extension model around it. Start with output_type and @agent.tool; add capabilities only when you need them.



