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

How-to guide

How to Build an AI SDR (With the Claude Agent SDK)

Search, enrich and draft in one agent loop. It finds 10 people, enriches the best 3 and writes a first-touch email for each. It never sends.

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

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:
bash
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.

python
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(), None

A 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.

python
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.

python
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.

python
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.

bash
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jq

One 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.

json
{
  "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.

json
{
  "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

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again.
429Above 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.
402The plan's records are spentCheck GET /v1/account. Free records are one-off, so move to a paid plan on pricing.
Total of 0 or a 400A filter value outside the catalogue, such as industry: "Software"Resolve the exact string with GET /v1/enums or leadocean_list_enum_values.
404 on enrichNobody matched that person_id, or no identifier to enrich bySkip the lead. The call costs nothing.
Tool never runsThe tool name is missing from allowed_toolsUse 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.

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 →