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 is output_type, not result_type. result_type was removed. Likewise result.usage() and result.timestamp() are now the properties result.usage and result.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#

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.