# How to Build an AI Agent with Pydantic AI

- Tool: Pydantic AI
- Difficulty: Intermediate
- Time required: 45-60 min
- Compatibility: Python 3.10+, pydantic-ai 2.x, an LLM API key, any OS
- Last updated: November 2026

## TL;DR

Pydantic AI gives you a type-safe Python agent: define typed dependencies with deps_type, register tools with @agent.tool, and validate every output against a Pydantic model. This guide builds an IT helpdesk agent that checks an employee's access level freely but defers granting admin rights until a manager approves it, using ApprovalRequired and DeferredToolRequests. Budget 45-60 minutes and Pydantic AI 2.x.

## What you'll build

You'll build an IT helpdesk agent for an internal tools team. It can look up an employee's current access level through a look_up_access tool, and when someone needs elevated permissions, it can grant admin access through grant_admin_access. The twist: grant_admin_access is marked requires_approval=True, so every call to it pauses the run and returns a DeferredToolRequests object instead of executing, no matter what the model decides on its own.

Pydantic AI's deps_type lets you inject typed, non-model-visible context (a database handle, an access store, a request-scoped user) into every tool via RunContext.deps, so tools stay testable outside the agent entirely. When a tool call needs human sign-off, either the tool itself raises ApprovalRequired, or you mark the whole tool requires_approval=True at registration. The first run_sync call then returns an output of type DeferredToolRequests listing every pending approval; you build a DeferredToolResults mapping tool-call IDs to ToolApproved or ToolDenied, then call run_sync again with message_history and deferred_tool_results to resume exactly where it paused.

Choose Pydantic AI when you want strict, validated Python types end to end, dependency injection for tools, and durable execution backends like Temporal or DBOS. Choose the Claude Agent SDK instead when you want Claude's own harness and built-in tools, or the OpenAI Agents SDK when you're building specifically around OpenAI's handoffs and guardrails.

## Before you start

- Python 3.10+ and a terminal
- An API key for your model provider (e.g. ANTHROPIC_API_KEY or OPENAI_API_KEY)
- pip install pydantic-ai
- Basic comfort with Python type hints, dataclasses, and async/await

## Step-by-step guide

### 1. Install Pydantic AI and set your provider key

Install the package and export your model provider's API key as an environment variable. Pydantic AI reads it automatically when you reference a model string like 'anthropic:claude-sonnet-5-5'; you never pass the key directly in code.

```
pip install pydantic-ai
export ANTHROPIC_API_KEY="sk-ant-..."
```

**Expected result:** pip install finishes with no errors and `python -c "import pydantic_ai"` runs silently.

### 2. Define typed dependencies for the access store

deps_type constrains what RunContext.deps can hold for this agent. Here it's a small dataclass wrapping an in-memory access store; in production it would wrap a real database client or directory service connection. The model never sees this object directly, only the tools you write do.

```
from dataclasses import dataclass, field

@dataclass
class AccessStore:
    records: dict = field(default_factory=lambda: {
        "emp-204": {"name": "Dana Kim", "role": "engineer", "access": "standard"},
    })

@dataclass
class HelpdeskDeps:
    store: AccessStore
```

**Expected result:** Two plain dataclasses, no Pydantic AI imports needed yet, ready to pass as deps when you run the agent.

### 3. Define the read-only tool: look_up_access

Register look_up_access with @agent.tool. The function's type hints become its input schema automatically; the docstring becomes its description. RunContext gives the tool access to ctx.deps.store without exposing that store to the model itself.

```
from pydantic_ai import Agent, RunContext

agent = Agent('anthropic:claude-sonnet-5-5', deps_type=HelpdeskDeps)

@agent.tool
def look_up_access(ctx: RunContext[HelpdeskDeps], employee_id: str) -> str:
    """Look up an employee's current access level by employee ID."""
    record = ctx.deps.store.records.get(employee_id)
    if not record:
        return f"No record found for {employee_id}"
    return f"{record['name']}: role={record['role']}, access={record['access']}"
```

**Expected result:** A tool registered on `agent`, callable end to end once you run the agent with a HelpdeskDeps instance.

### 4. Define the sensitive tool: grant_admin_access

grant_admin_access takes the same RunContext plus employee_id and reason, but is registered with requires_approval=True. That flag means every call to it defers, independent of anything the model or your instructions say; the handler itself only runs once a human has already approved it and the run resumes.

```
@agent.tool(requires_approval=True)
def grant_admin_access(ctx: RunContext[HelpdeskDeps], employee_id: str, reason: str) -> str:
    """Grant admin access to an employee. Always requires manager approval before it runs."""
    record = ctx.deps.store.records.get(employee_id)
    if record:
        record["access"] = "admin"
    return f"Granted admin access to {employee_id} ({reason})"
```

**Expected result:** A second tool, same shape as the first, that will never execute without a resumed run carrying an approval.

### 5. Allow the run to pause with a DeferredToolRequests output

Set output_type=[str, DeferredToolRequests] on the agent. This tells Pydantic AI the run can legitimately end two ways: a normal string answer, or a DeferredToolRequests object listing every tool call that's waiting on a human. Without this, a deferred call would raise instead of surfacing cleanly.

```
from pydantic_ai import DeferredToolRequests

agent = Agent(
    'anthropic:claude-sonnet-5-5',
    deps_type=HelpdeskDeps,
    output_type=[str, DeferredToolRequests],
    instructions=(
        "You are an IT helpdesk agent. Always call look_up_access before making any claim about "
        "an employee's permissions. Never tell someone you granted access without actually calling "
        "grant_admin_access."
    ),
)
```

**Expected result:** Running the agent on a request that needs admin access now returns a DeferredToolRequests instance instead of raising.

### 6. Run the agent and resolve the deferred approval

Call run_sync with the deps instance. If result.output is a DeferredToolRequests, walk its approvals, decide each one (here, a stand-in for checking the requester's manager status), and build a DeferredToolResults mapping each tool_call_id to True or a ToolDenied with a reason. Call run_sync again with message_history and deferred_tool_results to resume.

```
from pydantic_ai import DeferredToolResults, ToolDenied

deps = HelpdeskDeps(store=AccessStore())
result = agent.run_sync("Dana (emp-204) needs admin access to deploy the new release.", deps=deps)

if isinstance(result.output, DeferredToolRequests):
    approvals = {}
    for call in result.output.approvals:
        print(f"[APPROVAL NEEDED] {call.tool_name}({call.args})")
        approved_by_manager = True  # swap for a real Slack/ticket check
        approvals[call.tool_call_id] = (
            True if approved_by_manager else ToolDenied("No manager sign-off on file")
        )

    final = agent.run_sync(
        message_history=result.all_messages(),
        deps=deps,
        deferred_tool_results=DeferredToolResults(approvals=approvals),
    )
    print(final.output)
else:
    print(result.output)
```

**Expected result:** The script prints an [APPROVAL NEEDED] line for grant_admin_access, then a final confirmation string once the approval resolves.

### 7. Harden it: real directory API and an audit trail

Two changes separate this from a toy: replace the in-memory AccessStore with real calls to your directory service inside each tool (wrap them in try/except and return a plain error string so the model can react instead of the process crashing), and log every approval request and decision, including who approved it and when, before you build the DeferredToolResults. That log matters more than the approval step itself when someone audits access grants later.

```
@agent.tool(requires_approval=True)
def grant_admin_access(ctx: RunContext[HelpdeskDeps], employee_id: str, reason: str) -> str:
    """Grant admin access to an employee. Always requires manager approval before it runs."""
    try:
        ctx.deps.store.records[employee_id]["access"] = "admin"
    except KeyError:
        return f"No record found for {employee_id}"
    return f"Granted admin access to {employee_id} ({reason})"
```

**Expected result:** A handler that fails gracefully on bad input and a log entry for every approval decision, automated or human.

## Complete code example

File: `helpdesk_agent.py`

```python
"""IT helpdesk agent: checks access levels freely, grants admin access only after a
manager approves it via Pydantic AI's deferred tool approval.
Run: python helpdesk_agent.py (pip install pydantic-ai; set ANTHROPIC_API_KEY)
"""
from dataclasses import dataclass, field

from pydantic_ai import (
    Agent,
    RunContext,
    DeferredToolRequests,
    DeferredToolResults,
    ToolDenied,
)


@dataclass
class AccessStore:
    records: dict = field(default_factory=lambda: {
        "emp-204": {"name": "Dana Kim", "role": "engineer", "access": "standard"},
    })


@dataclass
class HelpdeskDeps:
    store: AccessStore


agent = Agent(
    "anthropic:claude-sonnet-5-5",
    deps_type=HelpdeskDeps,
    output_type=[str, DeferredToolRequests],
    instructions=(
        "You are an IT helpdesk agent. Always call look_up_access before making any claim about "
        "an employee's permissions. Never tell someone you granted access without actually calling "
        "grant_admin_access."
    ),
)


@agent.tool
def look_up_access(ctx: RunContext[HelpdeskDeps], employee_id: str) -> str:
    """Look up an employee's current access level by employee ID."""
    record = ctx.deps.store.records.get(employee_id)
    if not record:
        return f"No record found for {employee_id}"
    return f"{record['name']}: role={record['role']}, access={record['access']}"


@agent.tool(requires_approval=True)
def grant_admin_access(ctx: RunContext[HelpdeskDeps], employee_id: str, reason: str) -> str:
    """Grant admin access to an employee. Always requires manager approval before it runs."""
    try:
        ctx.deps.store.records[employee_id]["access"] = "admin"
    except KeyError:
        return f"No record found for {employee_id}"
    return f"Granted admin access to {employee_id} ({reason})"


def main() -> None:
    deps = HelpdeskDeps(store=AccessStore())
    result = agent.run_sync(
        "Dana (emp-204) needs admin access to deploy the new release.", deps=deps
    )

    if isinstance(result.output, DeferredToolRequests):
        approvals = {}
        for call in result.output.approvals:
            print(f"\n[APPROVAL NEEDED] {call.tool_name}({call.args})")
            approved_by_manager = True  # swap for a real Slack/ticket check
            approvals[call.tool_call_id] = (
                True if approved_by_manager else ToolDenied("No manager sign-off on file")
            )

        final = agent.run_sync(
            message_history=result.all_messages(),
            deps=deps,
            deferred_tool_results=DeferredToolResults(approvals=approvals),
        )
        print(f"\nAgent: {final.output}")
    else:
        print(f"\nAgent: {result.output}")


if __name__ == "__main__":
    main()
```

## Common mistakes

- **Registering grant_admin_access without requires_approval=True and relying on the system prompt to ask permission** — undefined Fix: A prompt instruction is a suggestion the model can ignore under the right phrasing. requires_approval=True is enforced by the framework, not the model's judgment.
- **Forgetting output_type=[str, DeferredToolRequests] and being surprised when a deferred call raises instead of returning cleanly** — undefined Fix: Without that output_type, Pydantic AI has no declared shape for a paused run. Set it on every agent that has at least one requires_approval tool.
- **Calling run_sync again after approval without passing message_history from the first result** — undefined Fix: The resumed call needs the original conversation to make sense of which tool call it's resolving. Always pass result.all_messages() as message_history.
- **Approving every pending request the same way, without checking which tool or input it's for** — undefined Fix: Iterate result.output.approvals and inspect call.tool_name and call.args individually; a blanket True for every pending call defeats the point of deferral.
- **Letting a tool raise an uncaught KeyError or similar instead of returning a plain error string** — undefined Fix: An uncaught exception still reaches the model as an opaque error with no useful context. Catch it and return a message that tells the model what to try next.

## Best practices

- Mark every tool that changes a permission, moves money, or sends something external with requires_approval=True; keep read-only tools approval-free.
- Keep deps_type narrow and typed; inject only what a tool actually needs, not a god object with access to everything.
- State the tool-use rule explicitly in instructions: never claim an action happened without actually calling the tool that performs it.
- Always pass message_history from the first result when resuming after approval, not a fresh prompt.
- Log every DeferredToolRequests and the DeferredToolResults you build for it, independent of whether the call was approved or denied.
- Prefer ToolDenied with a reason over silently dropping a denied call, so the model can tell the user why.

## Use cases

### undefined



### undefined



### undefined



## Frequently asked questions

### What's different about Pydantic AI compared to other Python agent frameworks?

Pydantic AI leans on Pydantic's validation for everything: tool inputs, structured outputs, and dependencies are all typed and checked at runtime, not just hinted. It also ships deferred tool approval and durable execution (Temporal, DBOS, Prefect) as first-class features rather than something you bolt on.

### How do I build an AI agent with Pydantic AI if I've only used plain API calls before?

Start with pip install pydantic-ai, create an Agent('provider:model'), add one tool with @agent.tool, and call agent.run_sync('your prompt'). Add deps_type for shared state and requires_approval on any tool that shouldn't run unsupervised, in that order.

### Is result.output the same as the old result.data?

No. Pydantic AI 1.x used result.data; 2.x renamed it to result.output. If you're following an older tutorial that references .data, treat it as a sign the example predates the current API.

### What's the difference between ApprovalRequired and requires_approval=True?

requires_approval=True on the tool decorator defers every call to that tool unconditionally. Raising ApprovalRequired inside a tool function lets you defer conditionally, based on the input, for example only when ctx.tool_call_approved is false and the request touches a protected resource.

### How much does a conversation like this cost in tokens?

On Claude Sonnet 5.5 ($2/M input, $10/M output), a short helpdesk exchange with one tool call and a modest system prompt runs about $0.01-0.02 per turn uncached, less once your instructions and tool schemas are stable enough to benefit from prompt caching.

### Can someone build this for me instead of doing it myself?

Yes. RapidDev builds production agents on Pydantic AI, the Claude Agent SDK, and the OpenAI Agents SDK, including the approval and audit-trail pieces most tutorials skip. A free 30-minute consultation is the fastest way to find out what your version would take.

---

Source: https://www.rapidevelopers.com/build-ai-agent/how-to-build-an-ai-agent-with-pydantic-ai
© RapidDev — https://www.rapidevelopers.com/build-ai-agent/how-to-build-an-ai-agent-with-pydantic-ai
