You clean Salesforce data with Claude Code by letting it drive the sf CLI for reads and writes, and LeadOcean for the facts. Claude Code writes the SOQL and the glue script. LeadOcean says what is true today. You approve a diff before any record changes.
The rule that keeps this safe: Claude Code proposes, a CSV diff shows, and only then does a bulk update run.
TL;DR
- It takes about 40 minutes: 10 to connect, 10 to test on 25 Contacts, 20 to review and write back.
- It spends 1 LeadOcean record per matched Contact. A 25-contact test costs up to 25 of the 1,000 free records. No match costs 0.
- Never let the model write to the org directly. Export, enrich, diff, review, then run one
sf data update bulk.
Prerequisites
- A Salesforce sandbox you can write to. Run this in a sandbox first.
- The Salesforce CLI (
sf) logged in to that sandbox:sf org login web --alias sandbox. - Claude Code installed, plus
curlandjq. - A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Size the problem for free
Count how many Contacts are worth enriching before you spend anything. Ask Claude Code to run SOQL through the CLI and report counts.
Using the sf CLI against the "sandbox" org, run these SOQL counts and report
each one. Read only. Do not change any record.
1. SELECT COUNT() FROM Contact WHERE Email != null
2. SELECT COUNT() FROM Contact WHERE Email != null AND Title = null
3. SELECT COUNT() FROM Contact WHERE Email != null AND LastModifiedDate < LAST_N_MONTHS:18Claude Code runs sf data query --query "..." --target-org sandbox three times. The third number is your stale pool. Contacts that nobody has touched in 18 months are where job changes hide.
Run the counts in a sandbox that mirrors production. A sandbox copy with scrambled emails will enrich nothing, so check that a few addresses look real before you go on.
Step 2: Export a 25-row test set
Pull a small batch first. Contacts need an email, because LeadOcean enrich takes one of linkedin_url, email, phone or person_id. There is no name plus domain lookup.
sf data query --target-org sandbox --result-format csv --query \
"SELECT Id, FirstName, LastName, Email, Title, Account.Name
FROM Contact
WHERE Email != null AND LastModifiedDate < LAST_N_MONTHS:18
LIMIT 25" --output-file contacts.csvOpen the file once. If a column is empty where you expect data, fix the query now.
Step 3: Enrich by email
Each request enriches one Contact. POST /v1/people/enrich takes the email, and reveal_email stays false because you already hold the address. You want the current role and the email_status.
echo "Id,email_status,current_title,current_company" > enriched.csv
tail -n +2 contacts.csv | tr -d '\r' | while IFS=, read -r id first last email rest; do
id=$(echo "$id" | tr -d '"'); email=$(echo "$email" | tr -d '"')
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$email\"}" |
jq -r --arg id "$id" '[$id,
(.data.contact_data.email_status // ""),
(.data.contact_data.contact_current_experiences[0].job_title // ""),
(.data.contact_data.contact_current_experiences[0].company_name // "")] | @csv' \
>> enriched.csv
doneThe loop sends one request at a time. That stays under the Free limit of 5 requests a second. A 404 means we hold nothing for that Contact, so jq prints blanks and the row costs nothing.
Ask Claude Code to read the script before it runs. Two checks: the key comes from $LEADOCEAN_API_KEY, and the script only reads from Salesforce.
Step 4: Build a diff, not a write
Have Claude Code compare each Contact with its enrichment and write a file of proposed changes. This is the review gate.
Read contacts.csv and enriched.csv. For each Contact, compare Title to
current_title in enriched.csv. Write proposed.csv with columns:
Id, field, old_value, new_value, reason.
Rules:
- Propose a Title change only when the current role clearly differs.
- If current_company differs from Account.Name, do NOT change Title.
Write the row to moved.csv for a human to decide.
- Never propose an Email change.
- If email_status is invalid, spam_trap, abuse or disposable, write the row
to flagged.csv instead.
Do not call sf. Print counts for each file.The model is good at the fuzzy part: "VP Sales" and "Vice President, Sales" are the same title, "CRO" at a new company is a move. The fuzzy part is also where it can be wrong, so you read the file.
Check moved.csv by hand. A Contact who changed employer is a different record: a new Contact on a new Account, not an edited Title.
Step 5: Write back only what you approved
When proposed.csv looks right, turn the approved Title changes into updates.csv. It has two columns, Id and Title.
sf data update bulk --sobject Contact --file updates.csv \
--target-org sandbox --wait 10The CLI uses Bulk API 2.0 for this command, per Salesforce's own CLI reference (October 2026). It returns a job id and per-record results. Keep the results file. It is your undo list: the old_value column in proposed.csv rebuilds the previous state.
Step 6: Check the result in the org
Verify the write with a query, not with the job status alone. A job can succeed on 24 of 25 rows and still look green.
Run: sf data query --target-org sandbox --result-format csv --query
"SELECT Id, Title, LastModifiedDate FROM Contact WHERE Id IN (<ids from updates.csv>)"
Compare each Title with updates.csv. List any Id whose Title does not match.Claude Code is good at this comparison and bad at remembering it, so make it print the mismatches every time. An empty list means the write landed.
Three habits make the whole loop repeatable. Keep every CSV from a run in a dated folder. Never reuse a proposed.csv from last week, because the data and the org both moved. And log the count of records spent next to the count of rows changed, so you can see your own hit rate.
Step 7: Scale the audience, not the risk
Once the test passes, size a bigger pool for free. This is also how you find net-new Contacts at accounts you already own. The MCP tool leadocean_count_leads costs 0 records.
Use the leadocean MCP server. Count people with title "Salesforce Administrator"
in country US. Do not search or look up anyone. Report the total, the number of
distinct companies, and the top five industries.Add the server first with claude mcp add --transport http leadocean https://api.leadocean.io/mcp, then run /mcp in the session to sign in. Claude Code's docs describe OAuth for remote servers, and no key is pasted into chat.
For a full-table run, move off the loop. Ask Claude Code to batch the work and write the diff file the same way. Solo at $97 a month, 50 requests a second, covers one person's cleanup. Agency at $497 a month, 200 requests a second, covers a RevOps team running several orgs. Pricing has the detail.
A note on cost at larger sizes. A 5,000-Contact stale pool spends at most 5,000 records, and only the matched ones count. That is past the free plan. Size the pool with the free counts first. Then pick Solo at $97 a month or Agency at $497 a month.
What you get
Step 7 returns a count with no spend. We ran it on 2026-10-21:
{"total":4640,"totalIsExact":true,"totalAll":13548,"distinctCompanies":3106,"credits":0,
"defaults":"Counted only addresses worth sending to (verified, catch_all_valid, catch_all)"}That is 4,640 mailable people titled Salesforce Administrator in the US, across 3,106 companies. Without the email filter the same title matches 13,548 people.
Step 3 returns one record per matched Contact. This is the response shape from the enrich docs, with placeholder values:
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_current_experiences": [
{ "job_title": "Salesforce Administrator", "company_name": "Acme", "company_domain": "acme.com" }
]
},
"resume_data": { "experiences": [{ "job_title": "...", "company_name": "..." }] }
},
"meta": { "credits": 1 }
}Since 2026-10-17 enrich also returns resume_data with past roles, which helps you spot a job change. Past employers carry no company_id yet.
Fields that fit the same pattern: Title, Department, and a custom Last_Verified__c date that you stamp when a Contact passes the check. A stamped date turns the 18-month filter in Step 1 into a rolling hygiene job.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | The x-api-key header is missing, or the key is wrong or revoked. | Copy a fresh key from the app and re-export $LEADOCEAN_API_KEY. |
| 402 | Your records are spent. The free plan is 1,000 records, one-off. | Check GET /v1/account, or move to Solo or Agency. |
| 404 | We hold nothing for that email. | Nothing to fix. It costs 0 records. Leave the Contact as is. |
| 429 | Requests above the plan's rate limit: 5 a second on Free, 200 on Agency. | Wait the Retry-After seconds. The one-at-a-time loop avoids it. |
| Empty search results | A filter value is not an exact enum string, such as Vice President instead of VP. | Call leadocean_list_enum_values and pass the exact string. |
| Export refused | One export is capped at 50,000 rows, and an account at 500,000 rows a day. | Split the job across requests and days, or narrow the filters. |
FAQ
Should Claude Code write to Salesforce directly?
No. Let it read and propose. Run the write yourself from an approved CSV, in a sandbox first. A model that edits live records on a bad guess is the failure to design out.
Does LeadOcean replace Salesforce data tools?
No. LeadOcean is a data source with an API and an MCP server. It has no CRM, sequencer or Chrome extension. Claude Code and the sf CLI do the Salesforce side.
How do I match Contacts without an email?
Enrich takes one of linkedin_url, email, phone or person_id. If you store LinkedIn URLs in a custom field, export it and send linkedin_url instead.
Can I enrich Accounts too?
Yes. POST /v1/companies/enrich takes a company domain. Export Website from Account, strip it to a bare domain, and follow the same diff-then-write pattern.
How fresh is the data?
The dataset is refreshed monthly, and each record carries its own fetched_at date. Read email_status before you email, because bounced emails get no refund.
For more recipes, see enrich data prompts, Claude Code Salesforce workflow automation and Claude Code Salesforce skills for admins.
Clean your Salesforce Contacts with data you can check
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →