You automate lead enrichment with Claude by using it twice: once in a chat to size the audience, and once to write a script that runs without it. The script does the daily work. Claude does the thinking up front.
This guide uses the LeadOcean API for data and Claude Code for the script. Every step has a command you can run.
TL;DR
- It takes about 30 minutes: 10 to size and connect, 10 to get a working script, 10 to schedule it.
- The 10-person test spends up to 20 records. Search returns each person at 1 record, and enrich costs 1 more per person.
- Claude writes the script once. A cron job runs it. Claude only runs again if you add the optional scoring step.
Prerequisites
- A LeadOcean API key from app.leadocean.io. The free plan has 1,000 records, one-off, and no card.
- The key in your shell as
$LEADOCEAN_API_KEY. - Claude Code installed, plus
python3,curlandjq. - For the optional scoring step, an
ANTHROPIC_API_KEYas$ANTHROPIC_API_KEY.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Size the list for free
Count the audience before you spend a record. A sizing call is free however often you send it. Send count=true and limit=1 as query parameters, with arrays comma-separated.
curl -s -G https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "jobLevel=VP" \
--data-urlencode "jobFunction=Sales & Business Development" \
--data-urlencode "country=US" \
--data-urlencode "employeeRange=51-200" \
--data-urlencode "emailStatus=verified,catch_all_valid,catch_all" \
--data-urlencode "count=true" \
--data-urlencode "limit=1" | jq '.meta | {total, totalIsExact, countOnly}'meta.total stops at 100,000. If totalIsExact is false, the number is a floor. Enum values are exact strings, so look them up instead of guessing.
The REST call names the three email statuses itself. Drop emailStatus to see everyone who matches, then add it back to see who you can mail. The gap between the two numbers tells you how much of the list will never reach an inbox. Size each audience this way before you write a line of code, because a count of a few hundred and a count of fifty thousand need different scripts.
Step 2: Connect Claude and let it write the script
Add the hosted MCP server, sign in with OAuth, then ask Claude Code to draft the pipeline. The server needs no key in the chat. Claude's docs say to run /mcp in a session to authenticate a remote server.
claude mcp add --transport http leadocean https://api.leadocean.io/mcpUse the leadocean MCP server. Call leadocean_list_enum_values for job_level and
leadocean_count_leads for US VPs in Sales & Business Development at companies of
51-200 people. Do not search or look up any person. Then write enrich.py that:
1. POSTs a flat JSON body to https://api.leadocean.io/v1/people/search
(limit up to 100, emailStatus verified/catch_all_valid/catch_all, cursor paging)
2. reads each row's meta.source_ids.person_id
3. POSTs {"person_id": ..., "reveal_email": true} to /v1/people/enrich
4. skips ids listed in seen.txt and writes enriched.jsonl
Read the key from $LEADOCEAN_API_KEY. Stop if GET /v1/account shows too few records.The first two calls are free. Review the script before you run it. Check that the key comes from the environment and that nothing loops without a cap.
Step 3: Run the script on ten people
A working version looks like this. Enrich takes one of linkedin_url, email, phone or person_id. Search rows carry the person_id, so you never need a name plus domain lookup.
import json, os, sys, time, requests
BASE = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "Content-Type": "application/json"}
MAX_PEOPLE = int(os.environ.get("MAX_PEOPLE", "10"))
SEARCH = {"jobLevel": ["VP"], "jobFunction": ["Sales & Business Development"],
"country": ["US"], "employeeRange": ["51-200"],
"emailStatus": ["verified", "catch_all_valid", "catch_all"]}
seen = set(open("seen.txt").read().split()) if os.path.exists("seen.txt") else set()
def call(method, path, **kw):
for _ in range(5):
r = requests.request(method, BASE + path, headers=H, timeout=60, **kw)
if r.status_code != 429:
return r
time.sleep(int(r.headers.get("Retry-After", "1")))
return r
left = call("GET", "/v1/account").json()["data"]["records"]["remaining"]
if left is not None and left < 2 * MAX_PEOPLE:
sys.exit(f"{left} records left, stopping")
ids, cursor = [], None
while len(ids) < MAX_PEOPLE:
body = dict(SEARCH, limit=min(100, MAX_PEOPLE - len(ids)))
if cursor:
body["cursor"] = cursor
page = call("POST", "/v1/people/search", json=body)
page.raise_for_status()
page = page.json()
ids += [r["meta"]["source_ids"]["person_id"] for r in page["data"]
if r["meta"]["source_ids"]["person_id"] not in seen]
cursor = page["meta"].get("nextCursor")
if not cursor:
break
with open("enriched.jsonl", "a") as out:
for pid in ids:
r = call("POST", "/v1/people/enrich", json={"person_id": pid, "reveal_email": True})
seen.add(pid)
if r.status_code == 404:
continue
r.raise_for_status()
d = r.json()["data"]
c = d.get("contact_data", {})
out.write(json.dumps({
"person_id": pid, "email_status": c.get("email_status"),
"emails": [e["email"] for e in c.get("contact_emails", [])],
"phones": [p["phone"] for p in c.get("contact_phones", [])],
"profile": d.get("profile_data")}) + "\n")
open("seen.txt", "w").write("\n".join(seen))MAX_PEOPLE=10 python3 enrich.py && wc -l enriched.jsonlA person we hold nothing for is a 404 and costs 0. Search itself returns each row at 1 record, which is why the test costs up to 20.
Step 4: Keep only mailable rows
Enrichment returns addresses. It does not say which are safe to send. Filter on email_status before anything reaches your outreach tool, because LeadOcean gives no refund for bounces.
jq -c 'select(.email_status == "verified" or .email_status == "catch_all_valid"
or .email_status == "catch_all")' enriched.jsonl > mailable.jsonl
wc -l enriched.jsonl mailable.jsonlStep 5: Add an optional Claude scoring pass
Use Claude as a judge only where a filter cannot decide. The docs show claude -p for non-interactive runs, with --output-format json and --json-schema for structured output. They recommend --bare for scripted calls, which needs ANTHROPIC_API_KEY.
while read -r row; do
echo "$row" | claude --bare -p "ICP: sales leaders at B2B SaaS companies. Judge \
this person's profile and return fit yes or no with a reason under 15 words." \
--output-format json \
--json-schema '{"type":"object","properties":{"fit":{"type":"string","enum":["yes","no"]},"reason":{"type":"string"}},"required":["fit","reason"]}' |
jq -c --argjson r "$row" '$r + .structured_output'
done < mailable.jsonl > scored.jsonlThis step spends Claude tokens, not LeadOcean records. Skip it if your ICP is fully expressed by filters.
Step 6: Schedule it
Put the run in cron so nobody opens a chat. Cron does not load your shell profile, so keep both keys as export lines in ~/.enrich.env and set it to chmod 600. Then add one line with crontab -e.
0 7 * * 1 cd ~/enrich && . ~/.enrich.env && MAX_PEOPLE=100 python3 enrich.py >> run.log 2>&1Repeat runs on the same filters page through the same people, and search charges for each row it returns again. On the free plan, change the filter each week. Paid plans have no per-record price and no credits. Solo ($97 a month) and Agency ($497) each have a fair-use pace. Reaching it slows the key rather than stopping it.
What a run costs
Each person costs two records in this design: one when search returns the row, one when enrich returns the person. A 404 on enrich costs nothing, and so do counts, enum lookups and GET /v1/account.
| Run | People | Records |
|---|---|---|
| Ten-person test | 10 | up to 20 |
Weekly cron at MAX_PEOPLE=100 | 100 | up to 200 |
| Free plan total | 500 | 1,000, then 402 |
On Free, the 1,000 records are one-off, so the weekly job runs about five times. On Solo, the pace is 10k enrichments every 5 hours and 100k search records every 5 hours, which covers this job many times over. Agency fits teams running several jobs, with 5 keys and 200 requests a second.
To cut the cost per person, skip the search half. If you already hold LinkedIn URLs or work emails, send them straight to enrich and pay one record each.
What you get
Step 2 makes two free calls. We ran them on 2026-10-21. The count is mailable people (verified, catch_all_valid or catch_all, the tool default), not everyone matching the other filters.
{"total":18567,"totalIsExact":true,"totalAll":60456,"distinctCompanies":12192,"defaults":"Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)"}
{"name":"job_level","total":6,"values":["C-Team","Director","Manager","Other","Staff","VP"]}Each enriched person returns the shape below. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"profile_data": { "...": "name, title, company" },
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"contact_emails": [{ "email": "jane.doe@acme.com", "type": "work" }],
"contact_phones": [{ "phone": "+14155550133", "type": "mobile" }],
"contact_current_experiences": [{ "job_title": "VP Sales", "company_name": "Acme" }]
},
"resume_data": {
"experiences": [{ "job_title": "Sales Director", "company_name": "<past employer>", "company_id": null }],
"educations": [{ "educational_establishment": "<school>" }]
}
}
}Without reveal_email, the email and phone arrays are absent. has_email and has_phone still tell you whether a value exists.
That count is 18,567 mailable US sales VPs at companies of 51 to 200 people, on 2026-10-21. Without the email condition the same filters match 60,456 (totalAll). They work at 12,192 distinct companies.
Since 17 October 2026, enrich also returns career history in resume_data: past roles with title, company name and dates, plus education, certifications and skills, each when we hold it. Past employers carry no company_id yet. The current role sits in contact_data.contact_current_experiences. Add a past_roles field to the script's output if your scoring step needs it.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | The x-api-key header is missing, or the key is wrong or revoked. | Copy a fresh key from the app and reload $LEADOCEAN_API_KEY. In cron, check the env file path. |
| 402 | Your records are spent. On Free the 1,000 are a one-off allowance and do not reset. | Run GET /v1/account for the balance, or move to Solo or Agency on pricing. |
| 429 | Above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. | Wait the Retry-After seconds and retry. The script above already does. |
| Empty results | A filter value is not an exact enum string, such as Vice President instead of VP. | Call leadocean_list_enum_values and pass the exact string. |
| 400 Invalid cursor | The cursor was edited, stored from an earlier search, or minted for a different query. | Restart the query and pass back meta.nextCursor unchanged. |
400 export_limit_rows or 429 export_limit_daily | One export is capped at 50,000 rows a request and 500,000 rows a day. Bulk export is not on Free. | Split the job across requests and days, or narrow the filters. |
FAQ
Does Claude run the enrichment each time?
No. Claude writes the script and can score the output, but cron runs the API calls. That keeps the run cheap and repeatable.
How many records does a run spend?
Two per person: one from search and one from enrich. A person we hold nothing for costs 0 on enrich. Counts, filter lookups and account checks are free.
Can I enrich leads I already have?
Yes. Skip the search and POST each email, linkedin_url or phone to /v1/people/enrich. Each request takes exactly one identifier. The CSV recipe covers that route.
Does the MCP route work without a script?
For small batches, yes. Claude calls the tools in chat, and every person returned costs 1 record. Past a few dozen rows, a script keeps long output out of the session. See enrich leads with Claude for the prompt.
Can the Exports page do this?
It builds a file from filters, with a column picker and a price preview. It does not take an uploaded list or run on a schedule. Bulk CSV export is included on Solo and Agency, not on Free.
How fresh is the data?
The dataset refreshes monthly, and each record carries its own fetched_at date. A weekly run on the same filters mostly returns the same people, so dedupe on person_id as the script does.
More recipes live in the blog hub. To compare tools first, read best lead enrichment tools. For CRM-specific lists, see SugarCRM enrichment tools. Plans are on pricing.
Automate your first enrichment run, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →