You build an AI SDR in Mastra by giving an agent three typed tools: count, search and enrich. LeadOcean serves all three over REST. The agent sizes the list, pulls ten people and enriches the best five.
TL;DR
- Time: 30 to 40 minutes to a working agent.
- Records: about 15 per run. Ten from search, five from enrich. The count call is free.
- Stack: TypeScript,
@mastra/core,zod, and a LeadOcean key from the free plan.
Prerequisites
- A LeadOcean account on the free plan: 1,000 records for life, no card, 5 requests a second. Create a key at app.leadocean.io and export it as
$LEADOCEAN_API_KEY. - Node.js 20 or newer and a TypeScript project with Mastra set up.
- A model key for the provider you pick, for example
ANTHROPIC_API_KEY. npm install @mastra/core zod
LeadOcean also runs a hosted MCP server at https://api.leadocean.io/mcp with 13 tools and OAuth 2.1 sign-in. Mastra's MCPClient connects to remote MCP servers and hands their tools to an agent (Mastra MCP docs, September 2026). This guide uses REST so the cost caps live in your code.
Step 1: Wrap the API in one small client
Every call goes to https://api.leadocean.io with the x-api-key header. Put the 402 and 429 handling in one place so each tool stays short.
// src/mastra/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 } = {},
retries = 3,
): Promise<any | null> {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
for (let attempt = 0; attempt < retries; attempt++) {
const 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,
});
if (res.status === 429) {
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.");
if (res.status === 404) return null;
if (!res.ok) throw new Error(`LeadOcean ${res.status}`);
return res.json();
}
throw new Error("Still rate limited after retries");
}A 429 means you went above the plan's rate limit (5 requests a second on Free). Retry-After says how long to wait. A 402 means the records are gone, so retrying does nothing.
Step 2: Define the three tools
Mastra tools use createTool from @mastra/core/tools with an id, description, inputSchema, outputSchema and an execute function (Mastra tools docs, September 2026). The count tool sends count=true as a query parameter with limit 1. It is free and returns meta.total, capped at 100,000.
// src/mastra/tools.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { call } from "./leadocean";
const filters = {
jobLevel: z.array(z.string()).describe('e.g. ["VP"]'),
jobFunction: z.array(z.string()).describe('e.g. ["Sales & Business Development"]'),
country: z.array(z.string()).describe('ISO codes, e.g. ["US"]'),
};
export const countProspects = createTool({
id: "count-prospects",
description: "Free. Count people matching filters. Always call before searching.",
inputSchema: z.object(filters),
outputSchema: z.object({ total: z.number() }).passthrough(),
execute: async (input) => {
const res = await call("POST", "/v1/people/search", {
query: { count: "true" },
body: { ...input, limit: 1 },
});
return res.meta;
},
});
export const searchProspects = createTool({
id: "search-prospects",
description: "Spends 1 record per person returned. Max 100 per page.",
inputSchema: z.object({ ...filters, limit: z.number().max(100).default(10) }),
outputSchema: z.object({ data: z.array(z.any()) }),
execute: async (input) => {
const res = await call("POST", "/v1/people/search", { body: input });
return { data: res.data };
},
});
export const enrichProspect = createTool({
id: "enrich-prospect",
description: "Spends 1 record. Returns profile, current role and work email.",
inputSchema: z.object({ person_id: z.string() }),
outputSchema: z.object({ result: z.any() }),
execute: async ({ person_id }) => {
const res = await call("POST", "/v1/people/enrich", {
body: { person_id, reveal_email: true },
});
return { result: res ?? "No match. Nothing was spent." };
},
});Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, so the agent searches first and enriches by person_id, which each search row carries at meta.source_ids.person_id.
Step 3: Build the agent
An agent is an Agent from @mastra/core/agent with instructions, a model in provider/model-name form and a tools object (Mastra agents docs, September 2026). The instructions carry the spending rules.
// src/mastra/agents/sdr.ts
import { Agent } from "@mastra/core/agent";
import { countProspects, searchProspects, enrichProspect } from "../tools";
export const sdrAgent = new Agent({
id: "sdr-agent",
name: "SDR Agent",
instructions: `You are an SDR research agent.
1. Always call count-prospects first. If total is over 5,000, narrow the filters.
2. Search at most 10 people. Never page further.
3. Enrich at most 5 people, the best title fits only.
4. Stop and report if a tool says records are spent.
Return a table: name, title, company, work email.`,
model: "anthropic/claude-sonnet-4-5",
tools: { countProspects, searchProspects, enrichProspect },
});Swap the model string for any provider you have a key for. The caps in the prompt are a guide only. Step 5 adds a hard stop.
Step 4: Register and run it
Register the agent on a Mastra instance, fetch it with getAgentById, and call .generate() for the full response.
// src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { sdrAgent } from "./agents/sdr";
export const mastra = new Mastra({ agents: { sdrAgent } });// src/run.ts
import { mastra } from "./mastra";
const agent = mastra.getAgentById("sdr-agent");
const out = await agent.generate(
"Find US VPs of Sales. Enrich the top 5.",
);
console.log(out.text);The agent counts, searches ten, picks five and enriches them. Expect four to eight model turns.
Step 5: Cap spend in code
A prompt is not a budget. Add a counter to the client so a loop cannot burn the 1,000 free records.
// src/mastra/guard.ts
import { OutOfRecords } from "./leadocean";
const LIMIT = 25;
let spent = 0;
export function guard(n: number) {
spent += n;
if (spent > LIMIT) throw new OutOfRecords(`Run cap of ${LIMIT} records reached`);
}Call guard(res.data.length) in searchProspects and guard(1) in enrichProspect when a match comes back. One record is one person you receive. An enrichment that finds nothing costs nothing.
Step 6: Read the tool calls
The generate() result includes toolCalls and toolResults (Mastra agents docs, September 2026). Print them while you test. You want count-prospects first, one search, then at most five enrich calls.
for (const c of out.toolCalls) console.log(c);If the agent searches before it counts, tighten the instructions. If it enriches more than five people, lower LIMIT.
What you get
The count call is free. On 2026-10-21 leadocean_count_leads returned this for VPs in Sales & Business Development in the US. It counts verified, catch_all_valid and catch_all emails by default:
{
"total": 129494,
"totalIsExact": true,
"totalAll": 350517,
"distinctCompanies": 58121
}That is 129,494 people with a mailable address. Without an email filter the same filters match 350,517. Over REST, meta.total stops at 100,000, so the agent sees 100,000 for this list and should narrow it before it searches.
A search page then follows the documented shape. This is the response shape from the API reference, with placeholder values:
{
"data": [{
"profile_data": { "...": "Jane Doe, VP Sales, Acme" },
"meta": { "source_ids": { "person_id": "per_000000" } }
}],
"meta": { "total": 1, "credits": 1, "nextCursor": "..." }
}Enrich returns the current role in contact_current_experiences[], phones in contact_phones[].phone and, since October 2026, career history in resume_data.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is set in the shell that runs Node |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Wait for Retry-After and retry, as in Step 1 |
| 402 | Free plan: the 1,000 records are spent. They do not reset | Stop the run. Paid plans slow down at their pace instead of returning 402. See pricing |
Empty data | A bad enum value, such as vp for jobLevel | Call leadocean_list_enum_values and copy the exact string (VP) |
| 404 on enrich | Nobody matched. It costs nothing | Return "no match" to the agent and move on |
| Export refused | A request over 50,000 rows, or past 500,000 a day | Split the export or run it tomorrow |
FAQ
Can I use LeadOcean's MCP server instead of REST?
Yes. The server at https://api.leadocean.io/mcp exposes 13 tools and signs in with OAuth 2.1. Mastra's MCPClient takes a remote URL and has an authenticate() flow for OAuth servers. REST is simpler to cap and test.
How many records does one run cost?
About 15 in this guide: ten from search and five from enrich. The count call, list_filters and list_enum_values cost nothing. The free plan covers around 65 such runs.
Does this send emails?
No. LeadOcean is a data API. It has no sequencer, dialer or CRM. Pass the table the agent returns to the sender you already use.
Which plan do I need?
Free covers building and testing. Solo at $97 a month suits one person running this daily. Agency at $497 a month fits teams that need 200 requests a second. See pricing.
Where do the other frameworks stand?
The same three tools port to Pydantic AI, LlamaIndex and LangGraph. Only the tool wrapper changes.
Give your Mastra agent real prospect data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →