AI personalization works when the model gets one true fact about each prospect and a hard limit on what it may write. It fails when you paste a profile and ask for "a personalized email". This guide builds the working version in four steps.
TL;DR
- The model writes one opening line from one fact. You pull that fact from career history, not from a scraped bio.
- Takes about 30 minutes. The 10-person test spends 20 LeadOcean records (10 search, 10 enrich).
- Gate every line with a check for invented facts before it goes into a sequencer.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY. The Free plan (1,000 records for life, no card, 5 requests a second) covers this guide many times over. - Python 3 with
requests, orcurlandjq. - Any LLM you can call from a script. The examples use the Claude Code CLI in headless mode (docs, checked September 2026). Swap in your own.
- A place to send the result: a CSV is enough. The sequencer is your choice. See best cold email campaigns and warm-up tools.
Step 1: Size the list before you spend anything
Count first. A count costs no records, and it tells you whether the filter is too wide to personalize by hand or by model.
Send count=true as a query parameter with limit=1. The JSON body rejects unknown fields.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["CA"]
}' | jq '.meta'On 21 October 2026 the same filters through the free leadocean_count_leads MCP tool returned 5,574 mailable VPs across 3,873 companies. "Mailable" there means email status verified, catch_all_valid or catch_all. Without an email filter the match was 18,800 people.
Enum values are exact strings. Check them with leadocean_list_enum_values or GET /v1/enums if a search comes back empty.
Step 2: Search for the people and keep the IDs
Search 10 people. This spends 10 records. Keep the person IDs, because enrichment takes person_id.
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["CA"],
"limit": 10
}' > search.jsonThe body is flat JSON and filters are arrays. limit runs 1 to 100, and you page with cursor. One search pages to 10,000 rows, then meta.depthCapped turns true. Narrow the filters instead of walking further.
Each search row carries its ID at meta.source_ids.person_id. That is the value enrich takes as person_id.
Step 3: Enrich for career history, then pick one fact
Enrichment is where personalization material comes from. Since October 2026 a person enrich returns resume_data: past roles (title, company name, dates, job level), education, certifications, awards and more, each when held. Past employers carry no company_id yet.
curl -s -X POST "https://api.leadocean.io/v1/people/enrich" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"person_id": "PERSON_ID_FROM_SEARCH", "reveal_email": true}'Enrich takes exactly one of linkedin_url, email, phone or person_id. A person we hold nothing for is a 404 and costs nothing.
Now choose the fact in code, not in the prompt. Rules that work:
- Prefer a past role at a named company over a vague trait.
- Prefer something older than 90 days. A fresh promotion is already in everyone's opener.
- If no usable fact exists, skip the person. A generic line beats a made-up one.
def pick_fact(person):
# resume_data holds past roles only; the current role is not repeated there
roles = (person.get("resume_data") or {}).get("experiences") or []
for r in roles:
if r.get("job_title") and r.get("company_name"):
return f"Previously {r['job_title']} at {r['company_name']}."
return Nonejob_title and company_name are the experience keys named in the person schema. Read one real response before you run at scale.
Step 4: Prompt the model with one fact and hard limits
The prompt has three jobs: give one fact, cap the length, forbid everything else. Save it as prompt.txt.
You write the first line of a cold email.
Fact about the recipient (the only fact you may use):
{fact}
Rules:
- One sentence, under 20 words.
- Mention the fact once, plainly. No praise, no "I noticed", no "impressive".
- Do not add any other detail about the person or their company.
- If the fact does not support a natural line, reply exactly: SKIP
- Output the sentence only.Run it once per prospect and write the result next to the ID.
claude -p "$(sed "s/{fact}/$FACT/" prompt.txt)" > line.txtThen gate the output before it reaches a sequencer.
def ok(line, fact):
if line.strip() == "SKIP":
return False
if len(line.split()) > 20:
return False
# every capitalised word in the line must appear in the fact
caps = [w for w in line.replace(".", "").split() if w[:1].isupper()]
return all(w in fact for w in caps[1:])The capitalised-word check is crude, but it catches invented company names. Review the first 20 lines by hand before you trust it.
Prompts that fail
Three patterns show up in most bad AI openers. Remove them from your prompt.
- "Write a personalized email to {name}." The model has no fact, so it invents one. Give it the fact or give it nothing.
- "Use their LinkedIn profile." A whole profile invites the model to pick the flashiest detail, often a stale one. Choose the fact in code.
- "Make it sound human." This adds filler. Cap the length and ban phrases instead.
Add a banned-phrase list to the rules if your outputs drift: "I came across", "I noticed", "hope this finds you well". Test on 20 rows and read every line.
The whole loop in one script
This ties the steps together. It reads search.json, enriches each person, picks a fact, calls the model and writes a CSV of lines that passed the gate.
import csv, json, os, subprocess, requests
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
people = json.load(open("search.json"))["data"]
rows = []
for p in people:
pid = p["meta"]["source_ids"]["person_id"]
r = requests.post("https://api.leadocean.io/v1/people/enrich",
headers=H, json={"person_id": pid})
if r.status_code == 404:
continue # nothing held, nothing charged
r.raise_for_status()
fact = pick_fact(r.json()["data"])
if not fact:
continue
prompt = open("prompt.txt").read().replace("{fact}", fact)
line = subprocess.run(["claude", "-p", prompt],
capture_output=True, text=True).stdout.strip()
if ok(line, fact):
rows.append({"person_id": pid, "line": line})
with open("lines.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["person_id", "line"])
w.writeheader()
w.writerows(rows)
print(f"{len(rows)} of {len(people)} lines passed")The ID comes from meta.source_ids.person_id on each search row, as the API reference documents. Join lines.csv back to your export on that ID when you load the sequencer.
Which facts are worth using
Not every field makes a good opener. Rank them before you write any prompt.
- Past employer and role. Safe, specific and checkable. Best default.
- Certification or award. Good when it relates to your offer. Weak when it does not.
- Education. Usable for alumni angles only. Otherwise it reads as a scrape.
- Current role and company. Everyone else uses it. Treat it as the fallback, not the hook.
Use the same fact in the email body only if your offer connects to it. A line about a past sales director role works for a sales-data offer. It does nothing for an unrelated pitch.
What you get
A run of the 10-person test gives you one row per person with a status. The enrich body below is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"email_status": "verified",
"contact_current_experiences": [
{ "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }
]
},
"resume_data": {
"experiences": [
{ "job_title": "Director of Sales", "company_name": "Example Co" }
],
"educations": [],
"certifications": []
}
}
}And the model step, with a placeholder fact:
fact: Previously Director of Sales at Example Co.
line: Your run as Director of Sales at Example Co is why I am writing about outbound data.
check: 17 words, no new capitalised names -> passExpect some skips. A person with no past roles in the data gets no line, and that is correct.
Before you scale past 10 people, send the lines to yourself and read them as a recipient would. Check that each one is true, short and relevant to the offer. If more than two in ten fail, change the fact rule, not the model. Then raise the batch size in Step 2 and keep the gate in place.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported in the shell that runs the script. |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Honour the Retry-After header and add a short sleep between calls. |
| 402 | Out of records on the Free plan | Check GET /v1/account, then move to a paid plan. See pricing. |
| Empty results | Enum value typed wrong, for example vp instead of VP in jobLevel | List the exact strings with GET /v1/enums. |
| 404 on enrich | Nothing held for that person | No charge. Skip the row. |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export by filter. |
FAQ
Should the model write the whole email?
No. Let it write one line from one fact. Keep the offer, the ask and the signature in a template you wrote and tested. See Claude Code cold email automation for the end-to-end loop.
How many records does this cost at 1,000 prospects?
About 2,000: one for each person returned by search and one for each enrichment that finds a person. Free covers 1,000 records for life, so a 1,000-prospect run needs a paid plan. Solo is $97 a month. Agency is $497 a month for teams.
Which LLM should I use?
Any that follows a short instruction reliably. The gate in Step 4 matters more than the model, because it catches invented names whatever you use.
Does AI personalization hurt deliverability?
The line itself does not. Sending unverified addresses does. Read email_status before you send, because bounced emails are not refunded.
Can I do this from a chat instead of a script?
Yes. The LeadOcean MCP server exposes 13 tools, including free counts. Connect it, ask for the count, then the search. The script above is the repeatable version.
Get verified career history for your next 10 prospects
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →