TL;DR
- You write three typed tools (count, search, enrich) and hand them to a Vercel AI SDK
ToolLoopAgent. The agent scores each lead against your ICP and returns a ranked list. - It takes about 40 minutes. The example run spends about 13 of your 1,000 free LeadOcean records. Counting is free.
- Call LeadOcean over REST with your key. The AI SDK can also load LeadOcean's MCP server, but that server signs in with OAuth, so a headless agent needs an
authProvider.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off, with 5 requests a second.
- Node 20 or newer, and the packages:
npm install ai zod, plus@ai-sdk/mcpfor Step 5. - A model the AI SDK can reach, such as an AI Gateway key or a provider key in your environment.
- Your LeadOcean key in the shell:
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Write the REST 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 each failure into a sentence the agent can act on. Steps 1 to 3 go in one file, leadocean.ts.
const BASE = "https://api.leadocean.io";
const KEY = process.env.LEADOCEAN_API_KEY!;
const RECORD_CAP = 20; // hard ceiling for one run
let spent = 0; // records spent in this process
type Result = { data?: any; error?: string };
async function call(path: string, body: object, query = ""): Promise<Result> {
for (let attempt = 0; attempt < 2; attempt++) {
const r = await fetch(`${BASE}${path}${query}`, {
method: "POST",
headers: { "x-api-key": KEY, "content-type": "application/json" },
body: JSON.stringify(body),
});
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: "Out of records on this plan. Stop and tell the user." };
if (r.status === 429) return { error: "Rate limited twice. Stop and report what you have." };
if (r.status === 404) return { error: "Nobody matched. This lookup cost nothing." };
if (!r.ok) return { error: `HTTP ${r.status}: ${(await r.text()).slice(0, 300)}` };
return { data: await r.json() };
}
return { error: "Unreachable" };
}A 402 means the plan has no records left. A 404 on enrich means nobody matched, and it costs nothing.
Step 2: Define the three tools
Each tool is a tool({ description, inputSchema, execute }) object, as the AI SDK tools docs show. Errors come back as plain return values, so the model reads your message instead of a stack trace.
import { tool } from "ai";
import { z } from "zod";
const icp = {
jobLevel: z.array(z.string()).optional(),
jobFunction: z.array(z.string()).optional(),
country: z.array(z.string()).optional(),
industry: z.array(z.string()).optional(),
employeeRange: z.array(z.string()).optional(),
};
export const countAudience = tool({
description: "Count mailable people for an ICP. Free. Call this first.",
inputSchema: z.object(icp),
execute: async (args) => {
const { data, error } = await call("/v1/people/search", { ...args, limit: 1 }, "?count=true");
return error ?? data.meta;
},
});
export const searchPeople = tool({
description: "Search people for an ICP. Costs 1 record per person returned. Max 10.",
inputSchema: z.object({ ...icp, limit: z.number().int().min(1).max(10) }),
execute: async (args) => {
if (spent + args.limit > RECORD_CAP) return "Record cap reached. Do not search again.";
const { data, error } = await call("/v1/people/search", args);
if (error) return error;
spent += data.data.length;
return data;
},
});
export const enrichPerson = tool({
description: "Full lookup for one person by person_id. Costs 1 record.",
inputSchema: z.object({ person_id: z.string() }),
execute: async ({ person_id }) => {
if (spent + 1 > RECORD_CAP) return "Record cap reached. Do not enrich again.";
const { data, error } = await call("/v1/people/enrich", { person_id, reveal_email: true });
if (error) return error;
spent += 1;
return data;
},
});
export const getSpent = () => spent;The cap lives in your code, not the prompt. A model that ignores an instruction still cannot spend past RECORD_CAP.
Step 3: Write the qualification rubric
The instructions hold the ICP and the scoring rules. Keep the rules checkable. Acme is a placeholder for your own company.
export const INSTRUCTIONS = `You qualify leads for Acme, which sells project management software.
ICP: VP of Sales at US software companies with 51 to 200 staff.
Procedure:
1. Call countAudience with the ICP filters. Report the total.
2. Call searchPeople with the same filters and limit 10.
3. Score each person 0 to 3: +1 title matches "VP Sales" or "VP of Sales",
+1 contact_data.email_status is verified, +1 the company is a software company.
4. Call enrichPerson for the 3 highest scores only.
5. Return a ranked list: name, title, company, score, one-line reason.
Rules: use only fields that came back in a tool result. If a field is missing,
write "unknown". Never call a tool more than the procedure says.
Filter values are exact strings: jobLevel "VP", jobFunction "Sales & Business Development",
country "US", industry "Software Development", employeeRange "51-200".`;Step 4: Run the agent
ToolLoopAgent runs the tool loop for you. stopWhen: isStepCount(8) sets the step limit explicitly, as the loop control docs recommend, instead of relying on the default of 20.
import { ToolLoopAgent, isStepCount } from "ai";
import { countAudience, searchPeople, enrichPerson, getSpent, INSTRUCTIONS } from "./leadocean";
const agent = new ToolLoopAgent({
model: "anthropic/claude-sonnet-5.5",
instructions: INSTRUCTIONS,
tools: { countAudience, searchPeople, enrichPerson },
stopWhen: isStepCount(8),
});
const result = await agent.generate({ prompt: "Qualify this week's leads." });
console.log(result.text);
console.log(`LeadOcean records spent: ${getSpent()}`);Step limits bound model calls, which is separate from LeadOcean records. You need both caps.
Step 5: Optional, load LeadOcean over MCP
LeadOcean also runs a hosted MCP server at https://api.leadocean.io/mcp with 13 tools. The AI SDK's createMCPClient loads them as tools, and its HTTP transport takes an authProvider for OAuth. LeadOcean's server supports dynamic client registration at https://api.leadocean.io/oauth/register.
import { createMCPClient } from "@ai-sdk/mcp";
const mcp = await createMCPClient({
transport: {
type: "http",
url: "https://api.leadocean.io/mcp",
authProvider: myOAuthClientProvider, // you implement this, see the AI SDK MCP docs
},
});
const tools = await mcp.tools();MCP gives the agent every LeadOcean tool with no glue code. REST gives you the record cap in Step 2. Pick REST for a scheduled job and MCP for an interactive app where a user can sign in.
Step 6: Check the balance
After the run, read your account. GET /v1/account is free.
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jqThe run searches 10 people (10 records) and enriches 3 (3 records). That is about 13 records.
What you get
Step 2's count tool is free. On 21 October 2026 the same filters as the instructions, run through the free LeadOcean count tool, returned this.
{
"total": 1649,
"totalIsExact": true,
"distinctCompanies": 1125,
"totalAll": 4141,
"credits": 0
}That is 1,649 VPs of Sales at US software companies of 51 to 200 staff, with a verified or catch-all email. Without the email condition the same filters match 4,141 people.
A search page has a different shape. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [
{
"profile_data": { "...": "name, headline, location" },
"contact_data": { "has_email": true, "email_status": "verified", "email_type": "work",
"contact_current_experiences": [ { "...": "current role and company" } ] },
"meta": { "source_ids": { "person_id": "<digits>" } }
}
],
"meta": { "count": 10, "limit": 10, "nextCursor": "<opaque cursor>" }
}Search rows are thin and carry no addresses. The agent passes meta.source_ids.person_id to enrichPerson, which returns the contact points. Person enrich also returns career history in resume_data (past roles, education, certifications), so the agent can cite a previous employer.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again. |
| 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 | The plan's records are spent | Check GET /v1/account. Free records are one-off, so move to a paid plan on pricing. |
| Total of 0 or a 400 | A filter value outside the catalogue, such as industry: "Software" | Resolve the exact string with GET /v1/enums or leadocean_list_enum_values. |
| 404 on enrich | Nobody matched that person_id | Skip the lead. The call costs nothing. |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export into smaller filtered requests. Bulk CSV export is not on Free. |
FAQ
Should I use REST or MCP with the AI SDK?
Use REST for a headless or scheduled agent. A key in x-api-key needs no sign-in step and lets you cap records in code. Use MCP when a person can complete the OAuth sign-in.
How do I keep the agent from overspending records?
Count in code, as in Step 2. The tool refuses once RECORD_CAP is reached, whatever the prompt says. A person returned counts as one record.
Does the agent send emails?
No. It ranks leads and returns data. LeadOcean is a data source, not a sequencer, dialer or CRM.
Can I run it as a Next.js route?
Yes. The tools are plain async functions, so they run in a route handler or a cron job. Keep LEADOCEAN_API_KEY on the server and never ship it to the browser.
What does it cost to run?
LeadOcean: about 13 records a run, so the free 1,000 covers dozens of test runs. Model tokens are billed by your model provider and vary with your prompt. For more volume, see pricing.
Related reading
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 live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →