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

How-to guide

How to Give a Pydantic AI Agent B2B Data (REST Tools and MCP)

A Python agent that sizes an audience for free, searches 5 people and enriches up to 3. At most 8 records a run, with 402 and 429 handled in code.

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

You can give a Pydantic AI agent live B2B data with four plain Python functions and one HTTP client. This guide builds that agent against the LeadOcean REST API. It also shows the MCP route, which suits agents a person runs and signs in to.

The agent sizes an audience for free, searches a small page, and enriches a few people. Errors 402 and 429 are handled in code, and a record budget stops a runaway loop.

TL;DR

  • Four tools (list_values, count_people, search_people, enrich_person) and one shared HTTP helper. About 25 minutes.
  • One run spends 5 to 8 LeadOcean records: 0 for the count, 5 for the search page, up to 3 for enrichments. A budget of 25 is the hard stop.
  • 402 and 429 become clear tool results the model can act on, so the agent stops instead of looping.

Prerequisites

  • Python 3.10 or later and pip.
  • A LeadOcean API key from the app, exported as $LEADOCEAN_API_KEY. The free plan has 1,000 records, one-off, no card, and both scopes (search and enrich).
  • A model key for the provider you pick. The code uses anthropic:claude-sonnet-4-6, and you can swap the string for any model Pydantic AI supports.
  • Pydantic AI with the Anthropic extra, plus httpx.

Full code for this guide is in the post, in two files: tools.py and agent.py.

Step 1: Install and set your keys

Pydantic AI installs from PyPI. The Anthropic provider is an optional extra, so name it. Without it the Agent constructor raises an ImportError.

bash
pip install "pydantic-ai-slim[anthropic]" httpx
export LEADOCEAN_API_KEY="..."   # from app.leadocean.io
export ANTHROPIC_API_KEY="..."

Keys stay in the environment. Nothing in the code below holds a literal key.

Step 2: Write one HTTP helper

Put every LeadOcean call through one function. It owns the retry rules and turns each status code into the right Pydantic AI exception. Save it as tools.py.

python
import asyncio
import os
from dataclasses import dataclass

import httpx
from pydantic_ai import ModelRetry, RunContext, ToolFailed

BASE_URL = "https://api.leadocean.io"


@dataclass
class Deps:
    http: httpx.AsyncClient
    budget: int = 25  # records this run may spend
    spent: int = 0


def make_client() -> httpx.AsyncClient:
    return httpx.AsyncClient(
        base_url=BASE_URL,
        headers={"x-api-key": os.environ["LEADOCEAN_API_KEY"]},
        timeout=30,
    )


async def call(ctx: RunContext[Deps], method: str, path: str, **kw) -> dict:
    """One LeadOcean request with the error handling the docs describe."""
    for attempt in range(3):
        r = await ctx.deps.http.request(method, path, **kw)
        wait = int(r.headers.get("Retry-After", "1"))
        if r.status_code == 429 and wait <= 5:  # above the plan's per-second limit
            await asyncio.sleep(wait)
            continue
        if r.status_code == 503:  # transient, never billed
            await asyncio.sleep(2**attempt)
            continue
        break

    if r.status_code == 200:
        body = r.json()
        ctx.deps.spent += body.get("meta", {}).get("credits", 0)
        return body
    if r.status_code == 404:  # nobody matched, costs nothing
        return {"found": False}

    message = r.json().get("error", {}).get("message", r.text)
    if r.status_code == 400:  # the model can fix its own arguments
        raise ModelRetry(f"LeadOcean rejected the request: {message}. "
                         "Check enum values with list_values.")
    if r.status_code == 402:
        raise ToolFailed("Out of records on the Free plan. Stop and tell the user.")
    if r.status_code == 429:  # still limited after the short waits
        raise ToolFailed(f"Rate limited, retry after {wait} seconds. Do not retry now.")
    raise ToolFailed(f"LeadOcean returned {r.status_code}: {message}")

Pydantic AI gives you two exceptions for failed tools, and the helper uses both (Pydantic AI docs, October 2026). ModelRetry sends the message back to the model so it can fix its arguments. ToolFailed records a failed result without spending the tool's retry budget.

The mapping follows the LeadOcean limits page:

  • 400 is a validation error, so the model gets a retry with the message.
  • 402 means the free plan is spent. Those records never reset, so retrying is pointless.
  • 429 means the key went above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. Wait for Retry-After and go again.
  • 429 that still holds after two short waits becomes a ToolFailed, so the model stops instead of hammering the key.
  • 503 is transient and never billed, so back off and retry.
  • 404 on enrich means nobody matched and costs nothing.

Step 3: Define the four tools

Each tool is an async function with a docstring. Pydantic AI reads the type hints and the docstring to build the schema the model sees (tools docs, October 2026). Save this as agent.py.

python
import asyncio

from pydantic_ai import Agent, RunContext, ToolFailed
from pydantic_ai.usage import UsageLimits

from tools import Deps, call, make_client

agent = Agent(
    "anthropic:claude-sonnet-4-6",
    deps_type=Deps,
    instructions=(
        "You build small B2B prospect lists with LeadOcean. Always call "
        "count_people first. Search only when the count is under 100,000. "
        "Enrich at most 3 people, and only when asked for contact details. "
        "Look up enum values with list_values instead of guessing."
    ),
)


@agent.tool
async def list_values(ctx: RunContext[Deps], name: str, q: str = "") -> list[str]:
    """Free. Exact values for a filter: job_level, job_function, country, seniority, industry."""
    params = {"limit": 50, **({"q": q} if q else {})}
    body = await call(ctx, "GET", f"/v1/enums/{name}", params=params)
    return body["data"]["values"]


@agent.tool
async def count_people(ctx: RunContext[Deps], filters: dict[str, list[str]]) -> int:
    """Free. Size a segment. Filter values are arrays, e.g. {"jobLevel": ["VP"], "country": ["CA"]}."""
    body = await call(ctx, "POST", "/v1/people/search",
                      params={"count": "true"}, json={**filters, "limit": 1})
    return body["meta"]["total"]


def trim(row: dict) -> dict:
    """Keep six fields. The model does not need the whole record."""
    contact = row.get("contact_data", {})
    job = (contact.get("contact_current_experiences") or [{}])[0]
    return {
        "person_id": row.get("meta", {}).get("source_ids", {}).get("person_id"),
        "name": row.get("profile_data", {}).get("profile_full_name"),
        "title": job.get("job_title"),
        "company": job.get("company_name"),
        "domain": job.get("company_domain"),
        "email_status": contact.get("email_status"),
    }


@agent.tool
async def search_people(ctx: RunContext[Deps], filters: dict[str, list[str]], limit: int = 5) -> list[dict]:
    """Costs 1 record per person returned. Returns thin rows: name, title, company, person_id."""
    left = ctx.deps.budget - ctx.deps.spent
    if left <= 0:
        raise ToolFailed("Record budget for this run is spent. Stop.")
    body = await call(ctx, "POST", "/v1/people/search",
                      json={**filters, "limit": min(limit, left, 25)})
    return [trim(row) for row in body["data"]]


@agent.tool
async def enrich_person(ctx: RunContext[Deps], person_id: str) -> dict:
    """Costs 1 record. Returns the profile and revealed emails for one person_id."""
    if ctx.deps.budget - ctx.deps.spent < 1:
        raise ToolFailed("Record budget for this run is spent. Stop.")
    body = await call(ctx, "POST", "/v1/people/enrich",
                      json={"person_id": person_id, "reveal_email": True})
    return body.get("data", body)


async def main() -> None:
    async with make_client() as http:
        deps = Deps(http=http, budget=25)
        result = await agent.run(
            "Find 5 VPs of Sales in Canada and tell me how many exist in total.",
            deps=deps,
            usage_limits=UsageLimits(request_limit=12),
        )
        print(result.output)
        print(f"LeadOcean records spent: {deps.spent}")


if __name__ == "__main__":
    asyncio.run(main())

Four choices matter here.

Count before you search. count_people sends count=true as a query parameter with limit 1 in the body. That is a free sizing call and spends no records. The instructions tell the model to call it first.

Enum values are exact strings. jobLevel takes VP, not vp or Vice President. list_values is free, and the 400 retry points the model at it when it guesses wrong.

The row is trimmed to six fields. A search row is large. The model only needs the person_id to enrich someone, so trim keeps the rest short and saves model tokens.

The budget lives in Deps. Each response carries meta.credits, the records counted for that call. The helper adds it to deps.spent, and the tools refuse to run once the budget is gone. UsageLimits(request_limit=12) separately caps model turns (agents docs, October 2026).

Step 4: Run it

Run the file. The agent counts the segment, searches 5 people and prints how many records it spent.

bash
python agent.py

Enrichment needs a person_id from search. There is no name plus domain lookup, so the agent searches first and enriches second. Each enrichment that finds someone is one record. One that finds nobody is a 404 and costs nothing.

Cost per run is the part to plan for. The search page is 5 records. Three enrichments add 3. On Free, 1,000 records cover about 125 runs of the larger size. Paid plans have no per-record price: Solo is $97 a month and Agency is $497 a month, and a key that reaches its fair-use pace is slowed, not cut off. See pricing.

Step 5: Or connect over MCP

The hosted MCP server at https://api.leadocean.io/mcp exposes 13 tools and signs in with OAuth 2.1. There is no key to paste. Pydantic AI's MCPToolset takes a prebuilt FastMCP Client for OAuth (MCP client docs, October 2026), and FastMCP's auth="oauth" runs the sign-in with dynamic client registration.

python
import asyncio

from fastmcp import Client
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset

leadocean = MCPToolset(Client("https://api.leadocean.io/mcp", auth="oauth"))
agent = Agent("anthropic:claude-sonnet-4-6", toolsets=[leadocean])


async def main() -> None:
    result = await agent.run("Count VPs of Sales in Canada. Do not search.")
    print(result.output)


asyncio.run(main())

The first run opens a browser sign-in. That fits an agent you run on your laptop. A server-side agent with no browser should use the REST tools above.

MCP gives the model every tool, including the ones that spend records. search_leads, get_lead, find_phone, get_company, search_companies and export_leads each cost one record per person or company returned. The REST tools above expose only four calls and cap the spend in code. Pick MCP for convenience and REST for control.

What you get

The list_values tool returns exact enum strings. Real output from leadocean_list_enum_values for job_level, run on 2026-10-01:

json
{"name":"job_level","total":6,"values":["C-Team","Director","Manager","Other","Staff","VP"]}

The count tool returns one integer. On 2026-10-21, leadocean_count_leads for jobLevel VP, jobFunction Sales & Business Development, country CA returned 5,574 mailable people. Mailable here means a verified, catch_all_valid or catch_all email. The same filters with no email condition matched 18,800. A REST count without an emailStatus filter can differ.

A search row looks like this. Response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": [
    {
      "profile_data": { "profile_full_name": "Jane Doe" },
      "contact_data": {
        "has_email": true,
        "email_status": "verified",
        "contact_current_experiences": [
          { "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }
        ]
      },
      "meta": { "source_ids": { "person_id": "12345678" } }
    }
  ],
  "meta": { "count": 1, "limit": 5, "credits": 1, "nextCursor": "..." }
}

Search rows carry flags, not addresses. has_email and email_status say whether an address exists and whether it is safe to send. The address comes from enrich_person, which is why the instructions say to enrich only when asked. LeadOcean does not refund bounced emails, so read email_status before anyone sends to the list.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCheck $LEADOCEAN_API_KEY. Create a new key in the app.
402Free plan: the 1,000 records are spent and do not resetMove to Solo ($97 a month) or Agency ($497 a month). The helper raises ToolFailed, so the agent stops.
429More requests a second than the plan allows (5 on Free, 50 on Solo, 200 on Agency)The helper waits for Retry-After and retries twice. Ask for full pages, not more calls, or move up a plan.
400 or empty dataA filter value is not an exact enum stringLook it up with GET /v1/enums/{name}. Pass it exactly.
400 export_limit_rowsAn export asked for more than 50,000 rows in one requestSplit the pull. The daily ceiling is 500,000 rows.

FAQ

Should I use REST tools or the MCP server with Pydantic AI?

Use REST for server-side agents. You control the schemas, the key and the record cap. Use MCP when a person runs the agent locally and can sign in through OAuth.

How many records does one run cost?

With these instructions, 5 to 8: a free count, a search page of 5 and up to 3 enrichments. The budget of 25 is the hard limit. Each person or company returned counts one record.

Why enrich by person_id and not by name?

There is no name plus domain lookup. Search by domain or title first, then pass the person_id from the row to POST /v1/people/enrich. It also works for people with no LinkedIn URL.

What happens when the free records run out?

The API answers 402 and the helper raises ToolFailed. The model reads the message and tells the user. Rejected requests are never metered. A 402 on Free means upgrade, not wait.

Can the agent pull thousands of rows?

Not through search. A page holds 100 rows at most, and one search walk stops at 10,000. For big pulls use POST /v1/exports: up to 50,000 rows a request and 500,000 a day.

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 →

Related guides: Mastra, LlamaIndex, LangGraph. More agent guides live on the LeadOcean blog.