Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Build a Lead Qualification Agent (Pydantic AI and LeadOcean)

Three typed tools, a typed result and a record budget guard. About 25 minutes and 8 records on the free plan.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

You build a Pydantic AI agent with three function tools and a typed result. The agent sizes an audience for free, pulls a few people, enriches the best ones and scores each against your ICP. This guide uses the REST API because a script has no browser for sign-in. The MCP route is in Step 5.

TL;DR

  • Time: about 25 minutes, including the install.
  • Cost: the test run spends 8 LeadOcean records (5 people from search, 3 enrichments). Sizing is free. The free plan's 1,000 records cover about 125 such runs.
  • Tools return errors as data. A 402 stops the run, a 429 waits and retries, a 404 reports "not found".

The full code is in this post.

Prerequisites

  • Python 3.10 or newer.
  • A LeadOcean API key from app.leadocean.io, exported as $LEADOCEAN_API_KEY. The free plan has 1,000 records, one-off, no card.
  • An Anthropic key as $ANTHROPIC_API_KEY. Pydantic AI takes any supported model through a provider:model string.
  • Packages: pydantic-ai and httpx.
bash
pip install pydantic-ai httpx
export LEADOCEAN_API_KEY="..."   # from app.leadocean.io
export ANTHROPIC_API_KEY="..."

Step 1: Write one request helper with a record budget

Every tool goes through one function. It sends the x-api-key header and refuses a call that would pass your cap. A runaway loop then costs at most the cap, not the plan.

One person returned is one record. A count, a 404 and a rejected request cost nothing (LeadOcean docs, September 2026). The Free plan allows 5 requests a second per account, so the helper honours Retry-After on a 429.

python
import os, time
from dataclasses import dataclass
import httpx

BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}

@dataclass
class Budget:
    cap: int = 25
    spent: int = 0

def call(budget: Budget, method, path, *, params=None, json=None, cost=0):
    """Returns the body, or {"error": ...} the model can read. Never raises."""
    if budget.spent + cost > budget.cap:
        return {"error": f"STOP: budget cap {budget.cap} reached ({budget.spent} spent)"}
    for attempt in range(3):
        r = httpx.request(method, BASE + path, headers=HEADERS,
                          params=params, json=json, timeout=30)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", "1"))
            if wait > 5:
                return {"error": f"STOP: throttled, retry in {wait}s"}
            time.sleep(wait)
            continue
        if r.status_code == 503:          # transient, never billed
            time.sleep(2 ** attempt)
            continue
        break
    if r.status_code == 402:
        return {"error": "STOP: out of records (402). Tell the user."}
    if r.status_code == 404:
        return {"found": False}
    if r.status_code >= 400:
        return {"error": f"{r.status_code}: {r.text[:200]}"}
    body = r.json()
    budget.spent += (body.get("meta") or {}).get("credits", cost)
    return body

Step 2: Define the typed result and the agent

Pydantic AI validates the final answer against an output_type. A model that returns a score of 9 or no reasoning fails validation and retries. The Budget object travels as deps, so tools reach it without globals (Pydantic AI agents, September 2026).

python
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext

class ScoredLead(BaseModel):
    person_id: str
    name: str
    title: str
    score: int = Field(ge=1, le=5)
    reason: str

class Report(BaseModel):
    audience_total: int
    leads: list[ScoredLead]
    stopped_early: str | None = None

agent = Agent(
    "anthropic:claude-sonnet-5-5",
    deps_type=Budget,
    output_type=Report,
    instructions=(
        "You qualify B2B leads. ICP: VP or head of sales at a software company. "
        "Call count_people first and report the total. Search at most 5 people, "
        "enrich at most 3. Score each enriched person 1 to 5 against the ICP "
        "with one line of reasoning. If a tool result starts with STOP, stop and "
        "put the reason in stopped_early."
    ),
)

The instructions hold the ICP and the cost rules, because the model cannot see your budget.

Step 3: Add the three tools

The @agent.tool decorator reads the docstring and type hints and builds the tool schema. Tools that need deps take a RunContext as the first argument (Pydantic AI function tools, September 2026).

The search body is flat JSON. Filters such as title and country are arrays of strings, and limit runs from 1 to 100. The free sizing call takes count=true as a query parameter and returns meta.total.

python
@agent.tool
def count_people(ctx: RunContext[Budget], title: list[str], country: list[str]) -> dict:
    """Free. Count people by job title keywords and ISO country codes,
    for example ["VP Sales"] and ["CA"]. Call this before search_people."""
    body = call(ctx.deps, "POST", "/v1/people/search", params={"count": "true"},
                json={"title": title, "country": country, "limit": 1})
    return body if "error" in body else {"total": body["meta"]["total"]}

@agent.tool
def search_people(ctx: RunContext[Budget], title: list[str],
                  country: list[str], limit: int = 5) -> dict:
    """Spends one record per person returned (max 10). Each row has a person_id.
    Use enrich_person for contact details."""
    limit = max(1, min(limit, 10))
    body = call(ctx.deps, "POST", "/v1/people/search", cost=limit,
                json={"title": title, "country": country, "limit": limit})
    return body if "error" in body else {"people": body["data"]}

@agent.tool
def enrich_person(ctx: RunContext[Budget], person_id: str) -> dict:
    """Spends one record. Enrich one person by person_id from search_people."""
    return call(ctx.deps, "POST", "/v1/people/enrich", cost=1,
                json={"person_id": person_id, "reveal_email": False})

Enrichment takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, so the agent searches first and enriches second. The model scores from the enriched profile, so reveal_email stays false and no address is returned.

Step 4: Run it and read the cost

Run the agent with a Budget as deps. Add UsageLimits so a confused model cannot loop: request_limit bounds model turns and tool_calls_limit bounds tool executions.

python
from pydantic_ai import UsageLimits

budget = Budget(cap=25)
result = agent.run_sync(
    "Find 5 VPs of sales in Canada, enrich the first 3 and score them.",
    deps=budget,
    usage_limits=UsageLimits(request_limit=12, tool_calls_limit=10),
)
print(result.output.model_dump_json(indent=2))
print(f"LeadOcean records spent: {budget.spent}")
print(result.usage())

An expected run is one free count, one search of 5 people and three enrichments: 5 + 3 = 8 records. The cap in Step 1 ends a bad run at 25. Your model provider bills tokens on top. result.usage() reports them, and this guide does not measure them.

A search row already says whether an address exists: has_email and email_status come back before you spend a record on enrichment. LeadOcean does not refund bounced emails, so read email_status before sending anything.

Step 5: Use the MCP server instead of REST

The hosted MCP server has 13 tools, including the free count_leads, count_companies, list_filters and list_enum_values. Its URL is https://api.leadocean.io/mcp, and it signs in with OAuth 2.1 instead of a pasted key. The OAuth server supports dynamic client registration at https://api.leadocean.io/oauth/register.

Pydantic AI connects to a remote MCP server with MCPToolset and a URL, registered through the toolsets argument (Pydantic AI MCP client, October 2026). Its docs say a pre-built fastmcp.Client is the route for OAuth, so build that client per their guide.

python
from pydantic_ai.mcp import MCPToolset

toolset = MCPToolset("https://api.leadocean.io/mcp")  # add OAuth via a fastmcp.Client
mcp_agent = Agent("anthropic:claude-sonnet-5-5", toolsets=[toolset])

Use REST for unattended jobs, because a key needs no sign-in. Use MCP for an agent a person supervises. Both spend the same records: search_leads is one per person returned, get_lead is one.

What you get

The free count is the first thing the agent does. This is the output of the free leadocean_count_leads MCP tool on 2026-10-21, with the filters from the run in Step 4: title VP Sales and country CA. total counts mailable people (verified, catch_all_valid and catch_all emails). totalAll is the same filters with no email condition. It cost 0 records.

json
{ "total": 658, "totalIsExact": true, "totalAll": 2661, "distinctCompanies": 590, "credits": 0 }

An enrichment returns the documented person shape. This is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe", "...": "headline, location" },
    "contact_data": { "has_email": true, "email_status": "verified" }
  },
  "meta": { "count": 1, "credits": 1 }
}

contact_emails and contact_phones appear only with reveal_email: true. Since 2026-10-17 enrichment also returns resume_data with past roles and education, each when held. Read the full schema in the API reference.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCheck $LEADOCEAN_API_KEY and that the header is x-api-key.
402The free plan's 1,000 records are spentThey do not reset. The tool returns STOP and the agent reports it in stopped_early. Move to Solo or Agency.
429Above the plan's rate limit: 5 requests a second on Free, 200 on AgencyWait for Retry-After. The helper retries short waits and stops on long ones.
400 on searchA wrong-case enum such as jobLevel: "vp" instead of VPEnum values are exact strings. List them with leadocean_list_enum_values.
404 on enrichNo matching personCosts nothing. Report "not found" and move on.
Capped exportMore than 50,000 rows in a request, or 500,000 in a daySplit the export by filter. Do not page it from the agent.

FAQ

How many records does one run cost?

The run in this guide spends 8: 5 from search and 3 from enrichment. Counting is free. The Budget cap in Step 1 stops a bad run at 25.

Does the count tool spend records?

No. Sizing with count=true and limit=1 costs nothing, however often you call it. meta.total is capped at 100,000.

Why a typed output instead of free text?

Validation catches a bad score before it reaches your CRM or sheet. Pydantic AI retries the model when the result fails the schema.

Can I run this on the free plan?

Yes. Free keys get the search and enrich scopes inside the 1,000 one-off records. No card is needed.

The same pattern in other frameworks: LangGraph, Mastra and LlamaIndex. Browse the full blog or see pricing.

Give your Pydantic AI agent real B2B data

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →