You build the agent as a Mastra Agent with three tools made by createTool. Each tool wraps one LeadOcean REST call. The agent sizes a list, pulls people, and enriches only the best fits.
TL;DR
- Time: about 30 minutes, most of it writing your ICP into the instructions.
- Cost: sizing is free. A run searches 10 people and enriches at most 3, so it spends 13 records of the 1,000 on the Free plan.
- Failure handling: retry on 429 using
Retry-After, stop and report on 402.
Prerequisites
- Node.js 20 or newer and a Mastra project (
npm create mastra@latest). - A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The Free plan gives 1,000 records once, no card. - An LLM key for the model you pick. The example uses the model string from Mastra's docs, so set
OPENAI_API_KEY. npm install @mastra/core zod, plus@mastra/mcpfor Step 4.
Step 1: Write a small LeadOcean client
All three tools share one function. It sends the x-api-key header, retries on 429, and turns 402 into a result the agent can read.
// src/mastra/tools/leadocean.ts
const BASE = "https://api.leadocean.io";
export class OutOfRecords extends Error {}
export async function call(
method: "GET" | "POST",
path: string,
opts: { query?: Record<string, string>; body?: unknown; signal?: AbortSignal } = {},
retries = 3,
): Promise<any> {
const qs = opts.query ? "?" + new URLSearchParams(opts.query) : "";
for (let attempt = 0; ; attempt++) {
const res = await fetch(BASE + path + qs, {
method,
headers: {
"x-api-key": process.env.LEADOCEAN_API_KEY!,
"content-type": "application/json",
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
if (res.status === 429 && attempt < retries) {
const wait = Number(res.headers.get("retry-after") ?? 1);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (res.status === 402) {
throw new OutOfRecords("LeadOcean records are spent. Stop and tell the user.");
}
if (res.status === 404) return { data: null, note: "nobody matched" };
if (!res.ok) throw new Error(`LeadOcean ${res.status}`);
return res.json();
}
}A 404 on enrich means nobody matched. It costs nothing, so the tool returns it as an answer, not a failure.
Step 2: Define the three tools
Mastra tools take an id, a description, an inputSchema, an outputSchema and an execute function (Mastra tools docs, September 2026). The first tool is free. It sends count=true as a query parameter with limit=1, so you learn the list size before spending anything.
// src/mastra/tools/leadocean-tools.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { call } from "./leadocean";
export const sizeList = createTool({
id: "size-list",
description: "Free. Count people matching job titles and ISO country codes.",
inputSchema: z.object({ titles: z.array(z.string()), countries: z.array(z.string()) }),
outputSchema: z.object({ total: z.number(), distinctCompanies: z.number().nullable() }),
execute: async ({ titles, countries }, { abortSignal }) => {
const res = await call("POST", "/v1/people/search", {
query: { count: "true" },
body: { title: titles, country: countries, limit: 1 },
signal: abortSignal,
});
return { total: res.meta.total, distinctCompanies: res.meta.distinctCompanies ?? null };
},
});
export const searchPeople = createTool({
id: "search-people",
description: "Spends 1 record per person returned. Max 10 per call.",
inputSchema: z.object({
titles: z.array(z.string()),
countries: z.array(z.string()),
limit: z.number().max(10).default(10),
}),
outputSchema: z.object({ people: z.array(z.any()) }),
execute: async ({ titles, countries, limit }, { abortSignal }) => {
const res = await call("POST", "/v1/people/search", {
body: { title: titles, country: countries, limit: Math.min(limit, 10) },
signal: abortSignal,
});
return { people: res.data };
},
});
export const enrichPerson = createTool({
id: "enrich-person",
description: "Spends 1 record. Use only on people that pass the ICP check.",
inputSchema: z.object({ personId: z.string() }),
outputSchema: z.object({ person: z.any() }),
execute: async ({ personId }, { abortSignal }) => {
const res = await call("POST", "/v1/people/enrich", {
body: { person_id: personId, reveal_email: true },
signal: abortSignal,
});
return { person: res.data ?? { note: "nobody matched" } };
},
});The body is flat JSON and filters are arrays. Search returns a thin row with no contact points. Each row carries meta.source_ids.person_id, and enrich takes exactly that id.
Step 3: Build the agent
Register the three tools on an Agent and describe them in the instructions. Mastra's docs say naming the tools in the system prompt helps the agent decide when to call one.
// src/mastra/agents/qualifier.ts
import { Agent } from "@mastra/core/agent";
import { sizeList, searchPeople, enrichPerson } from "../tools/leadocean-tools";
export const qualifier = new Agent({
id: "qualifier",
name: "Lead Qualifier",
instructions: `You qualify leads for an outbound team.
ICP: VP or Director of Sales, B2B software, 50 to 500 employees.
1. Call size-list first. If total is above 100,000, narrow the titles.
2. Call search-people once with limit 10.
3. Score each person 1 to 5 against the ICP using title and company.
4. Call enrich-person only for scores of 4 or 5, at most 3 people.
5. If a tool reports records are spent, stop and say so.
Return a table: name, title, company, score, reason, email status.`,
model: "openai/gpt-5.6-sol",
tools: { sizeList, searchPeople, enrichPerson },
});Run it with generate(). It returns the full response after all tool calls finish.
const res = await qualifier.generate("Qualify VP Sales titles in the US.");
console.log(res.text);The cap of 3 enrichments sits in the prompt, so also cap it in code if the run is unattended. A counter inside enrichPerson that throws on a fourth call is five lines.
Step 4: Use the MCP server instead of REST
LeadOcean also runs a hosted MCP server at https://api.leadocean.io/mcp with 13 tools. Mastra's MCPClient loads remote servers by URL, and listTools() hands the result to an agent (Mastra MCP docs, September 2026).
import { MCPClient } from "@mastra/mcp";
export const leadocean = new MCPClient({
id: "leadocean",
servers: { leadocean: { url: new URL("https://api.leadocean.io/mcp") } },
});
// tools: await leadocean.listTools()LeadOcean's MCP server signs in with OAuth 2.1 and takes no pasted key. Mastra documents an authenticate() flow for OAuth-protected servers, so complete that browser sign-in first. LeadOcean supports dynamic client registration, so the client can register itself. We have not tested this client against LeadOcean's server. If your setup cannot sign in, use the REST tools from Step 2.
Choose REST for unattended jobs, because you control retries and the record cap. Choose MCP when a person drives the agent interactively.
What you get
The sizing call is free. This count ran through the LeadOcean MCP leadocean_count_leads tool on 2026-10-21. Filters: jobLevel VP, jobFunction Sales & Business Development, country US. Only mailable addresses (verified, catch_all_valid or catch_all) are counted.
{
"total": 129494,
"totalIsExact": true,
"totalAll": 350517,
"distinctCompanies": 58121,
"distinctCompaniesIsExact": false,
"credits": 0
}credits: 0 confirms the count spent nothing. Without the email filter the same list is 350,517 people.
A search row comes back thin. This is the response shape from the API reference, with placeholder values:
{
"success": true,
"data": [
{
"profile_data": { "...": "name, title, company" },
"contact_data": { "has_email": true, "has_phone": false },
"meta": { "source_ids": { "person_id": "1234567890" } }
}
],
"meta": { "count": 10, "limit": 10, "credits": 10, "nextCursor": "..." }
}Enrich with reveal_email: true adds contact_emails, contact_phones and email_status to contact_data. Read email_status before you send. Bounced emails get no refund.
A run costs 10 records for the search plus up to 3 for enrichment, so 13 at most. A search that returns fewer than 10 people costs less. A 404 on enrich costs nothing.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is set in the shell that runs your Mastra dev server. |
| 429 | Requests above the plan's rate limit: 5 a second on Free, 200 on Agency | Honor Retry-After, as call does. Keep the agent to one tool call at a time. |
| 402 | Records spent. On Free the 1,000 are a one-off and do not reset | Stop the run. Move to a paid plan, see pricing. |
Empty data array | A filter value that does not match an exact string, such as jobLevel spelled in lowercase | Pull valid values from GET /v1/enums or the free leadocean_list_enum_values tool. |
depthCapped true, no nextCursor | One search pages to 10,000 rows | Narrow the filters and run smaller searches. |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export into several requests. |
FAQ
Can the agent run on the Free plan?
Yes. Free keys get search and enrich inside the 1,000 one-off records, at 5 requests a second. That is about 75 runs of 13 records.
Does Mastra need MCP to call LeadOcean?
No. createTool wraps any async function, so plain REST calls work. MCP is an option when your client can complete the OAuth sign-in.
How do I stop the agent overspending?
Cap enrichment in code, not only in the prompt. Count records inside each tool and throw when the budget is gone. Sizing with count=true is free, so size before every search.
Where is the full code?
The client, the three tools and the agent are all in the blocks above. Put them in the three files named in the comments and run the agent from Mastra Studio or a script.
How is this different from the same agent in other frameworks?
The tools are identical. Only the wrapper changes. See the Pydantic AI, LlamaIndex and LangGraph versions, or browse the blog.
Give your Mastra agent live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →