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.
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.
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-Afterand 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.
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.
python agent.pyEnrichment 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.
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:
{"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:
{
"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
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Check $LEADOCEAN_API_KEY. Create a new key in the app. |
| 402 | Free plan: the 1,000 records are spent and do not reset | Move to Solo ($97 a month) or Agency ($497 a month). The helper raises ToolFailed, so the agent stops. |
| 429 | More 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 data | A filter value is not an exact enum string | Look it up with GET /v1/enums/{name}. Pass it exactly. |
400 export_limit_rows | An export asked for more than 50,000 rows in one request | Split 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.