Build your first AI agent with Pydantic AI
May 27, 2026
Everyone is talking about AI agents nowadays. Some are building agents to analyze Excel data, others to auto-reply to emails. But how can one actually build an AI agent? That's what we'll look into in this post. We'll take a look at how to build an AI agent to write a cold email offering our Python consulting services, based on the target company's webpage. Our requirements are the following:
- User provides a company domain
- The app researches the company by reading its website
- The app generates a personalized cold email offering Python consulting services based on the user's description of services
- Company data is cached, so repeated lookups are fast and don't hit the website again and again
You can find the complete source code on GitHub.
So let's start building.
Create a new project
There are plenty of tools available out there. In this post, we'll be using Pydantic AI. It's a "Type-safe Python framework for building agents and LLM applications". If you've ever used FastAPI, you're familiar with Pydantic Validation. Pydantic AI uses all the good parts of it while adding support for building agents with various LLM providers.
So let's start by creating a project and installing the libraries:
$ uv init cold-email-agent
$ cd cold-email-agent
$ uv add pydantic-ai httpx beautifulsoup4
$ uv add --dev pytest pytest-asyncio
As mentioned, we've installed Pydantic AI. We've also added httpx to easily execute HTTP calls and beautifulsoup4 to easily parse content from the company's website. At that point, your project should look like this:
cold-email-agent/
├── .python-version
├── README.md
├── main.py
├── pyproject.toml
└── uv.lock
The next thing is to create our AI agent.
Become a better engineer, one article at a time.
Practices, mindsets, and habits that actually move the needle. Delivered weekly to your inbox.
AI agent with Pydantic AI
You can think of an AI agent as the brain of an application. In most cases, this is an LLM that decides what to do and how to do it based on available context and provided tools. The most barebones agent can simply be an LLM API wrapper to which a prompt is sent, and it responds with an answer.
First, create a new package cold_email_agent (create the directory with an __init__.py file) and add agent.py to it.
We can define our agent like that:
# cold_email_agent/agent.py
from pydantic_ai import Agent
cold_email_agent = Agent("groq:llama-3.3-70b-versatile")
All we need to do is to initialize the Agent class with the name of the model.
We're using Llama 3.3 70B, an open-source model hosted on Groq's fast inference infrastructure.
It can be used for free for a limited number of requests and tokens per month.
Structured output
As you might know, LLMs are simply returning unstructured text.
This is good enough for applications like chatbots, but it would be cool to get more structured output that we could use further in our application.
One great feature provided by PydanticAI is structured output.
This allows us to simply define a Pydantic model for the shape of the answer that we want to receive from the agent.
Pydantic AI ensures that the LLM returns the data in the expected format, parses it, and produces an instance of our model.
In our case, this can be ColdEmail with subject, body, and call to action.
So let's add that to our agent:
# cold_email_agent/models.py
from pydantic import BaseModel
class ColdEmail(BaseModel):
subject: str
body: str
call_to_action: str
# cold_email_agent/agent.py
from pydantic_ai import Agent
from cold_email_agent.models import ColdEmail
cold_email_agent = Agent(
"groq:llama-3.3-70b-versatile",
output_type=ColdEmail,
)
Now our AI agent will always return a result as an instance of the ColdEmail model.
Dependencies
Pydantic AI is built with testability in mind. That's why it provides dependency injection out of the box. It looks very similar to the one in FastAPI. All dependencies that our agent needs can be defined inside a dataclass that we provide to the agent. For now, we know that we'll need to capture the company's domain and provide it to the agent. So let's do that:
# cold_email_agent/agent.py
from dataclasses import dataclass
from pydantic_ai import Agent
from cold_email_agent.models import ColdEmail
@dataclass
class Deps:
domain: str
cold_email_agent = Agent(
"groq:llama-3.3-70b-versatile",
output_type=ColdEmail,
deps_type=Deps,
)
We've defined Deps, which we provide to the AI agent to indicate the shape of our dependencies.
System prompt
Now that we know how to provide the dependencies for our agent, we can proceed to use them.
First, we need to tell our agent who it is, how to behave, and what we want it to do.
We can do that with a system prompt.
We can use domain from the injected dependency to make the prompt dynamic.
Adding a system prompt with Pydantic AI is as easy as registering a function with a decorator:
# cold_email_agent/agent.py
# ... existing code
@cold_email_agent.system_prompt
async def system_prompt(ctx: RunContext[Deps]) -> str:
return (
f"You are a cold email writer for a Python consulting company. "
f"Your task is to write a personalized cold email to {ctx.deps.domain}. "
f"Use the tools to learn about the target company and about our services. "
f"The email should be concise, professional, and highlight how our services "
f"can specifically help them based on what you learn about their business. "
f"Keep the email under 200 words."
)
As you can see, we provide context via Deps and we can access its attributes via ctx.deps.
This allows us to easily inject the domain to system prompt.
Tools
At that point, we have an agent that knows what it is and what it should do. But we haven't provided any tools that it could use to satisfy our request. AI agent tools are functions that we define inside our code and provide to the AI agent to help it satisfy our requests. The agent knows about them, but it doesn't have to use all of them all the time. You can think of them as different tools inside the toolbox stored in a shed. For example, inside your toolbox, there can be a screwdriver, a wrench, a hammer, and a saw. To cut the branch on the tree, you'll use a saw. To tighten a screw, you'll use a screwdriver or a wrench, depending on the screw's type. It's very similar to AI agent tools. It decides which ones to use based on the context.
To satisfy our requirements, we need to build the following tools:
- Get company information
- Get my services
Registering tools with Pydantic AI is fairly simple.
Similar to system prompt, we just need to implement functions decorated with @cold_email_agent.tool decorator.
So let's implement the following things:
- store for caching content from company websites
- a tool for fetching content from the company's website and adding it to the cache
- a tool for getting details of the services that we provide
First, let's implement providers for time so that we can control time in tests:
# cold_email_agent/providers.py
from datetime import datetime
from typing import Protocol
class TimeProvider(Protocol):
def now(self) -> datetime: ...
class SystemTimeProvider:
def now(self) -> datetime:
return datetime.now()
class FakeTimeProvider:
def __init__(self, fixed_time: datetime):
self._fixed_time = fixed_time
def now(self) -> datetime:
return self._fixed_time
Next, let's implement the store and its tests:
# cold_email_agent/store.py
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Protocol
from cold_email_agent.providers import TimeProvider
MAX_AGE = timedelta(days=30)
class CompanyPageStore(Protocol):
def get(self, domain: str) -> str | None: ...
def save(self, domain: str, content: str) -> None: ...
class SQLiteCompanyPageStore:
def __init__(self, db_path: Path, time_provider: TimeProvider) -> None:
self._db_path = db_path
self._time_provider = time_provider
conn = sqlite3.connect(self._db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS company_pages (
domain TEXT PRIMARY KEY,
content TEXT NOT NULL,
fetched_at TEXT NOT NULL
)
"""
)
conn.commit()
conn.close()
def get(self, domain: str) -> str | None:
conn = sqlite3.connect(self._db_path)
row = conn.execute(
"SELECT content, fetched_at FROM company_pages WHERE domain = ?",
(domain,),
).fetchone()
conn.close()
if row is None:
return None
fetched_at = datetime.fromisoformat(row[1])
if self._time_provider.now() - fetched_at > MAX_AGE:
return None
return row[0]
def save(self, domain: str, content: str) -> None:
now = self._time_provider.now().isoformat()
conn = sqlite3.connect(self._db_path)
conn.execute(
"""
INSERT INTO company_pages (domain, content, fetched_at)
VALUES (?, ?, ?)
ON CONFLICT(domain) DO UPDATE SET content = ?, fetched_at = ?
""",
(domain, content, now, content, now),
)
conn.commit()
conn.close()
class InMemoryCompanyPageStore:
def __init__(self, time_provider: TimeProvider) -> None:
self._time_provider = time_provider
self._pages: dict[str, tuple[str, str]] = {}
def get(self, domain: str) -> str | None:
if domain not in self._pages:
return None
content, fetched_at_str = self._pages[domain]
fetched_at = datetime.fromisoformat(fetched_at_str)
if self._time_provider.now() - fetched_at > MAX_AGE:
return None
return content
def save(self, domain: str, content: str) -> None:
self._pages[domain] = (content, self._time_provider.now().isoformat())
# tests/test_store.py
from datetime import datetime, timedelta
import pytest
from cold_email_agent.providers import FakeTimeProvider
from cold_email_agent.store import InMemoryCompanyPageStore, SQLiteCompanyPageStore
FIXED_TIME = datetime(2026, 1, 15, 12, 0, 0)
class CompanyPageStoreContract:
@pytest.fixture
def store(self):
raise NotImplementedError
def test_saved_page_can_be_retrieved(self, store):
store.save("example.com", "Example content")
assert store.get("example.com") == "Example content"
def test_unknown_domain_returns_none(self, store):
assert store.get("unknown.com") is None
def test_expired_entry_returns_none(self, store):
store.save("old.com", "Old content")
store._time_provider._fixed_time = FIXED_TIME + timedelta(days=31)
assert store.get("old.com") is None
def test_fresh_entry_is_returned(self, store):
store.save("fresh.com", "Fresh content")
store._time_provider._fixed_time = FIXED_TIME + timedelta(days=29)
assert store.get("fresh.com") == "Fresh content"
def test_save_updates_existing(self, store):
store.save("example.com", "Version 1")
store.save("example.com", "Version 2")
assert store.get("example.com") == "Version 2"
class TestInMemoryCompanyPageStore(CompanyPageStoreContract):
@pytest.fixture
def store(self):
return InMemoryCompanyPageStore(time_provider=FakeTimeProvider(FIXED_TIME))
class TestSQLiteCompanyPageStore(CompanyPageStoreContract):
@pytest.fixture
def store(self, tmp_path):
return SQLiteCompanyPageStore(
db_path=tmp_path / "test.db",
time_provider=FakeTimeProvider(FIXED_TIME),
)
Here we used contract tests and providers to implement SQLite and an in-memory data store. We can use the in-memory one for tests to speed them up. For SQLite specifically, we could use an in-memory database, but that's not possible for every database. Therefore, I simply used the pattern I always use. You can find a deep dive into that topic inside my Complete Python Testing Guide course.
Second, we can add a description of services to services.txt:
Company: Giacosoft
Website: giacosoft.com
About:
Giacosoft helps startups build and scale their products with Python and AWS.
We understand tight deadlines, lean operations, and the need to move fast.
Services:
1. Web Development
We build custom web applications using Python (Django, FastAPI) and AWS.
Our solutions are scalable, secure, and maintainable. We support you from
rapid prototyping through production deployment and ongoing maintenance.
2. Consulting
We help teams improve code quality, implement automated testing, set up
CI/CD pipelines, fix security vulnerabilities, and optimize cloud
infrastructure for cost and performance.
3. Training
Customized training programs covering Python development (Django, FastAPI,
Celery), AWS cloud computing, DevOps practices, and security/compliance.
Key Technologies:
- Python (FastAPI, Django, Celery)
- AWS (infrastructure, optimization, cost-efficiency)
- Automated testing and CI/CD
- DevOps and deployment automation
Why Giacosoft:
- Deep Python expertise
- AWS mastery
- Startup mindset — we move fast and stay agile
- Clear, transparent communication
We help you reduce time to market, minimize development costs, and focus on
your business goals while we handle the technical implementation.
Third, we can extend dependency Deps and register two new tools:
# cold_email_agent/agent.py
from dataclasses import dataclass
from pathlib import Path
import httpx
from bs4 import BeautifulSoup
from pydantic_ai import Agent, RunContext
from cold_email_agent.models import ColdEmail
from cold_email_agent.store import CompanyPageStore
@dataclass
class Deps:
client: httpx.AsyncClient # NEW
domain: str
store: CompanyPageStore # NEW
services_path: Path # NEW
cold_email_agent = Agent(
"groq:llama-3.3-70b-versatile",
output_type=ColdEmail,
deps_type=Deps,
)
@cold_email_agent.system_prompt
async def system_prompt(ctx: RunContext[Deps]) -> str:
return (
f"You are a cold email writer for a Python consulting company. "
f"Your task is to write a personalized cold email to {ctx.deps.domain}. "
f"Use the tools to learn about the target company and about our services. "
f"The email should be concise, professional, and highlight how our services "
f"can specifically help them based on what you learn about their business. "
f"Keep the email under 200 words."
)
# NEW
@cold_email_agent.tool
async def get_company_info(ctx: RunContext[Deps]) -> str:
"""Fetch and return information about the target company from their website."""
domain = ctx.deps.domain
cached = ctx.deps.store.get(domain)
if cached is not None:
return cached
url = f"https://{domain}"
response = await ctx.deps.client.get(url, follow_redirects=True)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
text = "\n".join(line for line in text.splitlines() if line.strip())
text = text[:5000]
ctx.deps.store.save(domain, text)
return text
# NEW
@cold_email_agent.tool
async def get_my_services(ctx: RunContext[Deps]) -> str:
"""Read and return information about our consulting services."""
return ctx.deps.services_path.read_text()
We've added the following things to Deps:
httpxclient, which we use inside theget_company_infotool to fetch the company's website content.store, which we use for caching of website content insideget_company_info.services_path, which is the path to the services file used by theget_my_servicestool
At the end, we registered two new tools for cold_email_agent:
get_my_servicesget_company_info
Now the agent can use these two tools to satisfy our requests. It can easily access the company's data and the description of our services, which are then used to prepare cold email content.
IMPORTANT: Accepting domains from user input and making requests to whatever is provided is not safe. It opens you up to SSRF attacks. Therefore, you should apply security measures to prevent it. You can read this post to learn about measures you can apply to prevent SSRF attacks. Since we're the only users in this tutorial, it's not a concern here.
Testing AI agent
We have everything we need — except tests. So let's add them:
# tests/test_agent.py
from datetime import datetime
from pathlib import Path
import httpx
import pytest
from pydantic_ai.models.test import TestModel
from cold_email_agent.agent import cold_email_agent, Deps
from cold_email_agent.models import ColdEmail
from cold_email_agent.providers import FakeTimeProvider
from cold_email_agent.store import InMemoryCompanyPageStore
SERVICES_PATH = Path(__file__).parent.parent / "services.txt"
FIXED_TIME = datetime(2026, 1, 15, 12, 0, 0)
FAKE_HTML = """
<html>
<head><title>Acme Corp</title></head>
<body>
<nav>Skip this nav</nav>
<h1>Welcome to Acme Corp</h1>
<p>We build rockets and sell anvils.</p>
<script>var x = 1;</script>
<footer>Skip this footer</footer>
</body>
</html>
"""
@pytest.fixture
def store():
return InMemoryCompanyPageStore(time_provider=FakeTimeProvider(FIXED_TIME))
@pytest.fixture
def create_deps(store):
def _create_deps(
domain: str = "acme.com",
transport: httpx.MockTransport | None = None,
) -> tuple[Deps, httpx.AsyncClient]:
if transport is None:
transport = httpx.MockTransport(
lambda request: httpx.Response(200, text=FAKE_HTML)
)
client = httpx.AsyncClient(transport=transport)
deps = Deps(client=client, domain=domain, store=store, services_path=SERVICES_PATH)
return deps, client
return _create_deps
@pytest.mark.asyncio
async def test_agent_returns_cold_email(create_deps):
deps, client = create_deps()
async with client:
with cold_email_agent.override(model=TestModel()):
result = await cold_email_agent.run(
"Write a cold email to acme.com",
deps=deps,
)
assert isinstance(result.output, ColdEmail)
assert result.output.subject
assert result.output.body
assert result.output.call_to_action
@pytest.mark.asyncio
async def test_company_info_is_cached_after_fetch(create_deps, store):
deps, client = create_deps()
async with client:
with cold_email_agent.override(model=TestModel()):
await cold_email_agent.run(
"Write a cold email to acme.com",
deps=deps,
)
cached = store.get("acme.com")
assert cached is not None
assert "Welcome to Acme Corp" in cached
assert "We build rockets" in cached
@pytest.mark.asyncio
async def test_html_parsing_strips_scripts_and_nav(create_deps, store):
deps, client = create_deps()
async with client:
with cold_email_agent.override(model=TestModel()):
await cold_email_agent.run(
"Write a cold email to acme.com",
deps=deps,
)
cached = store.get("acme.com")
assert "var x = 1" not in cached
assert "Skip this nav" not in cached
assert "Skip this footer" not in cached
@pytest.mark.asyncio
async def test_agent_uses_cache_on_second_run(create_deps):
call_count = 0
def mock_transport(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(200, text=FAKE_HTML)
deps, client = create_deps(transport=httpx.MockTransport(mock_transport))
async with client:
with cold_email_agent.override(model=TestModel()):
await cold_email_agent.run("Write a cold email to acme.com", deps=deps)
await cold_email_agent.run("Write a cold email to acme.com", deps=deps)
assert call_count == 1
Here, we're using factory fixture for creating Deps for tests.
This allows us to provide sane defaults for tests while easily overriding them when needed.
Another thing worth noticing is with cold_email_agent.override(model=TestModel()).
This is a context manager that overrides the LLM model for tests with TestModel().
This way, we can avoid expensive and unpredictable calls to the model API itself.
That makes our tests fast and reliable.
You can read more about properties of high-quality tests here
You can run all tests with uv run pytest tests.
Let's test it also end-to-end - with real API calls. For that, we need an entry point. So let's add main.py to our package:
# cold_email_agent/main.py
import argparse
import asyncio
from pathlib import Path
import httpx
from cold_email_agent.agent import Deps, cold_email_agent
from cold_email_agent.providers import SystemTimeProvider
from cold_email_agent.store import SQLiteCompanyPageStore
DB_PATH = Path(__file__).parent.parent / "cache.db"
SERVICES_PATH = Path(__file__).parent.parent / "services.txt"
async def run(domain: str) -> None:
store = SQLiteCompanyPageStore(
db_path=DB_PATH,
time_provider=SystemTimeProvider(),
)
async with httpx.AsyncClient() as client:
deps = Deps(client=client, domain=domain, store=store, services_path=SERVICES_PATH)
result = await cold_email_agent.run(
f"Write a cold email to {domain}",
deps=deps,
)
email = result.output
print(f"\nSubject: {email.subject}\n")
print(email.body)
print(f"\n{email.call_to_action}")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate a cold email for a company")
parser.add_argument("domain", help="Company domain (e.g. example.com)")
args = parser.parse_args()
asyncio.run(run(args.domain))
if __name__ == "__main__":
main()
Here we initialize all of our dependencies to run our AI agent for real, grab CLI arguments, and then we execute it. To actually execute it, we need to add Groq's API key. You can create one here.
As mentioned above, it's free for limited amount of requests and tokens per month - see the quotas here.
Once you have the API key, set it as environment variable and execute the agent:
$ export GROQ_API_KEY=<api-key>
$ uv run python -m cold_email_agent.main lovable.com
This will produce output like this:
Subject: Personalized Consulting Services for Lovable.com
Dear Lovable.com Team, I came across your company and was impressed with your innovative approach. Our consulting services can help you streamline your operations and improve efficiency. We specialize in Python development and can assist with custom solutions, integrations, and optimizations. I'd love to discuss how our services can support your business goals.
Let's schedule a call to explore further
As you might notice, this isn't a very great cold email. One reason is that we're keeping the company's website scraping pretty minimal. To improve that, we could also follow navigation to visit more pages and gather more relevant context. Another reason is the model quality. We're using a model that's available for free. To improve that, we could switch to a paid model from various providers, as Pydantic AI agents are model agnostic. If you have an OpenAI or Anthropic API key, feel free to swap the model and see the difference.
At this point, your final project structure should look like this:
cold-email-agent/
├── .python-version
├── README.md
├── pyproject.toml
├── uv.lock
├── services.txt
├── cold_email_agent/
│ ├── __init__.py
│ ├── agent.py
│ ├── main.py
│ ├── models.py
│ ├── providers.py
│ └── store.py
└── tests/
├── test_agent.py
└── test_store.py
Conclusion
In this post, we went through the basics of building an AI agent with Pydantic AI. We've covered AI agents, system prompts, structured output, and tools. We've also learned how to write tests for our AI agent. Now it's up to you to use that knowledge to build something awesome.
Happy AI engineering!