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

How-to guide

How to build AI-driven personalization (from a people API to a first line)

Pull structured fields, pass only those to a model, and reject any line that cites a fact you did not supply.

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

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).
  • curl and jq.
  • 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.

json
{
  "jobLevel": ["VP"],
  "jobFunction": ["Advertising & Marketing"],
  "country": ["US"],
  "employeeRange": ["201-500"],
  "technologyCategories": ["Marketing automation"]
}
json
{ "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.

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

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

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

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

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

  1. A past role in the function you sell to. It proves they know the problem.
  2. A move in the last 12 months. It signals a new budget and a new stack.
  3. 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.

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

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

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

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

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

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported and sent as x-api-key.
429Above the plan's rate limit (5 a second on Free, 200 on Agency)Honor Retry-After. Add the sleep from Step 3.
402The plan is out of recordsFree is 1,000 one-off records. Check usage with GET /v1/usage, then upgrade or narrow the list.
Empty data arrayA filter value that is not an exact enum stringList valid values with GET /v1/enums/{name}. Seniority is lowercase, such as vp.
404 on enrichWe hold nothing for that personNo charge. Skip the row.
meta.depthCapped trueOne search pages to 10,000 rowsNarrow 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 →