TL;DR
- AI personalization is only as good as the fields you feed the model. Give it a title, an employer and a career fact, and forbid it from inventing anything else.
- It takes about 30 minutes: size the list, search 10 people, enrich them, generate lines, filter the output.
- It spends about 20 LeadOcean records. The Free plan has 1,000, no card.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY(the Free plan works: 1,000 records for life, 5 requests a second). curlandjq.- Python 3 and any LLM client. The script below calls a function named
llm(); wire it to the model you already use. - A sentence about what you sell. The model needs it, and it is the one thing you write by hand.
Step 1: Size the list before you pay for it
Count first. A count spends no records, so check the audience is not empty or enormous.
On 2026-10-21 we ran leadocean_count_leads through the MCP server. The filters: VPs in marketing, US, 201-500 employees, at companies running marketing automation software.
{
"jobLevel": ["VP"],
"jobFunction": ["Advertising & Marketing"],
"country": ["US"],
"employeeRange": ["201-500"],
"technologyCategories": ["Marketing automation"]
}{ "total": 313, "totalIsExact": true, "totalAll": 759, "distinctCompanies": 226, "credits": 0 }313 is the mailable count (verified, catch_all_valid or catch_all addresses). Without the email condition the same filters match 759 people. Run 10 first. Do not write 313 prompts before you know the first 10 read well.
Step 2: Search 10 people
Use the same filters as the count. The search body is flat JSON. Filters are arrays of strings. limit runs from 1 to 100 and paging uses cursor.
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": ["Advertising & Marketing"],
"country": ["US"],
"employeeRange": ["201-500"],
"technologyCategories": ["Marketing automation"],
"limit": 10
}' > search.json
jq -r '.data[].meta.source_ids.person_id' search.json > ids.txt
wc -l ids.txtEach row returned is one record, so this costs 10. The id you need is at meta.source_ids.person_id. It works for every row, including people with no LinkedIn URL.
Step 3: Enrich for career history
Search rows tell you who. Enrichment tells you what they did before, which is what makes a first line specific. Since October 2026, person enrich returns resume_data: past roles, education, certifications and awards, each when we hold it.
while read id; do
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\": \"$id\"}" >> people.jsonl
echo >> people.jsonl
sleep 0.3
done < ids.txtEnrich takes exactly one of linkedin_url, email, phone or person_id. A person we hold nothing for is a 404 and costs nothing. The sleep keeps you under the Free limit of 5 requests a second.
Step 4: Build a fact sheet, not a profile dump
Do not paste the whole record into the prompt. Pick 3 or 4 fields and label them. The model can only cite what you hand it.
import json
def fact_sheet(p):
cur = (p.get("contact_data", {}).get("contact_current_experiences") or [{}])[0]
past = (p.get("resume_data", {}).get("experiences") or [])[:3]
return {
"current_title": cur.get("job_title"),
"current_company": cur.get("company_name"),
"past_roles": [f'{e.get("job_title")} at {e.get("company_name")}' for e in past],
}
rows = [json.loads(l) for l in open("people.jsonl") if l.strip()]
people = [r["data"] for r in rows if r.get("data")] # 404 rows have no data
sheets = [fact_sheet(p) for p in people]Each entry in resume_data.experiences[] carries job_title, company_name, start_date and end_date. The current role is in contact_current_experiences[0] and is not repeated there. Print one sheet before you run the batch.
Step 4b: Pick the hook that fits your offer
Not every fact deserves a line. Rank them before you prompt.
- A past role in the function you sell to. It proves they know the problem.
- A move in the last 12 months. It signals a new budget and a new stack.
- Education or a certification. Use it last, and only if it is relevant.
Drop the rest. A line that cites a hobby or an award reads as surveillance, not research. Rank in code, so the model never chooses the hook.
def pick_hook(sheet):
for role in sheet["past_roles"]:
if "Demand" in role or "Growth" in role:
return role
return sheet["past_roles"][0] if sheet["past_roles"] else NonePass only the winning hook into the prompt. One fact in, one fact out.
Step 5: Prompt with a hard rule
The prompt does the work here. Say what to use, what to avoid and what to return when the facts are thin.
You write the first sentence of a cold email. Use only the FACTS below.
Rules:
- One sentence, under 25 words.
- Mention at most one fact. Prefer a past role over the current title.
- Never state a date, a number, an award or a product that is not in FACTS.
- If FACTS has no past role, return exactly: SKIP
We sell: {one sentence about your product}
FACTS: {fact_sheet as JSON}The SKIP path matters. A model with thin input writes a confident guess. Give it a way out.
Step 6: Reject lines that cite facts you did not supply
Validate in code. A cheap check catches invented names: every capitalized word in the output must appear in the fact sheet or the product sentence.
import re
def grounded(line, sheet, product):
allowed = json.dumps(sheet) + " " + product
words = re.findall(r"\b[A-Z][a-zA-Z]+\b", line)
return all(w in allowed for w in words[1:]) # skip the sentence's first word
for sheet in sheets:
line = llm(prompt_for(sheet))
if line.strip() != "SKIP" and grounded(line, sheet, PRODUCT):
print(line)Read the first 10 lines yourself. If 2 or more feel off, fix the prompt before you scale. It is cheaper to find that at 10 records than at 10,000.
What you get
The count in Step 1 is a real result from 2026-10-21. Below is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Marketing at Acme" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_current_experiences": [{ "job_title": "VP Marketing", "company_name": "Acme", "start_date": "2025-11-01" }]
},
"resume_data": {
"experiences": [{ "job_title": "Director of Demand Generation", "company_name": "Example Co", "start_date": "2020-02-01", "end_date": "2025-10-31" }]
},
"meta": { "source_ids": { "person_id": "1234567890" } }
}
}From that record, a grounded line reads: "You moved from demand gen at Example Co to running marketing at Acme." Every noun traces to a field. That is the test.
Past employers carry no company_id yet, so you cannot join a past role to a company record. Use the name as text.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and sent as x-api-key. |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Honor Retry-After. Add the sleep from Step 3. |
| 402 | The plan is out of records | Free is 1,000 one-off records. Check usage with GET /v1/usage, then upgrade or narrow the list. |
Empty data array | A filter value that is not an exact enum string | List valid values with GET /v1/enums/{name}. Seniority is lowercase, such as vp. |
| 404 on enrich | We hold nothing for that person | No charge. Skip the row. |
meta.depthCapped true | One search pages to 10,000 rows | Narrow the filters and run several searches. |
FAQ
How many records does a personalization test cost?
About 20 for 10 people: 10 for the search page and 10 for the enrichments. Counts are free, and an enrichment that finds nothing costs nothing.
Does the model need the person's full profile?
No. Three or four labeled fields give better lines than a full dump. A long input invites the model to quote something irrelevant.
Should I send the generated line without reading it?
Not at first. Read the first 10, then sample 1 in 20 on every batch. Check the email status too: LeadOcean does not refund bounces, so read email_status before you send.
Where does this fit with email personalization in general?
AI generation is one method. For the basics and the non-AI variants, see email personalization. For the sending side, read the guide to B2B outbound sales and the overview of B2B data for GTM teams.
Can I run this from an MCP client instead of curl?
Yes. The hosted MCP server exposes 13 tools, and counting and listing filters are free there. Record-spending tools such as search_leads cost one record per person, same as the REST API. See pricing for the plans.
Personalize from fields you can trace
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →