A live signal is a filter you can re-run, plus a trigger when its result changes. The free count is the trigger, and the search and enrich calls are the payload.
TL;DR
- Time: about 25 minutes. Five steps, one shell script, one MCP command.
- Records: about 30 on the first run (25 search rows, 5 enrichments). Counts, filter lookups and empty enrichments cost nothing.
- Freshness: LeadOcean refreshes monthly and each record carries a
fetched_atdate. You poll for change. You do not get a news feed.
Crustdata's page for this topic describes webhooks that update lists when prospects match new criteria (source: crustdata.com/solutions/ai-sdr, October 2026). This recipe polls instead, which needs no webhook receiver and costs no records.
Prerequisites
- A LeadOcean key in
$LEADOCEAN_API_KEY. The Free plan (1,000 records for life, 5 requests a second) covers everything here. curlandjq.- Optional: Claude Code, for the agent in Step 5.
- A list of signals you care about. Below, the signal is "VP and C-level sales leaders at 51 to 500 person US companies that run HubSpot".
Two limits to accept up front. The data refreshes monthly, so a signal can be up to a month old. Intent and hiring events are not in the dataset, so a recipe that needs them has to bring its own feed.
Step 1: Turn the signal into a filter
Pick signals LeadOcean can filter on. This table shows which ones work as a trigger and which do not.
| Signal you want | LeadOcean filter | Works as a trigger? |
|---|---|---|
| Uses a given tool | technologies, technologyCategories | Yes, for the about 7.8M sites crawled |
| Company size band | employeeRange, minEmployees, maxEmployees | Yes |
| Recent funding round | fundingType, minLastFundingYear | Yes at search time. Not in the free count |
| New decision maker | jobLevel, seniority, jobFunction | Only by diffing the list |
| Hiring, intent, news | None | No. Bring your own feed |
Check the exact enum strings before you write the body, because they are case sensitive. VP and vp are different values, and one of them returns nothing.
curl -s "https://api.leadocean.io/v1/enums/job_level" -H "x-api-key: $LEADOCEAN_API_KEY"The search body is flat JSON and every filter is an array. Save this as signal.json:
{
"jobLevel": ["VP", "C-Team"],
"jobFunction": ["Sales & Business Development"],
"employeeRange": ["51-200", "201-500"],
"hqCountry": ["US"],
"technologies": ["HubSpot"],
"limit": 1
}Add minLastFundingYear at search time for a funding signal. Do not trust a count that carries a funding filter: it can come back unsupported or as a fake round total.
Step 2: Size it with the free count
Send count=true as a query parameter, not in the body. The body rejects unknown fields, and limit: 1 keeps the call free.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d @signal.json | jq '{total: .meta.total, companies: .meta.distinctCompanies}'meta.total is capped at 100,000, so keep the signal narrow enough to stay under it. meta.distinctCompanies tells you how many employers the people span, which is the number an account-based team cares about. Run the count by hand a few times and adjust the filters until the segment is one your team could work in a quarter.
Step 3: Poll the count and fire on change
A cron job that stores yesterday's total turns the filter into a trigger. A changed total means people entered or left the segment.
#!/usr/bin/env bash
set -euo pipefail
API="https://api.leadocean.io/v1/people/search"
H=(-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json")
NOW=$(curl -s -X POST "$API?count=true" "${H[@]}" -d @signal.json | jq '.meta.total')
PREV=$(cat last_total.txt 2>/dev/null || echo 0)
echo "$NOW" > last_total.txt
if [ "$NOW" -eq "$PREV" ]; then echo "no change ($NOW)"; exit 0; fi
jq '.limit = 25' signal.json | curl -s -X POST "$API" "${H[@]}" -d @- \
| jq -r '.data[].meta.source_ids.person_id' | sort > page.txt
touch seen.txt
comm -13 <(sort seen.txt) page.txt > new_ids.txt
cat new_ids.txt >> seen.txt
wc -l < new_ids.txtEach run that fires spends up to 25 records, including rows you saw before. Run it on change only, and tighten the filter if the page keeps returning the same people. Count totals can also drift without new people if data is rebuilt, so treat a change as "look", not "act".
The seen.txt file is the memory of the agent. Without it, every fired run hands the same top 25 people back, and the agent writes to them twice. Keep it in version control or a small table, and prune it when someone replies or opts out.
Step 4: Enrich the new people by person_id
Search gives you meta.source_ids.person_id. Send it to enrich, with reveal_email on, to get the address and career history. Enrich takes exactly one of linkedin_url, email, phone or person_id. An enrichment that finds nothing costs nothing.
head -5 new_ids.txt | while read -r PID; 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\": \"$PID\", \"reveal_email\": true}"
done | jq -s '.' > enriched.jsonRead data.contact_data.email_status and each contact_emails[].status before any send. Bounced emails are not refunded. The current role is in contact_current_experiences[]. Past roles, education and certifications are in resume_data, which is where an agent finds an opener (a past employer, a certification).
Write the opener from facts the record holds, such as the current title or a certification, and nothing else. An agent that invents a reason to reach out burns the list faster than a human does.
Step 5: Give the agent the tools
Scripts get you a trigger. An agent gets you judgement about what to do with it: which person to lead with, what to say, when to skip. LeadOcean does not send email or run sequences, so the agent hands its output to whatever does.
An AI SDR needs the same filters and calls without you writing glue. LeadOcean's hosted MCP server exposes 13 tools, and leadocean_count_leads is free. In Claude Code, per its MCP docs (September 2026):
claude mcp add --transport http leadocean https://api.leadocean.io/mcpThen authorise it with the OAuth sign-in, with no key pasted. A standing instruction keeps the agent inside the budget:
Every morning, run leadocean_count_leads with the saved signal filters.
If the total differs from yesterday's, call leadocean_search_leads with limit 25.
Skip any person_id already in seen.txt. Draft one opener per new person from
their current role and latest past employer. Never call leadocean_find_phone
or leadocean_export_leads without asking me.Two guardrails matter more than the prompt. Cap the daily search at one page of 25 so a rule change cannot drain the Free plan. And keep a human review step before the first send, because the agent reads email_status but does not know your domain reputation.
What you get
The free count for the Step 1 signal, run through the LeadOcean MCP tool leadocean_count_leads on 2026-10-21. It carries a mailable default (verified, catch_all_valid or catch_all addresses):
filters: jobLevel [VP, C-Team], jobFunction [Sales & Business Development],
employeeRange [51-200, 201-500], hqCountry [US], technologies [HubSpot]
total (mailable): 15,543
totalAll: 46,649 (no email filter)
distinctCompanies: 6,526
records spent: 0The 15,543 is a mailable count, not a count of people who will reply. An enriched record looks like this (response shape from the API reference, placeholder values):
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_emails": [{ "email": "jane@acme.com", "type": "work", "status": "verified" }],
"contact_current_experiences": [{ "job_title": "VP of Sales", "company_name": "Acme", "company_domain": "acme.com" }]
},
"resume_data": { "experiences": [], "educations": [], "certifications": [] },
"meta": { "source_ids": { "person_id": "1234567890" }, "fetched_at": "2026-10-01" }
},
"meta": { "credits": 1 }
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported in the cron environment |
| 429 | Requests above the plan's rate limit (5 a second on Free, 200 on Agency) | Honour the Retry-After header and add a short sleep in the loop |
| 402 | The 1,000 Free records are spent | Move to Solo ($97 a month) or Agency ($497 a month); see pricing |
| Empty results | A filter value that is not an exact enum string, such as "VP" written as "vp" | Read /v1/enums/job_level and copy the string as printed |
.meta.total is null | A filter could not be applied, so the total is withheld | Remove the unsupported filter and read unsupportedFilters |
| Cursor stops at 10,000 rows | Search depth cap (meta.depthCapped) | Split the signal into narrower filters, such as one per country |
FAQ
Are these signals real time?
No. The dataset is refreshed monthly, and fetched_at on each record tells you when it was fetched. The recipe detects change at that cadence. For news-grade triggers, pair it with a source that publishes events.
What does a daily run cost?
Nothing when the count has not changed. When it fires, up to 25 records for the page plus one per enrichment. On the Free plan that is about 30 of your 1,000.
Can I use job changes as a signal?
Not as a filter. Person enrich returns career history, so you can read the start date of a current role and decide in code, but there is no "changed jobs" search parameter.
How narrow should a signal be?
Narrow enough that the count stays under 100,000 and the page of 25 is a meaningful slice. A segment of 500 to 20,000 people works well. If the total is in the millions, split it by country or headcount band and run one watch per slice.
Which AI SDR tool does this plug into?
Anything that can call HTTP or MCP: Claude Code, n8n, Clay or your own agent. See what an AI SDR is, the SDR tools compared and the sales MCPs compared, or browse the blog hub.
Point your AI SDR at a signal you can count for free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →