You can give a Vercel AI SDK agent live B2B data with four tool() definitions and one fetch helper. This guide builds that agent against the LeadOcean REST API. It also shows the MCP route, which suits agents a person runs and signs in to.
The agent sizes an audience for free, searches a small page, and enriches a few people. Errors 402 and 429 are handled in code, and a record budget stops a runaway loop.
TL;DR
- Four tools (
listValues,countPeople,searchPeople,enrichPerson) and one shared helper. About 25 minutes. - One run spends 5 to 8 LeadOcean records: 0 for the count, 5 for the search page, up to 3 for enrichments. A budget of 25 is the hard stop.
- 402 and 429 become clear tool errors the model can read, so the agent stops instead of looping.
Prerequisites
- Node.js 20 or later and a TypeScript runner such as
tsx. - A LeadOcean API key from the app, exported as
$LEADOCEAN_API_KEY. The free plan has 1,000 records, one-off, no card, and both scopes (search and enrich). - A model key for the provider you pick. The code uses the model string
anthropic/claude-sonnet-5.5from the AI SDK docs, and you can swap it for any model the SDK supports. - The
ai,zodand@ai-sdk/mcppackages.
The full code for this guide is in the post.
Step 1: Install and set your keys
Install the packages and keep both keys in the environment. Nothing below holds a literal key.
npm install ai zod @ai-sdk/mcp
npm install -D tsx typescript
export LEADOCEAN_API_KEY="..." # from app.leadocean.io
export AI_GATEWAY_API_KEY="..." # or your provider's keyStep 2: Write one HTTP helper
Put every LeadOcean call through one function. It owns the retry rules, counts spent records, and turns each status code into an error the model can read. Save it as leadocean.ts.
const BASE_URL = "https://api.leadocean.io";
export const budget = { limit: 25, spent: 0 };
export class LeadOceanError extends Error {}
const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000));
export async function call(
method: "GET" | "POST",
path: string,
opts: { query?: Record<string, string>; body?: unknown } = {},
) {
const url = new URL(path, BASE_URL);
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
let res: Response | undefined;
for (let attempt = 0; attempt < 3; attempt++) {
res = await fetch(url, {
method,
headers: {
"x-api-key": process.env.LEADOCEAN_API_KEY!,
"content-type": "application/json",
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const wait = Number(res.headers.get("Retry-After") ?? "1");
if (res.status === 429 && wait <= 5) { await sleep(wait); continue; }
if (res.status === 503) { await sleep(2 ** attempt); continue; }
break;
}
const r = res!;
if (r.status === 200) {
const body = await r.json();
budget.spent += body?.meta?.credits ?? 0;
return body;
}
if (r.status === 404) return { found: false }; // nobody matched, costs nothing
const text = await r.text();
if (r.status === 400)
throw new LeadOceanError(`Rejected: ${text}. Check enum values with listValues.`);
if (r.status === 402)
throw new LeadOceanError("Out of records on the Free plan. Stop and tell the user.");
if (r.status === 429)
throw new LeadOceanError("Rate limited. Do not retry now. Tell the user.");
throw new LeadOceanError(`LeadOcean returned ${r.status}: ${text}`);
}When execute throws, the AI SDK records a tool-error part and the model sees it on the next step (AI SDK docs, September 2026). So a clear message is the whole error channel.
The mapping follows the LeadOcean limits page:
- 400 is a validation error. The message tells the model to look up enum values.
- 402 means the free plan is spent. Those records never reset, so retrying is pointless.
- 429 means the key went above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. Wait for
Retry-Afterand go again. - 429 that still holds after two short waits becomes an error, so the model stops instead of hammering the key.
- 503 is transient and never billed, so back off and retry.
- 404 on enrich means nobody matched and costs nothing.
Step 3: Define the four tools
A tool has a description, an inputSchema (a Zod schema) and an execute function (tools docs, September 2026). Save this as agent.ts.
import { ToolLoopAgent, isStepCount, tool } from "ai";
import { z } from "zod";
import { budget, call } from "./leadocean";
const left = () => budget.limit - budget.spent;
const listValues = tool({
description:
"Free. Exact values for a filter: job_level, job_function, country, seniority, industry.",
inputSchema: z.object({ name: z.string(), q: z.string().optional() }),
execute: async ({ name, q }) => {
const body = await call("GET", `/v1/enums/${name}`, {
query: { limit: "50", ...(q ? { q } : {}) },
});
return body.data;
},
});
const countPeople = tool({
description:
'Free. Size a segment. Values are comma-separated, e.g. {"jobLevel": "VP", "country": "CA"}.',
inputSchema: z.object({ filters: z.record(z.string(), z.string()) }),
execute: async ({ filters }) => {
const body = await call("GET", "/v1/people/search", {
query: { ...filters, count: "true", limit: "1" },
});
return body.meta.total as number;
},
});
const trim = (row: any) => {
const contact = row.contact_data ?? {};
const job = contact.contact_current_experiences?.[0] ?? {};
return {
person_id: row.meta?.source_ids?.person_id,
name: row.profile_data?.profile_full_name,
title: job.job_title,
company: job.company_name,
domain: job.company_domain,
email_status: contact.email_status,
};
};
const searchPeople = tool({
description:
"Costs 1 record per person returned. Returns thin rows: name, title, company, person_id.",
inputSchema: z.object({
filters: z.record(z.string(), z.array(z.string())),
limit: z.number().int().min(1).max(25).default(5),
}),
execute: async ({ filters, limit }) => {
if (left() <= 0) throw new Error("Record budget for this run is spent. Stop.");
const body = await call("POST", "/v1/people/search", {
body: { ...filters, limit: Math.min(limit, left()) },
});
return body.data.map(trim);
},
});
const enrichPerson = tool({
description:
"Costs 1 record. Returns the profile and revealed emails for one person_id.",
inputSchema: z.object({ personId: z.string() }),
execute: async ({ personId }) => {
if (left() < 1) throw new Error("Record budget for this run is spent. Stop.");
const body = await call("POST", "/v1/people/enrich", {
body: { person_id: personId, reveal_email: true },
});
return body.data ?? body;
},
});
const agent = new ToolLoopAgent({
model: "anthropic/claude-sonnet-5.5",
instructions:
"You build small B2B prospect lists with LeadOcean. Always call countPeople first. " +
"Search only when the count is under 100,000. Enrich at most 3 people, and only when " +
"asked for contact details. Look up enum values with listValues instead of guessing.",
tools: { listValues, countPeople, searchPeople, enrichPerson },
stopWhen: isStepCount(12),
});
const result = await agent.generate({
prompt: "Find 5 VPs of Sales in Canada and tell me how many exist in total.",
});
console.log(result.text);
console.log(`LeadOcean records spent: ${budget.spent}`);Four choices matter here.
Count before you search. countPeople sends count=true with limit=1. That is a free sizing call and spends no records. The instructions tell the model to call it first.
Enum values are exact strings. jobLevel takes VP, not vp or Vice President. listValues is free, and the 400 message points the model at it when it guesses wrong.
The row is trimmed to six fields. A search row is large. The model only needs the person_id to enrich someone, so trim keeps the rest short and saves model tokens.
Two caps stop a loop. Each response carries meta.credits, the records counted for that call. The helper adds it to budget.spent, and the tools refuse to run once the budget is gone. isStepCount(12) separately caps model steps. The SDK default is 20 (agents docs, September 2026).
Step 4: Run it
Run the file. The agent counts the segment, searches 5 people and prints how many records it spent.
npx tsx agent.tsEnrichment needs a person_id from search. There is no name plus domain lookup, so the agent searches first and enriches second. Each enrichment that finds a person is one record. A 404 costs nothing.
Cost per run is the part to plan for. The search page is 5 records. Three enrichments add 3. On Free, 1,000 records cover about 125 runs of the larger size. Paid plans have no per-record price. Solo is $97 a month and Agency is $497 a month. A key that reaches its fair-use pace is slowed, not cut off. See pricing.
Step 5: Or connect over MCP
The hosted MCP server at https://api.leadocean.io/mcp exposes 13 tools and signs in with OAuth 2.1. There is no key to paste. The AI SDK's createMCPClient takes an HTTP transport and an optional authProvider for OAuth (MCP docs, September 2026).
import { ToolLoopAgent } from "ai";
import { createMCPClient } from "@ai-sdk/mcp";
const mcpClient = await createMCPClient({
transport: {
type: "http",
url: "https://api.leadocean.io/mcp",
authProvider: myOAuthClientProvider, // your OAuthClientProvider implementation
},
});
try {
const agent = new ToolLoopAgent({
model: "anthropic/claude-sonnet-5.5",
tools: await mcpClient.tools(),
});
const result = await agent.generate({
prompt: "Count VPs of Sales in Canada. Do not search.",
});
console.log(result.text);
} finally {
await mcpClient.close();
}You supply myOAuthClientProvider. The server supports dynamic client registration at https://api.leadocean.io/oauth/register, so a client can register itself. That setup fits an agent a person approves in a browser. A server-side agent with no browser should use the REST tools above.
MCP gives the model every tool, including the ones that spend records. search_leads, get_lead, find_phone, get_company, search_companies and export_leads each cost one record per person or company returned. The REST tools above expose four calls and cap the spend in code. Pick MCP for convenience and REST for control.
What you get
The listValues tool returns exact enum strings. Real output from leadocean_list_enum_values for job_level, run on 2026-10-21:
{"name":"job_level","total":6,"values":["C-Team","Director","Manager","Other","Staff","VP"]}The count tool returns one integer. On 2026-10-21, leadocean_count_leads for jobLevel VP, jobFunction Sales & Business Development, country CA returned 5,574 people with a verified, catch-all valid or catch-all email. Without an email filter the same filters matched 18,800 people. A REST count without an emailStatus filter can differ from the mailable figure.
A search row looks like this. Response shape from the API reference, 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": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }
]
},
"meta": { "source_ids": { "person_id": "12345678" } }
}
],
"meta": { "count": 1, "limit": 5, "credits": 1, "nextCursor": "..." }
}Search rows carry flags, not addresses. has_email and email_status say whether an address exists and whether it is safe to send. The address comes from enrichPerson, which is why the instructions say to enrich only when asked. LeadOcean does not refund bounced emails, so read email_status before you send to a list.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Check $LEADOCEAN_API_KEY. Create a new key in the app. |
| 402 | Free plan: the 1,000 records are spent and do not reset | Move to Solo ($97 a month) or Agency ($497 a month). The helper throws, so the agent stops. |
| 429 | More requests a second than the plan allows (5 on Free, 50 on Solo, 200 on Agency) | The helper waits for Retry-After and retries twice. Ask for full pages, not more calls, or move up a plan. |
400 or empty data | A filter value is not an exact enum string | Look it up with GET /v1/enums/{name}. Pass it exactly. |
400 export_limit_rows | An export asked for more than 50,000 rows in one request | Split the pull. The daily ceiling is 500,000 rows. |
FAQ
Should I use REST tools or the MCP server with the AI SDK?
Use REST for server-side agents. You control the schemas, the key and the record cap. Use MCP when a person runs the agent and can sign in through OAuth.
How many records does one run cost?
With these instructions, 5 to 8: a free count, a search page of 5 and up to 3 enrichments. The budget of 25 is the hard limit. Each person or company returned counts one record.
Why enrich by person_id and not by name?
There is no name plus domain lookup. Search by domain or title first, then pass the person_id from the row to POST /v1/people/enrich. It also works for people with no LinkedIn URL.
What happens when the free records run out?
The API answers 402 and the helper throws. The model reads the tool-error, and tells the user. Rejected requests are never metered. A 402 on Free means upgrade, not wait.
Can the agent pull thousands of rows?
Not through search. A page holds 100 rows at most, and one search walk stops at 10,000. For big pulls use POST /v1/exports: up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on Free.
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 →Related guides: OpenAI Agents SDK, Claude Agent SDK, Pydantic AI. More agent guides live on the LeadOcean blog. Plans are on the pricing page.