TL;DR
- You give a Vercel AI SDK agent three LeadOcean tools (count, search, enrich). A prompt drafts a first-touch email per lead. You review the drafts. Nothing is sent.
- It takes about 45 minutes to build. Each run spends about 13 LeadOcean records: 10 from one search, 3 enrichments. Counting is free.
- The code calls LeadOcean over REST with an API key. An MCP route also exists (see the FAQ), but REST gives you exact control over the record budget.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off, at 5 requests a second.
- Node 20 or newer and a model key for the provider you pick.
- Install the packages:
npm i ai zod. - Your LeadOcean key in the shell:
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Write one fetch helper with 402 and 429 handling
Every tool shares one function. It sends the x-api-key header, retries a 429 once after Retry-After, and turns a 402 into a message the agent can act on.
// leadocean.ts
const BASE = "https://api.leadocean.io";
const KEY = process.env.LEADOCEAN_API_KEY!;
export const spent = { records: 0 };
const RECORD_CAP = 20; // hard ceiling for one run
export async function lo(method: "GET" | "POST", path: string, body?: unknown) {
for (let attempt = 0; attempt < 2; attempt++) {
const r = await fetch(BASE + path, {
method,
headers: { "x-api-key": KEY, "content-type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (r.status === 429 && attempt === 0) {
const wait = Number(r.headers.get("retry-after") ?? "1");
await new Promise((res) => setTimeout(res, wait * 1000));
continue;
}
if (r.status === 402) return { error: "records_spent", hint: "Stop. The free records are spent." };
if (r.status === 429) return { error: "rate_limited", hint: "Stop. Retry later." };
if (r.status === 404) return { error: "not_found", hint: "Nobody matched. This cost nothing." };
if (!r.ok) return { error: `http_${r.status}`, detail: await r.text() };
return r.json();
}
}
export function reserve(n: number) {
if (spent.records + n > RECORD_CAP) return false;
spent.records += n;
return true;
}A 402 means the 1,000 free records are spent. Paid plans slow down at their pace instead. A 404 on enrich means nobody matched and costs nothing. Returning errors as values lets the model read them instead of crashing the loop.
Step 2: Define the three tools
The AI SDK takes a description, an inputSchema (Zod) and an execute function per tool. Search filters are arrays of strings, and limit runs from 1 to 100. Enrichment takes the person_id that a search row carries at meta.source_ids.person_id.
// tools.ts
import { tool } from "ai";
import { z } from "zod";
import { lo, reserve } from "./leadocean";
export const tools = {
countAudience: tool({
description: "Free. Count people matching filters before searching.",
inputSchema: z.object({
title: z.array(z.string()),
country: z.array(z.string()).describe("ISO 3166-1 alpha-2, e.g. US"),
}),
execute: async (f) =>
lo("POST", "/v1/people/search?count=true&limit=1", f),
}),
searchPeople: tool({
description: "Search people. Costs 1 record per person returned. Max 10.",
inputSchema: z.object({
title: z.array(z.string()),
country: z.array(z.string()),
limit: z.number().int().min(1).max(10).default(10),
}),
execute: async (f) => {
if (!reserve(f.limit)) return { error: "run_cap", hint: "Record cap reached." };
return lo("POST", "/v1/people/search", f);
},
}),
enrichPerson: tool({
description: "Enrich one person by person_id. Costs 1 record if found.",
inputSchema: z.object({ person_id: z.string() }),
execute: async ({ person_id }) => {
if (!reserve(1)) return { error: "run_cap", hint: "Record cap reached." };
return lo("POST", "/v1/people/enrich", { person_id });
},
}),
};The count tool uses count=true as a query parameter, as the API reference requires. The JSON body rejects unknown fields.
Step 3: Run the agent loop
generateText runs the tool loop for you. stopWhen caps the number of steps, so a confused model cannot loop forever. The prompt fixes the order: count first, then search, then enrich three, then write drafts.
// agent.ts
import { generateText, isStepCount } from "ai";
import { tools } from "./tools";
import { spent } from "./leadocean";
const { text } = await generateText({
model: "anthropic/claude-sonnet-5.5", // any model string your setup supports
tools,
stopWhen: isStepCount(10),
system:
"You are an SDR assistant. Never send anything. " +
"If a tool returns records_spent or rate_limited, stop and say so.",
prompt: `Our product: usage-based billing for SaaS.
1. Count VP-level or Head of Sales people in the US with countAudience.
2. Search 10 with searchPeople.
3. Pick the 3 best fits and enrich each with enrichPerson, using meta.source_ids.person_id.
4. Write one 60-word first-touch email per person. Output drafts only.`,
});
console.log(text);
console.log(`LeadOcean records spent: ${spent.records}`);The model string follows the AI SDK docs for the Vercel AI Gateway. If you call a provider package directly, pass its model object instead. Check the AI SDK tool calling docs (September 2026) for the current helper names, because the SDK renames step helpers between major versions.
Step 4: Guard the budget
The RECORD_CAP in Step 1 is the only thing between a prompt mistake and your 1,000 free records. Keep it. A run that searches 10 and enriches 3 spends 13, so a cap of 20 leaves room for one retry.
Add a second guard for paid plans. Paid plans have a fair-use pace, not credits, so nothing stops mid-run. Read meta.notice on any response: it appears only while the account is being served at a reduced rate.
What you get
The count call is free. It uses the agent's filters: title "VP Sales" or "Head of Sales", country US. This is leadocean_count_leads on 2026-10-21.
{
"total": 18520,
"totalIsExact": true,
"totalAll": 56852,
"distinctCompanies": 12932,
"credits": 0
}total counts mailable people (verified, catch_all_valid or catch_all email status). totalAll is the same filters with no email condition. Counting spent 0 records.
An enriched person comes back in this shape (response shape from the API reference, placeholder values):
{
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_current_experiences": [{ "title": "VP Sales", "company": "Acme" }]
},
"resume_data": {},
"meta": { "source_ids": { "person_id": "1234567890" } }
}The email address appears only when the call sets reveal_email to true. Your agent can then draft from title, company and career history without ever holding an address it should not send to.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported in the shell that runs Node. |
| 429 | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Wait Retry-After seconds. The helper retries once, then tells the agent to stop. |
| 402 | Free plan: the 1,000 records are spent | Check GET /v1/account. Free records are one-off, so move to a paid plan on pricing. |
| Empty results | A filter value is not an exact enum string | Call GET /v1/enums or the leadocean_list_enum_values tool and copy the value exactly. |
| Search stops paging | One search pages to 10,000 rows (meta.depthCapped) | Narrow the filters and run several smaller searches. |
| Export refused | A request is limited to 50,000 rows, and 500,000 a day | Split the export. Bulk CSV export is not on Free. |
FAQ
Can the agent use LeadOcean's MCP server instead of REST?
Yes. The AI SDK has an MCP client, createMCPClient, that takes an HTTP transport and an optional authProvider for OAuth (AI SDK MCP docs, September 2026). LeadOcean's server is at https://api.leadocean.io/mcp, exposes 13 tools and signs in with OAuth 2.1. REST is simpler for a server-side agent with a key.
How many records does one run cost?
About 13: 10 from the search and 3 enrichments. Counting, filter lookups and enrichments that find nothing cost nothing. On the free plan that is roughly 75 runs.
Does the agent send the emails?
No, and it should not until a person reads the drafts. LeadOcean has no sequencer, so pass approved drafts to your own sending tool. No refund applies to bounces, so read email_status first.
Where is the full code?
All three files are in this post: leadocean.ts, tools.ts and agent.ts. Copy them in order into one Node project.
What does it cost at volume?
Free covers the first 1,000 records. Solo is $97 a month for one person. Agency is $497 a month for teams, at 200 requests a second. See pricing.
Same recipe on other stacks: OpenAI Agents SDK, Claude Agent SDK and Pydantic AI. More recipes are in the blog hub.
Give your Vercel AI SDK agent real B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →