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

How-to guide

How to Build a Lead Qualification Agent (With the Claude Agent SDK)

Three custom tools, one system prompt and a hard record cap. The agent sizes an audience for free, pulls 10 people, and enriches the 3 best.

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

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

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

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

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}

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

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

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

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

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

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", "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

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

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.

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 →