AI

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.

Scalable FastAPI Applications on AWS

Learn how to build FastAPI apps on scale

Take Course

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.

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:

  • httpx client, which we use inside the get_company_info tool to fetch the company's website content.
  • store, which we use for caching of website content inside get_company_info.
  • services_path, which is the path to the services file used by the get_my_services tool

At the end, we registered two new tools for cold_email_agent:

  • get_my_services
  • get_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

Become a better engineer, one article at a time.

Practices, mindsets, and habits that actually move the needle. Delivered weekly to your inbox.

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!

Share