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

How to Build an AI Agent with Python

Pydantic AI is a Python-native agent framework built around type safety: you define the exact shape of the answer you want as a Pydantic model, and the agent validates its own output against it before returning. This guide builds a travel-itinerary agent with two tools (flight and hotel search) and dependency-injected API clients via RunContext, returning a structured Itinerary object instead of a wall of text. Budget 40-50 minutes and a few cents per run on Claude Sonnet 5.5.

What you'll learn

  • Build a type-safe agent with Pydantic AI's Agent class and a structured output_type instead of parsing free text
  • Define tools with @agent.tool and RunContext so dependencies like API clients are injected, not hardcoded
  • Get the agent's final answer back as a validated Pydantic object your code can use directly
  • Know the Pythonic idioms, dataclasses, type hints, dependency injection, that separate this from a framework-agnostic build
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate9 min read40-50 minPython 3.10+, pydantic-ai 2.x, Claude or OpenAI API key, any OSLast updated November 2026RapidDev Engineering Team
TL;DR

Pydantic AI is a Python-native agent framework built around type safety: you define the exact shape of the answer you want as a Pydantic model, and the agent validates its own output against it before returning. This guide builds a travel-itinerary agent with two tools (flight and hotel search) and dependency-injected API clients via RunContext, returning a structured Itinerary object instead of a wall of text. Budget 40-50 minutes and a few cents per run on Claude Sonnet 5.5.

Quick facts about this guide
FactValue
ToolPython
DifficultyIntermediate
Time required40-50 min
CompatibilityPython 3.10+, pydantic-ai 2.x, Claude or OpenAI API key, any OS
Last updatedNovember 2026

What you'll build

You'll build a travel-itinerary agent: tell it a destination, origin, and number of days, and it searches flights and hotels, then returns a day-by-day plan as a validated Python object, not a paragraph you have to parse yourself. Ask for Itinerary.days[1].estimated_cost_usd right after the call returns; there's no regex or JSON.loads involved.

That's the specific thing Pydantic AI buys you over a framework-agnostic approach: output_type takes a Pydantic model, and the agent keeps retrying internally until its answer actually validates against it. Tools work the same way in reverse. Every @agent.tool function takes a RunContext[Deps] as its first argument, and ctx.deps carries whatever dependencies you injected at call time (an API client, a database connection, an API key), so a tool never reaches into a global variable or a closure to get what it needs.

Pick Pydantic AI when you're building in Python specifically and want the agent's output typed and validated like the rest of your codebase. Pick LangChain's create_agent (see the pillar guide) when you want the widest ecosystem of middleware and examples. Pick the from-scratch approach when you need to see every request. All three run the same kind of tool-calling loop underneath; this guide is about what changes when Python's own type system does some of the work for you.

Prerequisites

  • Python 3.10+ and a terminal
  • An Anthropic or OpenAI API key with available credits
  • pip install pydantic-ai
  • Comfort with dataclasses, type hints, and Pydantic models

Step-by-step guide

1

Install Pydantic AI and set your API key

Install pydantic-ai, which pulls in Pydantic itself as a dependency. Export your model provider's API key as an environment variable; Pydantic AI reads it the same way the underlying SDK would. The model string format is 'provider:model-id', so Claude Sonnet is 'anthropic:claude-sonnet-5-5'.

typescript
1pip install pydantic-ai
2export ANTHROPIC_API_KEY="sk-ant-..."

Expected result: python -c "import pydantic_ai" runs with no import errors.

2

Define the structured output you want back

Before writing any tool, define what a correct answer looks like as Pydantic models: DayPlan (a day number, a list of activities, and an estimated cost) and Itinerary (a destination, a list of DayPlan, and a total cost). This is the contract the agent's final answer must satisfy, decided before you write a single line of agent logic.

typescript
1from pydantic import BaseModel
2
3class DayPlan(BaseModel):
4 day: int
5 activities: list[str]
6 estimated_cost_usd: float
7
8class Itinerary(BaseModel):
9 destination: str
10 days: list[DayPlan]
11 total_cost_usd: float

Expected result: Two plain Pydantic models you could validate against sample data by hand, with no agent involved yet.

3

Define a Deps dataclass for dependency injection

Deps holds whatever a tool needs to do real work, here a placeholder for an API client, in production a real flights/hotels API wrapper. You'll pass an instance of it into run_sync() later, and every tool's RunContext carries it through ctx.deps. This is the idiomatic alternative to a tool reaching for a module-level client or an API key baked into a closure.

typescript
1from dataclasses import dataclass
2
3@dataclass
4class Deps:
5 travel_api_client: object # swap for a real flights/hotels API client

Expected result: A dataclass with no logic of its own, just a typed container for what tools will need.

4

Define tools with @agent.tool and RunContext

Each tool takes ctx: RunContext[Deps] as its first parameter, which doesn't appear in the schema sent to the model, then the parameters the model actually fills in. search_flights and search_hotels both read ctx.deps.travel_api_client instead of calling a global. The docstring is still the only thing the model reads to decide when to call each one.

typescript
1from pydantic_ai import RunContext
2
3@agent.tool
4def search_flights(ctx: RunContext[Deps], origin: str, destination: str, date: str) -> str:
5 """Search for flights between two cities on a given date. Returns price and duration."""
6 return f"{origin} -> {destination} on {date}: $420, 6h10m nonstop"
7
8@agent.tool
9def search_hotels(ctx: RunContext[Deps], destination: str, nights: int) -> str:
10 """Search for hotels in a destination for a number of nights. Returns name and total price."""
11 return f"Riverside Inn, {destination}: ${120 * nights} for {nights} nights"

Expected result: Two tool functions whose first parameter is never part of what the model sees or fills in.

5

Create the Agent with deps_type, output_type, and a system prompt

Agent() takes the model string, deps_type (the Deps class from step 3), output_type (the Itinerary model from step 2), and a system_prompt. deps_type and output_type are what make this agent type-safe end to end: your IDE and your test suite both know exactly what a call returns.

typescript
1from pydantic_ai import Agent
2
3agent = Agent(
4 "anthropic:claude-sonnet-5-5",
5 deps_type=Deps,
6 output_type=Itinerary,
7 system_prompt=(
8 "You are a travel planner. Always search flights and hotels before "
9 "building a day-by-day plan with realistic costs."
10 ),
11)

Expected result: An agent object whose .run_sync() calls will only ever return an Itinerary, or raise a validation error you can catch.

6

Run it, read the typed result, and harden it

Call run_sync() with the user's request and a Deps instance. result.output is an Itinerary object, not a string, access result.output.total_cost_usd directly. For production, wrap the call in a try/except for pydantic_ai's result-validation errors (the model gave an answer that doesn't fit your schema after retries) and never call run_sync() from code that's already inside an async event loop, use the async agent.run() there instead.

typescript
1deps = Deps(travel_api_client=None) # swap for a real client
2result = agent.run_sync(
3 "Plan a 3-day trip to Lisbon leaving from Boston on 2026-12-10.",
4 deps=deps,
5)
6print(result.output.destination, result.output.total_cost_usd)
7for day in result.output.days:
8 print(day.day, day.activities, day.estimated_cost_usd)

Expected result: A printed, fully-typed Itinerary with per-day activities and costs, validated before your code ever touches it.

Complete working example

travel_agent.py
1"""Travel-itinerary agent built with Pydantic AI: typed tools, typed output.
2Run: python travel_agent.py (pip install pydantic-ai; set ANTHROPIC_API_KEY)
3"""
4from dataclasses import dataclass
5
6from pydantic import BaseModel
7from pydantic_ai import Agent, RunContext
8
9
10class DayPlan(BaseModel):
11 day: int
12 activities: list[str]
13 estimated_cost_usd: float
14
15
16class Itinerary(BaseModel):
17 destination: str
18 days: list[DayPlan]
19 total_cost_usd: float
20
21
22@dataclass
23class Deps:
24 travel_api_client: object # swap for a real flights/hotels API client
25
26
27agent = Agent(
28 "anthropic:claude-sonnet-5-5",
29 deps_type=Deps,
30 output_type=Itinerary,
31 system_prompt=(
32 "You are a travel planner. Always search flights and hotels before "
33 "building a day-by-day plan with realistic costs."
34 ),
35)
36
37
38@agent.tool
39def search_flights(ctx: RunContext[Deps], origin: str, destination: str, date: str) -> str:
40 """Search for flights between two cities on a given date. Returns price and duration."""
41 # ctx.deps.travel_api_client would make the real call in production
42 return f"{origin} -> {destination} on {date}: $420, 6h10m nonstop"
43
44
45@agent.tool
46def search_hotels(ctx: RunContext[Deps], destination: str, nights: int) -> str:
47 """Search for hotels in a destination for a number of nights. Returns name and total price."""
48 return f"Riverside Inn, {destination}: ${120 * nights} for {nights} nights"
49
50
51def main():
52 deps = Deps(travel_api_client=None)
53 result = agent.run_sync(
54 "Plan a 3-day trip to Lisbon leaving from Boston on 2026-12-10.",
55 deps=deps,
56 )
57 itinerary = result.output
58 print(f"{itinerary.destination}: ${itinerary.total_cost_usd:.2f} total")
59 for day in itinerary.days:
60 print(f" Day {day.day}: {', '.join(day.activities)} (${day.estimated_cost_usd:.2f})")
61
62
63if __name__ == "__main__":
64 main()
65

Common mistakes

Why it's a problem: Setting output_type=str and regex-parsing a structured answer out of free text

How to avoid: Define a Pydantic model for the shape you actually want and pass it as output_type. Pydantic AI retries internally until the model's answer validates against it.

Why it's a problem: Reading an API client or key from a global variable inside a tool function

How to avoid: Inject it through deps_type and read it from ctx.deps inside the tool. It's testable (swap in a fake client) and makes every dependency explicit.

Why it's a problem: Forgetting ctx: RunContext[Deps] as a tool's first parameter

How to avoid: Without it, the tool has no typed way to reach deps, and Pydantic AI won't know to exclude that parameter from the schema it sends to the model.

Why it's a problem: Calling run_sync() from code that's already inside an async event loop (a web server handler, for example)

How to avoid: run_sync() can't run inside a running loop. Use the async agent.run() there instead and await it.

Why it's a problem: Reading result.data instead of result.output

How to avoid: Pydantic AI 2.x renamed the result attribute to .output. Code copied from an older tutorial that uses .data will raise an AttributeError.

Best practices

  • Model every agent's final answer as a Pydantic class and pass it as output_type, rather than parsing text after the fact.
  • Inject dependencies through deps_type and RunContext instead of module-level clients or closures.
  • Keep tool docstrings as the single source of truth the model reads; don't duplicate that explanation in a separate prompt.
  • Use the async agent.run() inside any code that's already async; reserve run_sync() for scripts and notebooks.
  • Pin pydantic-ai to a known 2.x version; the result attribute and a few APIs changed between 1.x and 2.x.
  • Write a quick test that calls a tool function directly with a fake Deps, no model involved, before testing the full agent.

Still stuck?

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

ChatGPT Prompt

I have a Pydantic AI travel agent with search_flights and search_hotels tools, typed Deps injection, and an Itinerary output_type (code below). Add a third tool, check_visa_requirements, and make the system prompt call it before building the itinerary for international trips. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open travel_agent.py and ask: 'Add a retry policy for ModelRetry inside search_flights when the mock API returns no results, and a unit test that calls search_flights directly with a fake Deps.'

Frequently asked questions

Why use Python specifically to build an AI agent?

Python has the deepest ecosystem of agent frameworks (LangChain, Pydantic AI, Google ADK, CrewAI) and the most examples to learn from. Pydantic AI in particular leans into Python's type system, so IDEs and test suites can check an agent's inputs and outputs the same way they check any other function.

What does Pydantic AI add over just calling the Claude or OpenAI API directly?

Automatic tool-schema generation from type hints, a typed dependency-injection system (RunContext and deps_type), and an output_type that the agent's final answer is validated and retried against, instead of you parsing free text yourself.

What's the difference between output_type and just asking the model to return JSON?

Asking for JSON in the prompt is a suggestion the model can ignore or get slightly wrong. output_type is enforced: Pydantic AI validates the response against your model and retries internally if it doesn't fit, so your code only ever sees a valid object.

Can I use Pydantic AI with models other than Claude?

Yes. The model string takes a provider prefix (anthropic:, openai:, and others), so swapping providers is a one-line change as long as the tool-calling behavior you depend on is supported by that model.

How do I give a Pydantic AI agent memory across multiple calls?

Pydantic AI itself handles a single run; for multi-turn memory, pass the previous run's messages (result.all_messages()) into the next run_sync() or agent.run() call, or pair it with a durable-execution integration like Temporal for longer-running conversations.

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

Yes. RapidDev builds production Python agents on Pydantic AI, LangChain, and the Claude Agent SDK, including the dependency injection and validation patterns this guide introduces. A free 30-minute consultation is the fastest way to scope your version.

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.