TL;DR
- You give a Claude Agent SDK agent three LeadOcean tools (count, search, enrich) and a prompt that drafts a first-touch email per lead. It returns drafts for you to review. It sends nothing.
- It takes about 60 minutes to build. Each run spends about 13 LeadOcean records: 10 from one search, 3 enrichments. Counting is free.
- The code calls LeadOcean over REST with an API key. The SDK does not run an interactive OAuth flow, and LeadOcean's hosted 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, at 5 requests a second.
- Python 3.10 or newer and an Anthropic API key in your environment.
- Install the packages:
pip install claude-agent-sdk httpx. - Your LeadOcean key in the shell:
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Write one HTTP 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}
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 costs nothing.
Step 2: Define the tools
Each tool uses the SDK's @tool decorator. A failure returns is_error: True, so the agent reads your message instead of a stack trace. Search sends a flat JSON body where every filter is an array.
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}
FILTERS = {"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"}}}
@tool("count_audience", "Count people for an ICP. Free. Call this first.",
{"type": "object", "properties": FILTERS, "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. 1 record per person returned. Max 10.",
{"type": "object", "properties": {**FILTERS,
"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. 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 in the prompt. A model that ignores an instruction still cannot spend past RECORD_CAP.
Step 3: Write the SDR prompt
The prompt holds the ICP, the procedure and the writing rules. Acme and its product are placeholders for your own.
SYSTEM = """You are an SDR 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. Pick the 3 best fits: title closest to "VP Sales", email_status verified.
4. Call enrich_person for those 3 only.
5. For each, write a first-touch email: subject under 6 words, body under
90 words, one specific line taken from resume_data (a past role, school
or certification), one question as the call to action.
Rules: use only facts that came back in a tool result. If a field is missing,
leave it out. Never claim you saw something you did not. Do not send anything.
Return the 3 drafts as a numbered list with name, title, company, email.
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 the three tools by their full name, mcp__leadocean__<tool>. max_turns stops a loop.
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="Draft this week's outreach.", 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. Leave headroom: the SDK treats it as an estimate.
Step 5: Read the balance
After a run, read your account. GET /v1/account is free.
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jqOne run searches 10 people (10 records) and enriches 3 (3 records). That is about 13 records, so the free 1,000 covers dozens of test runs. Read each draft against the tool results before you use it.
What you get
Step 2's count tool is free. On 21 October 2026 the filters from the system prompt returned this from the free LeadOcean count tool.
{
"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",
"contact_current_experiences": [ { "...": "current role and company" } ] },
"meta": { "source_ids": { "person_id": "<digits>" } }
}
],
"meta": { "count": 10, "limit": 10, "nextCursor": "<opaque cursor>" }
}Search rows carry 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), which is where the personal line in each draft comes from.
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
Does the agent send the emails?
No. It returns drafts. LeadOcean is a data source, not a sequencer, dialer or CRM, so you paste approved drafts into the tool you already send from.
Why REST and not LeadOcean's MCP server?
The Agent SDK docs say it does not open a browser for sign-in. LeadOcean's MCP server uses OAuth 2.1, so you would fetch a token yourself and pass it in headers. REST with an API key is simpler for a headless agent.
How do I stop it spending too many records?
Count in code, as in Step 2. The tools refuse once RECORD_CAP is hit, whatever the prompt says. One person returned counts as one record.
What does a run cost?
On LeadOcean, about 13 records. Claude tokens are billed by Anthropic and vary with your prompt. For regular volume, see pricing: Solo is $97 a month and Agency is $497 a month.
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.
Related reading
Same recipe on other stacks: OpenAI Agents SDK and Vercel AI SDK. To score leads instead of writing to them, see build a lead qualification agent with the Claude Agent SDK. More recipes are in the blog hub.
Give your AI SDR live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →