TL;DR
- Add the LeadOcean MCP server to Codex CLI, test 25 rows in the terminal, then run the full file with a short Python script against
POST /v1/people/enrich. - It takes about 20 minutes. Each person we match spends one record. A person we hold nothing for is a 404 and costs nothing.
- A 200-row file spends at most 200 of your 1,000 free records. The real number is the count of matched rows.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Its keys get both the search and enrich scopes.
- An API key in your shell as
$LEADOCEAN_API_KEY. The Codex steps use OAuth and need no key. The script step does. - Codex CLI, Python 3 and
curl. - A CSV with one identifier per row: an
emailor alinkedin_url. Enrich takes exactly one oflinkedin_url,email,phoneorperson_id. There is no name plus company lookup.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Connect the LeadOcean MCP server
Codex keeps MCP servers in ~/.codex/config.toml, or in .codex/config.toml for a trusted project. The Codex MCP docs, October 2026, show a remote server as a [mcp_servers.<name>] table with a url.
[mcp_servers.leadocean]
url = "https://api.leadocean.io/mcp"
enabled_tools = ["leadocean_get_lead", "leadocean_count_leads", "leadocean_list_enum_values"]
default_tools_approval_mode = "prompt"The last two lines are optional. enabled_tools is an allow list, so Codex can only call the one tool that enriches a person and two free ones. prompt makes Codex ask before each call. Remove both when the pilot is done.
You can also add the server from the shell with codex mcp add leadocean --url https://api.leadocean.io/mcp. Then sign in:
codex mcp login leadoceanYour browser opens on LeadOcean for an OAuth sign-in. No key goes into the file. Our OAuth server supports dynamic client registration, so you add no client ID. Open codex, type /mcp and check that leadocean is listed. The setup for every client, Codex included, is on MCP for agents.
Step 2: Put your contacts in a CSV
Keep the file plain. Use a header row, one person per row and no commas inside fields. The identifier column is what we match on. Name and company are for your own checking.
first_name,last_name,company,email,linkedin_url
Jane,Doe,Acme,jane.doe@acme.com,
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-exampleSave it as contacts.csv in the project. These rows are placeholders. Put your own first 25 rows in pilot.csv, so the first run stays cheap.
Step 3: Pilot 25 rows in the terminal
Run a small batch first. The pilot gives you the match rate and shows whether your identifiers work, before you commit the whole file. Start codex in the folder that holds pilot.csv and paste this.
Read pilot.csv. For each row, call leadocean_get_lead with the email, or the
linkedin_url when there is no email. Stop after 25 rows. Write pilot_enriched.csv
with the original columns plus job title, company, email_status and has_phone.
Skip rows with no identifier. Do not call any other LeadOcean tool that spends
records. At the end, tell me how many rows matched and how many returned nothing.Read each tool call before you approve it. A leadocean_get_lead call that finds nobody costs nothing, the same as the REST 404. Every match costs one record, so 25 rows spend at most 25.
Terminal chat suits a few dozen rows. Past that, each row is a model turn, and a script is faster and cheaper.
Open pilot_enriched.csv before you go on. Check the match rate first: if most rows came back empty, your identifiers are the problem, not the data. Then read email_status, which tells you how much of the file is safe to send.
Step 4: Run the full file with a script
Send one enrich request per row. Set reveal_email to true to get the addresses and phones we hold, at no extra record. Ask Codex to write the file for you, or paste this into enrich.py yourself.
This version is resumable. It appends to enriched.csv and skips any row already written, so a stop on a 402 loses nothing.
import csv, json, os, time, urllib.request, urllib.error
KEY = os.environ["LEADOCEAN_API_KEY"]
URL = "https://api.leadocean.io/v1/people/enrich"
COLS = ["result", "email_status", "best_email", "best_phone"]
def enrich(body):
req = urllib.request.Request(
URL, data=json.dumps(body).encode(), method="POST",
headers={"x-api-key": KEY, "content-type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
if e.code == 429:
time.sleep(int(e.headers.get("Retry-After", "1")))
return enrich(body)
return e.code, None
def ident(row):
return row.get("email") or row.get("linkedin_url") or row.get("first_name", "") + row.get("last_name", "")
done = set()
if os.path.exists("enriched.csv"):
with open("enriched.csv", newline="") as g:
done = {ident(r) for r in csv.DictReader(g)}
with open("contacts.csv", newline="") as f:
rows = list(csv.DictReader(f))
fields = list(rows[0].keys()) + COLS
fresh = not os.path.exists("enriched.csv")
matched = 0
with open("enriched.csv", "a", newline="") as g:
out = csv.DictWriter(g, fieldnames=fields)
if fresh:
out.writeheader()
for row in rows:
key = ident(row)
if key in done:
continue
body = {"reveal_email": True}
if row.get("email"):
body["email"] = row["email"]
elif row.get("linkedin_url"):
body["linkedin_url"] = row["linkedin_url"]
else:
out.writerow({**row, "result": "no_identifier"})
continue
code, data = enrich(body)
if code == 402:
print("Out of records. Stopping.")
break
if code != 200:
out.writerow({**row, "result": "not_found" if code == 404 else f"http_{code}"})
continue
matched += 1
c = data["data"].get("contact_data", {})
emails, phones = c.get("contact_emails") or [], c.get("contact_phones") or []
out.writerow({**row, "result": "matched",
"email_status": c.get("email_status") or "",
"best_email": emails[0]["email"] if emails else "",
"best_phone": phones[0]["phone"] if phones else ""})
print(f"Matched {matched} rows this run. Records spent: {matched}.")Run it with python3 enrich.py in your own shell. The final line is your record cost for that run. To run it again after a stop, change nothing. It picks up where it left.
The script sends one request at a time and waits out any 429, so it stays inside the Free limit of 5 requests a second. It writes a row for every input, so a failed run still leaves a file you can compare against contacts.csv.
Step 5: Check the cost, then check the statuses
GET /v1/account is free. It returns records used and remaining, so you can compare it with the script's count.
curl -s https://api.leadocean.io/v1/account \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records | {used, remaining}'Then filter on email_status before you send anything. Safe to send: verified and catch_all_valid. catch_all cannot be confirmed either way. Do not send to invalid, spam_trap, abuse or disposable. Treat role, risky and derived with care. Bounces are not refunded.
Read the status on the enriched record, not in a search row. Search can show verified for a person who enriches as untested, and the enriched answer is the correct one.
What you get
The output is enriched.csv: your original columns plus result, email_status, best_email and best_phone. Rows with no match say not_found and spent nothing. This is the shape of a matched row, with placeholder values.
first_name,last_name,company,email,linkedin_url,result,email_status,best_email,best_phone
Jane,Doe,Acme,jane.doe@acme.com,,matched,verified,jane.doe@acme.com,+14155550133
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-example,not_found,,,Each enrich call returns this response shape from the API reference, with placeholder values. The contact_emails and contact_phones arrays appear only when reveal_email is true. Since October 2026 a match also carries resume_data: past roles, education, certifications and more, each when we hold it. Past employers carry no company_id yet.
{
"success": true,
"data": {
"profile_data": { "...": "name, headline, current role, company" },
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
],
"contact_phones": [
{ "phone": "+14155550133", "type": "mobile", "priority": 1 }
]
},
"resume_data": {
"experiences": ["<past roles: title, company name, start and end dates, job level>"],
"educations": ["<schools and degrees>"]
}
},
"meta": { "source": "leadocean", "key_hash": "<cache key>" }
}Statuses are exact strings. We read the list from the free leadocean_list_enum_values tool on 2026-10-21. It returned 13 values.
{
"name": "email_status",
"total": 13,
"values": ["verified","catch_all_valid","catch_all","risky","unknown","untested","invalid",
"role","disposable","spam_trap","abuse","derived","none"]
}We did not run a paid enrichment for this page, so there is no measured match rate here. Your cost is the matched count the script prints.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key. | Copy the key again from app.leadocean.io and re-export $LEADOCEAN_API_KEY. |
| 402 | The free plan's 1,000 records are spent. They do not reset. | Check GET /v1/account. Move to Solo at $97 a month. |
| 429 | Above your plan's rate limit (5 a second on Free, 50 on Solo, 200 on Agency), or a fair-use window reached. | Wait the Retry-After seconds. The script above already does, and runs one request at a time. |
| 404 on a row | We hold nothing for that identifier. | Nothing to fix and no record spent. Try the other identifier, or search by domain and enrich by person_id. |
| Empty or rejected filters in the terminal | A value is not in the catalogue, such as a guessed industry. | Have Codex call leadocean_list_enum_values and use the exact string. Add that tool to enabled_tools if you set an allow list. |
400 export_limit_rows | You used an export to fill gaps and limit is above 50,000. | Split the audience and run several exports. The cap is 50,000 rows a request and 500,000 a day. |
FAQ
How many records does a CSV enrichment cost?
One per row we match. A row we hold nothing for is a 404 and costs nothing. A 200-row file therefore spends at most 200 records. Read the script's final count for the real number.
Can I enrich with only a name and a company?
No. Enrich needs one of linkedin_url, email, phone or person_id. With a name and a domain, search by domain first with /v1/people/search, then enrich the person you want by person_id. Search spends one record per person returned.
Can I run this with codex exec instead of the TUI?
Yes, for the pilot. The Codex non-interactive docs, October 2026, say codex exec runs in a read-only sandbox by default. Add --sandbox workspace-write if the run must write pilot_enriched.csv. For the full file, the script is still the better tool.
Does the free plan work for this?
Yes. Free is 1,000 records, one-off, with the same API and MCP server as the paid plans. Solo is $97 a month, with a fair-use pace instead of credits.
How fresh is the data I get back?
The dataset is refreshed monthly. Each record also carries its own fetched_at date, so check it before you rely on a job title for a campaign.
Other clients have the same recipe: Gemini CLI, Windsurf and Cursor. The MCP hub lists every client.
Enrich your first 25 contacts from Codex CLI, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →