You build the AI SDR as a CrewAI crew with two LeadOcean tools: one searches people, one enriches them. A researcher agent calls the tools. A writer agent drafts the email. The whole run spends about 15 records.
TL;DR
- Time: about 30 minutes from an empty folder to a working crew.
- Cost: 15 LeadOcean records per run (10 from search, 5 from enrichment). The Free plan has 1,000 records for life, so roughly 66 runs.
- The tools are plain REST calls with
x-api-key. They turn a 402 or 429 into a message the agent can act on.
Prerequisites
- Python 3.10 or newer and
pip install 'crewai[tools]' requests. - A LeadOcean API key in
$LEADOCEAN_API_KEY. Start free, no card. The Free key has both scopes: search and enrich. - An LLM key for CrewAI (for example
OPENAI_API_KEY). CrewAI reads it from the environment.
The full code is in the steps below. Paste the blocks in order into one crew.py.
Step 1: Size the list for free
Count before you spend. A count costs no records, so check the audience first.
curl -s "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"]}'count=true goes in the query string, not the JSON body. The body is flat JSON and every filter is an array of strings. Enum values are exact: VP, not vp. Check them with leadocean_list_enum_values or GET /v1/enums.
Step 2: Write the search tool
CrewAI tools subclass BaseTool with a Pydantic args_schema and a _run method, per the CrewAI tools docs (September 2026). The tool below calls POST /v1/people/search and returns a short JSON string.
import json, os, time
import requests
from typing import Type
from pydantic import BaseModel, Field
from crewai.tools import BaseTool
API = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "Content-Type": "application/json"}
def call(method, path, **kw):
"""One request with the 402 and 429 handling the agent needs."""
for attempt in range(3):
r = requests.request(method, API + path, headers=HEADERS, timeout=30, **kw)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "1")))
continue
if r.status_code == 402:
return {"stop": "LeadOcean records are spent. Do not retry. Tell the user to upgrade."}
if r.status_code == 404:
return {"not_found": True}
r.raise_for_status()
return r.json()
return {"stop": "Rate limited three times. Wait a minute and try again."}
class SearchInput(BaseModel):
titles: list[str] = Field(..., description='Job title keywords, e.g. ["VP Sales"]')
country: list[str] = Field(default=["US"], description="ISO country codes, e.g. ['US','CA']")
limit: int = Field(default=10, ge=1, le=100, description="Rows to return. Each row costs one record.")
class SearchPeople(BaseTool):
name: str = "search_people"
description: str = "Find people by job title and country. Returns up to `limit` rows, one record each."
args_schema: Type[BaseModel] = SearchInput
def _run(self, titles: list[str], country: list[str] = ["US"], limit: int = 10) -> str:
body = {"title": titles, "country": country, "limit": min(limit, 10)}
out = call("POST", "/v1/people/search", json=body)
return json.dumps(out)[:6000]The min(limit, 10) is a cost guard. The agent cannot spend more than 10 records on one search, whatever it decides.
Step 3: Write the enrich tool
Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup. Search first, then enrich by person_id, which each search row carries at meta.source_ids.person_id.
class EnrichInput(BaseModel):
person_id: str = Field(..., description="person_id from a search row")
class EnrichPerson(BaseTool):
name: str = "enrich_person"
description: str = "Get the email, status and current role for one person_id. Costs one record."
args_schema: Type[BaseModel] = EnrichInput
def _run(self, person_id: str) -> str:
out = call("POST", "/v1/people/enrich", json={"person_id": person_id, "reveal_email": True})
return json.dumps(out)[:6000]reveal_email: true returns the address and costs no extra record. A person we hold nothing for is a 404 and costs nothing, which the call helper returns as not_found. Read email_status before anything is sent: bounced emails are not refunded.
Step 4: Define the agents and tasks
Two agents keep the roles clean. The researcher owns the tools. The writer has none and cannot spend records.
from crewai import Agent, Task, Crew, Process
researcher = Agent(
role="Prospect researcher",
goal="Find 5 VPs of Sales in Canada with a verified work email",
backstory="You pick people, then enrich only the first 5. You never enrich twice.",
tools=[SearchPeople(), EnrichPerson()],
max_iter=8,
verbose=True,
)
writer = Agent(
role="Outbound writer",
goal="Write one short first email per prospect",
backstory="Plain, under 80 words, one question at the end. No invented facts.",
verbose=True,
)
find = Task(
description="Search for VPs of Sales in CA (limit 10). Enrich the first 5 by person_id. "
"Keep only people whose email_status is verified or catch_all_valid.",
expected_output="A list of name, title, company, email and email_status.",
agent=researcher,
)
draft = Task(
description="For each prospect, write a first email about outbound data. Use only the fields given.",
expected_output="One email per prospect with a subject line.",
agent=writer,
context=[find],
)
crew = Crew(agents=[researcher, writer], tasks=[find, draft], process=Process.sequential)
print(crew.kickoff())max_iter=8 caps the loops, which caps the spend if the model tries to retry a failing tool.
Step 5: Use MCP instead of REST (optional)
CrewAI agents accept MCP servers through mcps=[...] and MCPServerHTTP, per the CrewAI MCP docs (September 2026). LeadOcean's hosted MCP server is at https://api.leadocean.io/mcp and exposes 13 tools. It signs in with OAuth 2.1, so there is no key to paste.
from crewai.mcp import MCPServerHTTP
researcher = Agent(
role="Prospect researcher",
goal="Find 5 VPs of Sales in Canada",
backstory="Count first, then search.",
mcps=[MCPServerHTTP(url="https://api.leadocean.io/mcp", streamable=True)],
)OAuth suits an interactive client. A scheduled crew with no browser is simpler on REST with a key, which is why the main path above uses it. The free MCP tools (leadocean_count_leads, leadocean_list_filters, leadocean_list_enum_values) never spend records.
What you get
The free count for the audience in Step 1, run with leadocean_count_leads on 21 October 2026 (VP, Sales & Business Development, country CA, mailable by default):
{
"total": 5574,
"totalIsExact": true,
"totalAll": 18800,
"distinctCompanies": 3873,
"credits": 0
}total counts people whose best email is verified or catch-all. totalAll is the same filters with no email condition.
A person record from enrich, response shape from the API reference (placeholder values):
{
"success": true,
"data": {
"profile_data": { "...": "Jane Doe, VP Sales, Acme" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_emails": [{ "...": "jane.doe@acme.com" }],
"contact_current_experiences": [{ "...": "current role at Acme" }]
},
"resume_data": { "...": "past roles, education, when held" }
}
}Your crew run ends with five drafted emails. Cost: 10 search rows plus 5 enrichments is 15 records. A 404 on enrich is free, so a run can only come in under that.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Export LEADOCEAN_API_KEY and check the header name. |
| 429 | Over the plan's rate limit (5 requests a second on Free, 200 on Agency) | Honour Retry-After. The call helper sleeps and retries. |
| 402 | Records spent. On Free the 1,000 are a one-off and do not reset | Return a stop message so the agent does not loop. Upgrade on pricing. |
Empty data | Enum value in the wrong case (vp instead of VP) | Resolve values with leadocean_list_enum_values first. |
| 400 on count | count=true sent in the JSON body | Move it to the query string. |
| Export refused | A request over 50,000 rows, or past 500,000 rows a day | Split the export. Bulk CSV export is not on Free. |
FAQ
How many records does one run cost?
Fifteen here: 10 search rows and 5 enrichments. Counts and empty enrichments cost nothing.
Should I use REST or MCP with CrewAI?
Use REST with a key for scheduled or headless crews. Use MCP when a person signs in interactively and you want the tool list without writing wrappers.
Does CrewAI send emails?
No. This crew drafts. Send through your own sequencer, and read email_status first.
Which plan do I need?
Free is enough to build and test. Solo at $97 a month raises the rate limit to 50 requests a second. Agency at $497 a month suits teams running many crews. See pricing.
Can I build the same thing in another framework?
Yes. See the guides for Pydantic AI, Mastra and LlamaIndex.
Give your CrewAI agents real prospect data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →