You give a Claude Agent SDK agent live B2B data by wrapping LeadOcean REST calls as custom tools. The agent counts an audience for free, pulls a small page, and enriches a few people. A record cap in your code stops a runaway loop.
This guide also shows the MCP route, and when to pick it.
TL;DR
- Four tools (
list_values,count_people,search_people,enrich_person) and one shared HTTP helper. About 30 minutes. - One run spends 5 to 7 LeadOcean records: 0 for the count, 5 for the search page, up to 2 for enrichments. A cap of 15 is the hard stop.
- 402 and 429 come back as clear tool errors, so the agent stops instead of retrying forever.
Prerequisites
- Python 3.10 or newer.
- A LeadOcean key from the app, exported as
$LEADOCEAN_API_KEY. The free plan has 1,000 records for life, no card, 5 requests a second. - An Anthropic API key in
$ANTHROPIC_API_KEY. - The Claude Agent SDK and
httpx.
The full sample is in the post: lo.py, tools.py and a run script.
Step 1: Install and set keys
pip install claude-agent-sdk httpx
export LEADOCEAN_API_KEY="..." # from app.leadocean.io
export ANTHROPIC_API_KEY="..."Keys stay in the environment. No literal key appears in the code below.
Step 2: Write one HTTP helper
Every LeadOcean call goes through one function. It sends the x-api-key header, waits and retries on 429, and turns each failure into a message the model can act on. Save it as lo.py.
import asyncio, os
import httpx
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
CAP = 15 # records this process may spend
spent = {"records": 0}
class LOError(Exception):
pass
async def call(method: str, path: str, *, params=None, body=None) -> dict:
async with httpx.AsyncClient(base_url=BASE, headers=HEADERS, timeout=30) as c:
for attempt in range(3):
r = await c.request(method, path, params=params, json=body)
if r.status_code == 429: # above the plan's per-second limit
await asyncio.sleep(min(int(r.headers.get("Retry-After", "1")), 5))
continue
if r.status_code == 503: # transient, never billed
await asyncio.sleep(2 ** attempt)
continue
break
if r.status_code == 200:
data = r.json()
spent["records"] += data.get("meta", {}).get("credits", 0)
return data
if r.status_code == 404: # nobody matched, costs nothing
return {"found": False}
if r.status_code == 402:
raise LOError("The free records are spent. Stop and tell the user.")
if r.status_code == 429:
raise LOError("Rate limited. Stop and report what you have.")
if r.status_code == 401:
raise LOError("Bad or missing API key. Stop.")
raise LOError(f"HTTP {r.status_code}: {r.text[:300]}")The LeadOcean limits page defines the codes. 429 means the key went above its plan's rate limit (5 a second on Free, 50 on Solo, 200 on Agency) and carries Retry-After. 402 means the records are spent. On Free they do not reset. A 404 on enrich costs nothing.
Step 3: Define the tools
The SDK's @tool decorator takes a name, a description and an input schema (custom tools docs, September 2026). Returning is_error: True lets the model read your message instead of a stack trace.
import json
from claude_agent_sdk import tool, create_sdk_mcp_server
from lo import call, spent, CAP, LOError
def ok(x): return {"content": [{"type": "text", "text": json.dumps(x)}]}
def err(m): return {"content": [{"type": "text", "text": m}], "is_error": True}
ARR = {"type": "array", "items": {"type": "string"}}
FILTERS = {"jobLevel": ARR, "jobFunction": ARR, "country": ARR,
"industry": ARR, "employeeRange": ARR}
@tool("list_values", "Free. Exact values for a filter name, e.g. job_level, country.",
{"name": str, "q": str})
async def list_values(args):
try:
d = await call("GET", f"/v1/enums/{args['name']}",
params={"limit": 50, "q": args.get("q", "")})
return ok(d["data"])
except LOError as e:
return err(str(e))
@tool("count_people", "Free. Size an audience. Call this first.",
{"type": "object", "properties": FILTERS})
async def count_people(args):
try:
d = await call("POST", "/v1/people/search", params={"count": "true"},
body={**args, "limit": 1})
return ok(d["meta"])
except LOError as e:
return err(str(e))
@tool("search_people", "1 record per person returned. Max 5.",
{"type": "object", "properties": {**FILTERS, "limit": {"type": "integer"}},
"required": ["limit"]})
async def search_people(args):
n = min(int(args["limit"]), 5)
if spent["records"] + n > CAP:
return err("Record cap reached. Do not search again.")
try:
d = await call("POST", "/v1/people/search", body={**args, "limit": n})
return ok(d["data"])
except LOError as e:
return err(str(e))
@tool("enrich_person", "1 record. Full lookup by person_id from a search row.",
{"person_id": str})
async def enrich_person(args):
if spent["records"] + 1 > CAP:
return err("Record cap reached. Do not enrich again.")
try:
d = await call("POST", "/v1/people/enrich",
body={"person_id": args["person_id"], "reveal_email": True})
return ok(d)
except LOError as e:
return err(str(e))
leadocean = create_sdk_mcp_server(
name="leadocean", version="1.0.0",
tools=[list_values, count_people, search_people, enrich_person])Search takes a flat JSON body where each filter is an array, with limit from 1 to 100. Enrich 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 by person_id.
The cap is in code, not in the prompt. A model that ignores an instruction still cannot spend past CAP.
Step 4: Run the agent
Tools from an in-process server are named mcp__<server>__<tool>. Put those full names in allowed_tools or the agent cannot call them. max_turns stops a loop.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
from lo import spent
SYSTEM = """You build small B2B prospect lists with LeadOcean.
Always call count_people first and report the total.
Then call search_people with limit 5.
Enrich at most 2 people, only if the user asks for contact details.
Filter values are exact strings. If unsure, call list_values.
Use only fields that came back in a tool result."""
TOOLS = [f"mcp__leadocean__{t}" for t in
("list_values", "count_people", "search_people", "enrich_person")]
async def main():
from tools import leadocean
options = ClaudeAgentOptions(
system_prompt=SYSTEM,
mcp_servers={"leadocean": leadocean},
allowed_tools=TOOLS,
max_turns=10,
)
prompt = ("VPs of Sales at US software companies with 51-200 staff. "
"Size it, then show me 5 and enrich the first 2.")
async for m in query(prompt=prompt, options=options):
if isinstance(m, ResultMessage) and m.subtype == "success":
print(m.result)
print("LeadOcean records spent:", spent["records"])
asyncio.run(main())Save the Step 3 code as tools.py so the import resolves.
Step 5: Or use the hosted MCP server
LeadOcean's hosted MCP server at https://api.leadocean.io/mcp exposes 13 tools. It signs in with OAuth 2.1 in a browser, and there is no API key to paste. That suits an interactive client. The LeadOcean MCP docs give the Claude Code setup:
claude mcp add --transport http leadocean https://api.leadocean.io/mcp
claude mcp login leadoceanThe Agent SDK accepts remote servers of type http (MCP docs, September 2026). A headless process has no browser for the sign-in, so keep the REST tools above for a server-side agent.
Allow only the free tools unless you want the agent to spend: leadocean_count_leads, leadocean_count_companies, leadocean_list_filters and leadocean_list_enum_values cost nothing. leadocean_search_leads, leadocean_get_lead, leadocean_find_phone, leadocean_get_company, leadocean_search_companies and leadocean_export_leads cost one record per person, company or row returned. Pick MCP for a quick prototype. Pick REST tools for a server-side agent with a cap.
What you get
The count tool is free. On 2026-10-21 leadocean_count_leads ran with five filters: jobLevel VP, jobFunction Sales & Business Development, country US, employeeRange 51-200 and industry Software Development. It returned this:
{"total": 1649, "totalIsExact": true, "totalAll": 4141, "distinctCompanies": 1125, "credits": 0}The 1,649 counts people whose email is verified, catch-all valid or catch-all. Without an email filter the same filters match 4,141.
A search row looks like this. Response shape from the API reference, 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": "..." }
}Cost per run: 0 for the count, 5 for the page, up to 2 for enrichments. That is 7 records at most. On Free, 1,000 records is about 140 runs. On Solo ($97 a month) there is no per-record price, only a fair-use pace. See pricing.
Search rows carry flags, not addresses. Read email_status before a list goes to a sequencer, because LeadOcean does not refund bounced emails.
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 | Move to Solo ($97 a month) or Agency ($497 a month). The helper tells the agent to stop. |
| 429 | More requests a second than the plan allows (5 on Free, 200 on Agency) | The helper waits for Retry-After and retries. Fetch full pages instead of more calls, or move up a plan. |
400 or empty data | A filter value is not an exact enum string | Call list_values. Pass the string exactly, for example Sales & Business Development. |
| Tool never runs | The tool name is missing from allowed_tools | Use the full name mcp__leadocean__<tool>. |
| Export refused | 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?
Use REST tools for server-side agents. You control the key, the schemas and the record cap. Use MCP when a person runs the agent and can complete OAuth sign-in.
How many records does one run cost?
With these instructions, 5 to 7. The count is free, the page is 5 and enrichments are up to 2. The cap of 15 is the hard limit.
Why enrich by person_id?
There is no name plus domain lookup. Search by title or domain first, then pass the person_id from the row to POST /v1/people/enrich.
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. Bulk CSV export is not on Free.
Related guides: Vercel AI SDK, OpenAI Agents SDK, Pydantic AI. More agent guides live on the LeadOcean blog.
Give your Claude agent real B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →