TL;DR
- You write three Python tools (count, search, enrich) and hand them to a Claude Agent SDK agent. The agent scores each lead against your ICP and returns a ranked list.
- It takes about 45 minutes. The example run spends about 13 of your 1,000 free LeadOcean records. Counting is free.
- Call LeadOcean over REST with your key, not over its MCP server. The Agent SDK does not run an interactive OAuth flow, and LeadOcean's MCP server signs in with OAuth.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off, with 5 requests a second.
- Python 3.10 or newer, the Claude Agent SDK (
pip install claude-agent-sdk httpx) and an Anthropic API key in your environment. - Your LeadOcean key in the shell:
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Write the REST helper with 402 and 429 handling
Every tool shares one function. It sends the x-api-key header, retries a 429 once after Retry-After, and turns a 402 into a message the agent can act on.
import os, asyncio, httpx
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
SPENT = {"records": 0} # running total for this process
RECORD_CAP = 20 # hard ceiling for one run
async def call(method, path, params=None, body=None):
async with httpx.AsyncClient(timeout=30) as client:
for attempt in range(2):
r = await client.request(method, BASE + path, headers=HEADERS, params=params, json=body)
if r.status_code == 429 and attempt == 0:
await asyncio.sleep(int(r.headers.get("Retry-After", "1")))
continue
break
if r.status_code == 402:
return None, "Out of records on this plan. Stop and tell the user."
if r.status_code == 429:
return None, "Rate limited twice. Stop and report what you have."
if r.status_code == 404:
return None, "Nobody matched. This lookup cost nothing."
if r.status_code >= 400:
return None, f"HTTP {r.status_code}: {r.text[:300]}"
return r.json(), NoneA 402 means the plan has no records left. A 404 on enrich means nobody matched, and it costs nothing.
Step 2: Define the three tools
Each tool uses the SDK's @tool decorator. Failures come back with is_error: True, so the agent reads your message instead of a raw exception.
import json
from claude_agent_sdk import tool, create_sdk_mcp_server
def ok(data): return {"content": [{"type": "text", "text": json.dumps(data)}]}
def err(msg): return {"content": [{"type": "text", "text": msg}], "is_error": True}
@tool("count_audience", "Count mailable people for an ICP. Free. Call this first.",
{"type": "object", "properties": {
"jobLevel": {"type": "array", "items": {"type": "string"}},
"jobFunction": {"type": "array", "items": {"type": "string"}},
"country": {"type": "array", "items": {"type": "string"}},
"industry": {"type": "array", "items": {"type": "string"}},
"employeeRange": {"type": "array", "items": {"type": "string"}}},
"required": []})
async def count_audience(args):
data, e = await call("POST", "/v1/people/search", params={"count": "true"},
body={**args, "limit": 1})
return err(e) if e else ok(data["meta"])
@tool("search_people", "Search people for an ICP. Costs 1 record per person returned. Max 10.",
{"type": "object", "properties": {
"jobLevel": {"type": "array", "items": {"type": "string"}},
"jobFunction": {"type": "array", "items": {"type": "string"}},
"country": {"type": "array", "items": {"type": "string"}},
"industry": {"type": "array", "items": {"type": "string"}},
"employeeRange": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "minimum": 1, "maximum": 10}},
"required": ["limit"]})
async def search_people(args):
n = min(int(args.get("limit", 10)), 10)
if SPENT["records"] + n > RECORD_CAP:
return err("Record cap reached for this run. Do not search again.")
data, e = await call("POST", "/v1/people/search", body={**args, "limit": n})
if e: return err(e)
SPENT["records"] += len(data["data"])
return ok(data)
@tool("enrich_person", "Full lookup for one person by person_id. Costs 1 record.",
{"person_id": str})
async def enrich_person(args):
if SPENT["records"] + 1 > RECORD_CAP:
return err("Record cap reached for this run. Do not enrich again.")
data, e = await call("POST", "/v1/people/enrich",
body={"person_id": args["person_id"], "reveal_email": True})
if e: return err(e)
SPENT["records"] += 1
return ok(data)
leadocean = create_sdk_mcp_server(name="leadocean", version="1.0.0",
tools=[count_audience, search_people, enrich_person])The cap lives in your code, not the prompt. A model that ignores an instruction still cannot spend past RECORD_CAP.
Step 3: Write the qualification rubric
The system prompt holds the ICP and the scoring rules. Keep the rules checkable. Acme is a placeholder for your own company.
SYSTEM = """You qualify leads for Acme, which sells project management software.
ICP: VP of Sales at US software companies with 51 to 200 staff.
Procedure:
1. Call count_audience with the ICP filters. Report the total.
2. Call search_people with the same filters and limit 10.
3. Score each person 0 to 3: +1 title matches "VP Sales" or "VP of Sales",
+1 contact_data.email_status is verified, +1 the company is a software company.
4. Call enrich_person for the 3 highest scores only.
5. Return a ranked list: name, title, company, score, one-line reason.
Rules: use only fields that came back in a tool result. If a field is missing,
write "unknown". Never call a tool more than the procedure says.
Filter values are exact strings: jobLevel "VP", jobFunction "Sales & Business Development",
country "US", industry "Software Development", employeeRange "51-200"."""Step 4: Run the agent
allowed_tools pre-approves exactly the three tools, named mcp__leadocean__<tool>. max_turns stops a loop.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
system_prompt=SYSTEM,
mcp_servers={"leadocean": leadocean},
allowed_tools=["mcp__leadocean__count_audience",
"mcp__leadocean__search_people",
"mcp__leadocean__enrich_person"],
max_turns=12,
max_budget_usd=1.00,
)
async for message in query(prompt="Qualify this week's leads.", options=options):
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
print(f"LeadOcean records spent: {SPENT['records']}")
asyncio.run(main())max_budget_usd caps Claude spend, which is separate from LeadOcean records. The SDK docs warn the estimate can pass the value, so leave headroom.
Step 5: Check the balance
After the run, read your account. GET /v1/account is free.
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jqThe run searches 10 people (10 records) and enriches 3 (3 records). That is about 13 records. Read the first output line by line: every fact should appear in a tool result.
What you get
Step 2's count tool is free. On 21 October 2026 the same filters as the system prompt, run through the free LeadOcean count tool (re-run the same day in review), returned this.
{
"total": 1649,
"totalIsExact": true,
"distinctCompanies": 1125,
"totalAll": 4141,
"credits": 0,
"defaults": "Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)"
}That is 1,649 VPs of Sales at US software companies of 51 to 200 staff, with a verified or catch-all email. Without the email condition the same filters match 4,141 people.
A search page has a different shape. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [
{
"profile_data": { "...": "name, headline, location" },
"contact_data": { "has_email": true, "email_status": "verified", "email_type": "work",
"contact_current_experiences": [ { "...": "current role and company" } ] },
"meta": { "source_ids": { "person_id": "<digits>" } }
}
],
"meta": { "count": 10, "limit": 10, "nextCursor": "<opaque cursor>" }
}Search rows are thin: no addresses. The agent passes meta.source_ids.person_id to enrich_person, which returns the contact points. Person enrich also returns career history in resume_data (past roles, education, certifications), so the agent can cite a previous employer.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again. |
| 429 | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Wait Retry-After seconds. The helper retries once, then tells the agent to stop. |
| 402 | The plan's records are spent | Check GET /v1/account. Free records are one-off, so move to a paid plan on pricing. |
| Total of 0 or a 400 | A filter value outside the catalogue, such as industry: "Software" | Resolve the exact string with GET /v1/enums or leadocean_list_enum_values. |
| 404 on enrich | Nobody matched that person_id, or no identifier to enrich by | Skip the lead. The call costs nothing. |
| Tool never runs | The tool name is missing from allowed_tools | Use the full name mcp__leadocean__<tool>. |
FAQ
Why not connect LeadOcean's MCP server directly?
You can, but the SDK's docs say it does not open a browser for OAuth. LeadOcean's MCP server uses OAuth 2.1, so you would obtain a token in your own app and pass it in headers. REST with an API key is simpler for a headless agent.
How do I keep the agent from overspending records?
Count in code, as in Step 2. The tool refuses once RECORD_CAP is reached, whatever the prompt says. A person returned counts as one record.
Can I run it on a schedule?
Yes. It is a plain Python script, so cron or any job runner can start it. Print SPENT each run and check GET /v1/account after the first few.
Does the agent send emails?
No. It ranks leads and returns data. LeadOcean is a data source, not a sequencer, dialer or CRM.
What does it cost to run?
LeadOcean: about 13 records a run, so the free 1,000 covers dozens of test runs. Claude tokens are billed by Anthropic and vary with your prompt. For more volume, see pricing.
Related reading
Same recipe on other stacks: Vercel AI SDK and OpenAI Agents SDK. For outreach, see build an AI SDR with the Claude Agent SDK. More recipes are in the blog hub.
Give your qualification agent live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →