Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an AI Agent with Pydantic AI

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 learn

  • Define typed dependencies with deps_type and access them in a tool via RunContext
  • Register tools with @agent.tool and let Pydantic AI infer their schema from type hints
  • Defer a sensitive tool call with ApprovalRequired instead of trusting the model to ask permission itself
  • Resume a paused run with DeferredToolResults once a human has approved or denied the request
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate9 min read45-60 minPython 3.10+, pydantic-ai 2.x, an LLM API key, any OSLast updated November 2026RapidDev Engineering Team
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.

Quick facts about this guide
FactValue
ToolPydantic AI
DifficultyIntermediate
Time required45-60 min
CompatibilityPython 3.10+, pydantic-ai 2.x, an LLM API key, any OS
Last updatedNovember 2026

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.

Prerequisites

  • 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.

typescript
1pip install pydantic-ai
2export 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.

typescript
1from dataclasses import dataclass, field
2
3@dataclass
4class AccessStore:
5 records: dict = field(default_factory=lambda: {
6 "emp-204": {"name": "Dana Kim", "role": "engineer", "access": "standard"},
7 })
8
9@dataclass
10class HelpdeskDeps:
11 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.

typescript
1from pydantic_ai import Agent, RunContext
2
3agent = Agent('anthropic:claude-sonnet-5-5', deps_type=HelpdeskDeps)
4
5@agent.tool
6def look_up_access(ctx: RunContext[HelpdeskDeps], employee_id: str) -> str:
7 """Look up an employee's current access level by employee ID."""
8 record = ctx.deps.store.records.get(employee_id)
9 if not record:
10 return f"No record found for {employee_id}"
11 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.

typescript
1@agent.tool(requires_approval=True)
2def grant_admin_access(ctx: RunContext[HelpdeskDeps], employee_id: str, reason: str) -> str:
3 """Grant admin access to an employee. Always requires manager approval before it runs."""
4 record = ctx.deps.store.records.get(employee_id)
5 if record:
6 record["access"] = "admin"
7 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.

typescript
1from pydantic_ai import DeferredToolRequests
2
3agent = Agent(
4 'anthropic:claude-sonnet-5-5',
5 deps_type=HelpdeskDeps,
6 output_type=[str, DeferredToolRequests],
7 instructions=(
8 "You are an IT helpdesk agent. Always call look_up_access before making any claim about "
9 "an employee's permissions. Never tell someone you granted access without actually calling "
10 "grant_admin_access."
11 ),
12)

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.

typescript
1from pydantic_ai import DeferredToolResults, ToolDenied
2
3deps = HelpdeskDeps(store=AccessStore())
4result = agent.run_sync("Dana (emp-204) needs admin access to deploy the new release.", deps=deps)
5
6if isinstance(result.output, DeferredToolRequests):
7 approvals = {}
8 for call in result.output.approvals:
9 print(f"[APPROVAL NEEDED] {call.tool_name}({call.args})")
10 approved_by_manager = True # swap for a real Slack/ticket check
11 approvals[call.tool_call_id] = (
12 True if approved_by_manager else ToolDenied("No manager sign-off on file")
13 )
14
15 final = agent.run_sync(
16 message_history=result.all_messages(),
17 deps=deps,
18 deferred_tool_results=DeferredToolResults(approvals=approvals),
19 )
20 print(final.output)
21else:
22 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.

typescript
1@agent.tool(requires_approval=True)
2def grant_admin_access(ctx: RunContext[HelpdeskDeps], employee_id: str, reason: str) -> str:
3 """Grant admin access to an employee. Always requires manager approval before it runs."""
4 try:
5 ctx.deps.store.records[employee_id]["access"] = "admin"
6 except KeyError:
7 return f"No record found for {employee_id}"
8 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 working example

helpdesk_agent.py
1"""IT helpdesk agent: checks access levels freely, grants admin access only after a
2manager approves it via Pydantic AI's deferred tool approval.
3Run: python helpdesk_agent.py (pip install pydantic-ai; set ANTHROPIC_API_KEY)
4"""
5from dataclasses import dataclass, field
6
7from pydantic_ai import (
8 Agent,
9 RunContext,
10 DeferredToolRequests,
11 DeferredToolResults,
12 ToolDenied,
13)
14
15
16@dataclass
17class AccessStore:
18 records: dict = field(default_factory=lambda: {
19 "emp-204": {"name": "Dana Kim", "role": "engineer", "access": "standard"},
20 })
21
22
23@dataclass
24class HelpdeskDeps:
25 store: AccessStore
26
27
28agent = Agent(
29 "anthropic:claude-sonnet-5-5",
30 deps_type=HelpdeskDeps,
31 output_type=[str, DeferredToolRequests],
32 instructions=(
33 "You are an IT helpdesk agent. Always call look_up_access before making any claim about "
34 "an employee's permissions. Never tell someone you granted access without actually calling "
35 "grant_admin_access."
36 ),
37)
38
39
40@agent.tool
41def look_up_access(ctx: RunContext[HelpdeskDeps], employee_id: str) -> str:
42 """Look up an employee's current access level by employee ID."""
43 record = ctx.deps.store.records.get(employee_id)
44 if not record:
45 return f"No record found for {employee_id}"
46 return f"{record['name']}: role={record['role']}, access={record['access']}"
47
48
49@agent.tool(requires_approval=True)
50def grant_admin_access(ctx: RunContext[HelpdeskDeps], employee_id: str, reason: str) -> str:
51 """Grant admin access to an employee. Always requires manager approval before it runs."""
52 try:
53 ctx.deps.store.records[employee_id]["access"] = "admin"
54 except KeyError:
55 return f"No record found for {employee_id}"
56 return f"Granted admin access to {employee_id} ({reason})"
57
58
59def main() -> None:
60 deps = HelpdeskDeps(store=AccessStore())
61 result = agent.run_sync(
62 "Dana (emp-204) needs admin access to deploy the new release.", deps=deps
63 )
64
65 if isinstance(result.output, DeferredToolRequests):
66 approvals = {}
67 for call in result.output.approvals:
68 print(f"\n[APPROVAL NEEDED] {call.tool_name}({call.args})")
69 approved_by_manager = True # swap for a real Slack/ticket check
70 approvals[call.tool_call_id] = (
71 True if approved_by_manager else ToolDenied("No manager sign-off on file")
72 )
73
74 final = agent.run_sync(
75 message_history=result.all_messages(),
76 deps=deps,
77 deferred_tool_results=DeferredToolResults(approvals=approvals),
78 )
79 print(f"\nAgent: {final.output}")
80 else:
81 print(f"\nAgent: {result.output}")
82
83
84if __name__ == "__main__":
85 main()
86

Common mistakes

Why it's a problem: Registering grant_admin_access without requires_approval=True and relying on the system prompt to ask permission

How to avoid: 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.

Why it's a problem: Forgetting output_type=[str, DeferredToolRequests] and being surprised when a deferred call raises instead of returning cleanly

How to avoid: 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.

Why it's a problem: Calling run_sync again after approval without passing message_history from the first result

How to avoid: 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.

Why it's a problem: Approving every pending request the same way, without checking which tool or input it's for

How to avoid: 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.

Why it's a problem: Letting a tool raise an uncaught KeyError or similar instead of returning a plain error string

How to avoid: 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.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a Pydantic AI agent with look_up_access and grant_admin_access tools, the second marked requires_approval=True and resolved via DeferredToolRequests/DeferredToolResults (code below). Add a third tool, revoke_admin_access, with the same requires_approval pattern, and make grant_admin_access refuse to run if the employee already has admin access. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open helpdesk_agent.py and ask: 'Add a reset_password tool with the same RunContext and requires_approval=True pattern as grant_admin_access, and update the instructions to mention when to use it.'

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.