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

How-to guide

How to Use AI to Personalize Cold Emails (Tools and Prompts)

Give the model one verified fact per prospect, not a LinkedIn dump. Here is the query, the prompt and the output check.

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

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

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

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": ["Sales & Business Development"],
    "country": ["CA"],
    "limit": 10
  }' > search.json

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

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

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

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

bash
claude -p "$(sed "s/{fact}/$FACT/" prompt.txt)" > line.txt

Then gate the output before it reaches a sequencer.

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

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

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

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

Expect 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

ErrorCauseFix
401Missing or wrong x-api-keyCheck $LEADOCEAN_API_KEY is exported in the shell that runs the script.
429Above 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.
402Out of records on the Free planCheck GET /v1/account, then move to a paid plan. See pricing.
Empty resultsEnum value typed wrong, for example vp instead of VP in jobLevelList the exact strings with GET /v1/enums.
404 on enrichNothing held for that personNo charge. Skip the row.
Export refusedOver 50,000 rows a request or 500,000 a daySplit 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 →