Pydantic AI takes the developer experience that made FastAPI a standard and applies it to agent development: typed tool definitions, dependency injection, structured output, and a model-agnostic interface that swaps providers in one line. It has crossed 16k GitHub stars without making a lot of noise — because developers who try it tend to keep using it.
Pair it with Anchor's managed cloud browsers and you get a browser agent where every tool input, every tool output, and every agent response is fully typed and validated by Pydantic — not just the LLM call. No silent failures from malformed selectors, no guessing what the model returned. If it compiles and the types pass, it runs.
Why Pydantic AI?
- Dependency injection for tools. Browser sessions, HTTP clients, and database handles are passed into tools via a typed
RunContext[Deps]— not global state. Tests swap in a mock browser in one line. - Structured output as a first-class feature. Define a Pydantic model as
output_typeand the agent returns a validated Python object. No more parsing free text. - Model-agnostic. Switch between OpenAI, Anthropic, and Gemini by changing one import — your tools don't need to change.
- FastAPI familiarity. If you've written a FastAPI route, Pydantic AI's
@agent.tooldecorator and dependency system will feel instantly natural.
Setup
pip install pydantic-ai anchorpy playwright pydantic
playwright install chromium
export ANCHOR_API_KEY="your_anchor_key"
export OPENAI_API_KEY="your_openai_key"
Defining the Dependency Container
In Pydantic AI, tools don't reach for globals. Instead, you define a typed Deps dataclass that holds external resources, and the framework injects it into every tool call. For a browser agent, that resource is the Playwright page:
from __future__ import annotations
import os
from dataclasses import dataclass
from playwright.async_api import Page
@dataclass
class BrowserDeps:
page: Page
anchor_session_id: str
Defining Structured Output
Instead of asking the agent to "return a summary", give it a Pydantic model to fill in. The framework validates the LLM's response against the schema before your code ever sees it:
from pydantic import BaseModel, Field
class PageSummary(BaseModel):
title: str = Field(description="The page's <title> text")
main_topic: str = Field(description="One sentence describing what this page is about")
key_points: list[str] = Field(description="Up to 5 bullet-point findings", max_length=5)
current_url: str = Field(description="The URL after any redirects")
Building the Agent and Tools
Register browser actions as tools using the @agent.tool decorator. The first argument is always RunContext[BrowserDeps] — that's how tools access the injected Playwright page:
from pydantic_ai import Agent, RunContext
agent: Agent[BrowserDeps, PageSummary] = Agent(
"openai:gpt-4o",
output_type=PageSummary,
system_prompt=(
"You are a reliable browser research agent. "
"Navigate to the requested URL, read the page carefully, "
"and return a structured summary of what you find."
),
)
@agent.tool
async def navigate(ctx: RunContext[BrowserDeps], url: str) -> str:
# Navigate to a URL and return the page title.
await ctx.deps.page.goto(url, wait_until="domcontentloaded")
return await ctx.deps.page.title()
@agent.tool
async def get_text(ctx: RunContext[BrowserDeps], selector: str = "body") -> str:
# Extract visible text from a CSS selector on the current page.
text = await ctx.deps.page.inner_text(selector)
return text[:6000]
@agent.tool
async def get_current_url(ctx: RunContext[BrowserDeps]) -> str:
# Return the current URL of the browser (after any redirects).
return ctx.deps.page.url
@agent.tool
async def click(ctx: RunContext[BrowserDeps], selector: str) -> str:
# Click an element matching the CSS selector.
await ctx.deps.page.click(selector)
await ctx.deps.page.wait_for_load_state("networkidle", timeout=5000)
return f"Clicked {selector!r}. Now at: {ctx.deps.page.url}"
Running the Agent with an Anchor Session
Create the Anchor session once and inject the Playwright page through BrowserDeps. Pydantic AI handles the agent loop; your code stays clean:
import asyncio
from anchorpy import AnchorClient
from playwright.async_api import async_playwright
anchor = AnchorClient(api_key=os.environ["ANCHOR_API_KEY"])
async def run_browser_agent(goal: str) -> PageSummary:
session = await anchor.sessions.create(
session={"maxDuration": 120, "idleTimeout": 30}
)
try:
async with async_playwright() as pw:
browser = await pw.chromium.connect_over_cdp(session.data.cdp_url)
page = browser.contexts[0].pages[0]
deps = BrowserDeps(page=page, anchor_session_id=session.id)
result = await agent.run(goal, deps=deps)
await browser.close()
return result.output # Fully validated PageSummary instance
finally:
await anchor.sessions.terminate(session.id)
async def main():
summary = await run_browser_agent(
"Go to https://news.ycombinator.com and summarise the top stories"
)
print(f"Title: {summary.title}")
print(f"Topic: {summary.main_topic}")
for point in summary.key_points:
print(f" • {point}")
print(f"URL: {summary.current_url}")
asyncio.run(main())
Swapping the Model
Pydantic AI's model-agnostic interface means you change the model string and nothing else. Your tools, your BrowserDeps, and your PageSummary output type are untouched:
from pydantic_ai.models.anthropic import AnthropicModel
# Switch to Claude — one line, zero refactoring
agent_claude: Agent[BrowserDeps, PageSummary] = Agent(
AnthropicModel("claude-sonnet-4-6"),
output_type=PageSummary,
system_prompt=agent.system_prompt,
)
Testing Without a Real Browser
Dependency injection pays off in tests. Swap the real page for a MagicMock — no Anchor session, no network, no Playwright install required in CI:
import pytest
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_navigate_tool():
mock_page = MagicMock()
mock_page.goto = AsyncMock(return_value=None)
mock_page.title = AsyncMock(return_value="Hacker News")
deps = BrowserDeps(page=mock_page, anchor_session_id="test-session")
# Call the unwrapped tool function directly
result = await navigate.function(MagicMock(deps=deps), "https://news.ycombinator.com")
assert result == "Hacker News"
mock_page.goto.assert_called_once()
Production Tips
- Use
output_typeeverywhere. Even for simple tasks like "return the price", define a one-field model. You get validation, IDE autocomplete, and a clear contract — for free. - Scope sessions to runs. Create a fresh Anchor session per
agent.run()call in production. Pydantic AI'sdepsparameter makes this natural — no global state, no session bleed between concurrent jobs. - Retry with
retries. Passretries=3toAgent()and Pydantic AI will automatically retry failed tool calls — useful when the LLM hits an ambiguous page and needs to look again. - Stream long tasks. For pages that take time to load, use
agent.run_stream()to get intermediate tool outputs as they arrive — Anchor's session lifecycle is unchanged, but your UI can show progress.
What's Next
Try Anchor free and run your first Pydantic AI browser agent in minutes →



