Pydantic AI + Anchor: Type-Safe Browser Agents in Python

Hands On
Jun 16
by Idan Raman
Pydantic AI + Anchor: Type-Safe Browser Agents in Python

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_type and 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.tool decorator 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_type everywhere. 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's deps parameter makes this natural — no global state, no session bleed between concurrent jobs.
  • Retry with retries. Pass retries=3 to Agent() 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 →

Stay ahead in browser automation

We respect your inbox. Privacy policy

Welcome aboard! Thanks for signing up
Oops! Something went wrong while submitting the form.