# How to Build an AI Agent with Python

- Tool: Python
- Difficulty: Intermediate
- Time required: 40-50 min
- Compatibility: Python 3.10+, pydantic-ai 2.x, Claude or OpenAI API key, any OS
- Last updated: November 2026

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

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

## Before you start

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

```
pip install pydantic-ai
export 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.

```
from pydantic import BaseModel

class DayPlan(BaseModel):
    day: int
    activities: list[str]
    estimated_cost_usd: float

class Itinerary(BaseModel):
    destination: str
    days: list[DayPlan]
    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.

```
from dataclasses import dataclass

@dataclass
class Deps:
    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.

```
from pydantic_ai import RunContext

@agent.tool
def search_flights(ctx: RunContext[Deps], origin: str, destination: str, date: str) -> str:
    """Search for flights between two cities on a given date. Returns price and duration."""
    return f"{origin} -> {destination} on {date}: $420, 6h10m nonstop"

@agent.tool
def search_hotels(ctx: RunContext[Deps], destination: str, nights: int) -> str:
    """Search for hotels in a destination for a number of nights. Returns name and total price."""
    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.

```
from pydantic_ai import Agent

agent = Agent(
    "anthropic:claude-sonnet-5-5",
    deps_type=Deps,
    output_type=Itinerary,
    system_prompt=(
        "You are a travel planner. Always search flights and hotels before "
        "building a day-by-day plan with realistic costs."
    ),
)
```

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

```
deps = Deps(travel_api_client=None)  # swap for a real client
result = agent.run_sync(
    "Plan a 3-day trip to Lisbon leaving from Boston on 2026-12-10.",
    deps=deps,
)
print(result.output.destination, result.output.total_cost_usd)
for day in result.output.days:
    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 code example

File: `travel_agent.py`

```python
"""Travel-itinerary agent built with Pydantic AI: typed tools, typed output.
Run: python travel_agent.py (pip install pydantic-ai; set ANTHROPIC_API_KEY)
"""
from dataclasses import dataclass

from pydantic import BaseModel
from pydantic_ai import Agent, RunContext


class DayPlan(BaseModel):
    day: int
    activities: list[str]
    estimated_cost_usd: float


class Itinerary(BaseModel):
    destination: str
    days: list[DayPlan]
    total_cost_usd: float


@dataclass
class Deps:
    travel_api_client: object  # swap for a real flights/hotels API client


agent = Agent(
    "anthropic:claude-sonnet-5-5",
    deps_type=Deps,
    output_type=Itinerary,
    system_prompt=(
        "You are a travel planner. Always search flights and hotels before "
        "building a day-by-day plan with realistic costs."
    ),
)


@agent.tool
def search_flights(ctx: RunContext[Deps], origin: str, destination: str, date: str) -> str:
    """Search for flights between two cities on a given date. Returns price and duration."""
    # ctx.deps.travel_api_client would make the real call in production
    return f"{origin} -> {destination} on {date}: $420, 6h10m nonstop"


@agent.tool
def search_hotels(ctx: RunContext[Deps], destination: str, nights: int) -> str:
    """Search for hotels in a destination for a number of nights. Returns name and total price."""
    return f"Riverside Inn, {destination}: ${120 * nights} for {nights} nights"


def main():
    deps = Deps(travel_api_client=None)
    result = agent.run_sync(
        "Plan a 3-day trip to Lisbon leaving from Boston on 2026-12-10.",
        deps=deps,
    )
    itinerary = result.output
    print(f"{itinerary.destination}: ${itinerary.total_cost_usd:.2f} total")
    for day in itinerary.days:
        print(f"  Day {day.day}: {', '.join(day.activities)} (${day.estimated_cost_usd:.2f})")


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

## Common mistakes

- **Setting output_type=str and regex-parsing a structured answer out of free text** — undefined Fix: 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.
- **Reading an API client or key from a global variable inside a tool function** — undefined Fix: 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.
- **Forgetting ctx: RunContext[Deps] as a tool's first parameter** — undefined Fix: 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.
- **Calling run_sync() from code that's already inside an async event loop (a web server handler, for example)** — undefined Fix: run_sync() can't run inside a running loop. Use the async agent.run() there instead and await it.
- **Reading result.data instead of result.output** — undefined Fix: 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.

## Use cases

### undefined



### undefined



### undefined



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

---

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