You give a CrewAI agent B2B data by wrapping three LeadOcean endpoints as tools and capping what one run may spend. The agent sizes an audience for free, searches it, then enriches the people it picks. This guide builds that in about 20 minutes. It is the pattern for b2b data for agents in CrewAI: REST tools you control, with a record cap in code.
TL;DR
- Three CrewAI tools (count, search, enrich) call
https://api.leadocean.iowith one key. The whole thing is two Python files. - It takes about 20 minutes. One run spends 8 LeadOcean records, with a hard cap of 15 set in code.
- Sizing is free. A 402 or a long 429 stops the run instead of looping.
Prerequisites
- A LeadOcean account on the free plan: app.leadocean.io/register. Free gives 1,000 records once, with no card.
- An API key exported as
$LEADOCEAN_API_KEY. Free keys get both scopes, search and enrich. - Python 3.10 to 3.13. CrewAI's install page says it needs
>=3.10and<3.14(CrewAI docs, October 2026). - A model key for your LLM. CrewAI defaults to an OpenAI model and reads
OPENAI_API_KEY(CrewAI docs, October 2026).
Step 1: Install and set the key
Create a folder and install CrewAI and requests. Then export both keys in your shell. Never paste a key into the code.
mkdir crewai-leadocean && cd crewai-leadocean
uv add crewai requests
export LEADOCEAN_API_KEY="paste-your-key-here"
export OPENAI_API_KEY="paste-your-model-key-here"Check the key with a call that costs nothing. GET /v1/account is free and returns your plan and records left.
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY"Step 2: Write one helper that handles every error
Save this as leadocean_tools.py. Every tool goes through one function. It sends the x-api-key header, retries short 429s, backs off on 503 and tracks the records spent. A 402, a bad key or a long 429 sets a stop flag, and every later call returns STOP.
The error shape never varies: { success: false, error: { message } } (LeadOcean docs, October 2026). So one branch covers every endpoint.
import json
import os
import time
import requests
from crewai.tools import tool
BASE = "https://api.leadocean.io"
KEY = os.environ["LEADOCEAN_API_KEY"]
MAX_RECORDS = 15 # hard cap for one run
spent = 0 # records counted so far
stopped = "" # set once a call says the run must end
def retry_after(r):
return int(r.headers.get("Retry-After", 1))
def call(method, path, params=None, body=None):
"""Return (json, "") on success or (None, message) on failure."""
global spent, stopped
if stopped:
return None, f"STOP: {stopped}"
for attempt in range(4):
r = requests.request(method, BASE + path, params=params, json=body,
headers={"x-api-key": KEY}, timeout=30)
if r.status_code == 503: # store unreachable, never billed
time.sleep(2 ** attempt)
continue
if r.status_code == 429 and retry_after(r) <= 5: # per-second limit
time.sleep(retry_after(r))
continue
break
if r.status_code == 401:
stopped = "401: the API key is missing, wrong or revoked."
elif r.status_code == 402:
stopped = "402: the free records are spent. They do not reset."
elif r.status_code == 429 and retry_after(r) > 5:
stopped = f"429: a fair-use window is reached. Retry-After is {retry_after(r)}s."
if stopped:
return None, f"STOP: {stopped}"
if r.status_code == 429:
return None, "ERROR: still rate limited after retries. Try again later."
if r.status_code == 404:
return None, "NOT_FOUND: nobody matched. Nothing was charged."
if r.status_code == 403:
return None, "ERROR: the key lacks the scope for this endpoint."
if r.status_code >= 400:
return None, f"ERROR {r.status_code}: {r.text[:300]}" # 400 lists the bad fields
data = r.json()
meta = data.get("meta") or {}
rows = data.get("data")
spent += meta.get("credits", len(rows) if isinstance(rows, list) else 1)
return data, ""The 402 rule matters on Free. Those 1,000 records are a one-off, so a retry loop would never end. A paid account that reaches a fair-use window is paced to one request a minute instead of refused. The docs say not to retry into that pace.
Step 3: Define the three tools
Add these to the same file. CrewAI's @tool decorator turns a function into a tool. The docstring becomes the description the model reads, so put the exact enum values in it. Enum filters are exact strings: VP works, vice president returns nothing useful.
The search body is flat JSON. Filters are arrays and limit runs 1 to 100. The count call sends count=true as a query parameter with limit 1, which spends nothing.
def filters(title, job_level, job_function, country):
f = {"title": title, "jobLevel": job_level,
"jobFunction": job_function, "country": country}
f = {k: v for k, v in f.items() if v}
f.update(emailStatus=["verified"], emailType=["work"]) # mailable work emails
return f
@tool("Count people")
def count_people(title: list[str] = [], job_level: list[str] = [],
job_function: list[str] = [], country: list[str] = []) -> str:
"""Free. Count verified work-email people matching the filters.
job_level: C-Team, VP, Director, Manager, Staff, Other.
job_function example: Sales & Business Development.
country: ISO codes such as US. Call this before searching."""
body = {**filters(title, job_level, job_function, country), "limit": 1}
data, err = call("POST", "/v1/people/search", params={"count": "true"}, body=body)
return err or json.dumps({"total": data["meta"].get("total")})
@tool("Search people")
def search_people(title: list[str] = [], job_level: list[str] = [],
job_function: list[str] = [], country: list[str] = [],
limit: int = 5) -> str:
"""Costs 1 record per person returned. Same filters as Count people.
Returns person_id, name, title and company. Ask for few rows."""
n = min(limit, 100, MAX_RECORDS - spent)
if n <= 0:
return f"STOP: the run budget of {MAX_RECORDS} records is spent."
body = {**filters(title, job_level, job_function, country), "limit": n}
data, err = call("POST", "/v1/people/search", body=body)
if err:
return err
out = []
for row in data["data"]:
exp = ((row.get("contact_data") or {}).get("contact_current_experiences") or [{}])[0]
out.append({
"person_id": (row.get("meta") or {}).get("source_ids", {}).get("person_id"),
"name": (row.get("profile_data") or {}).get("profile_full_name"),
"title": exp.get("job_title"),
"company": exp.get("company_name"),
})
return json.dumps(out)
@tool("Enrich person")
def enrich_person(person_id: str) -> str:
"""Costs 1 record. Pass a person_id from Search people.
Returns the work email and its status."""
if spent >= MAX_RECORDS:
return f"STOP: the run budget of {MAX_RECORDS} records is spent."
data, err = call("POST", "/v1/people/enrich",
body={"person_id": person_id, "reveal_email": True})
if err:
return err
person = data["data"]
emails = (person.get("contact_data") or {}).get("contact_emails") or []
return json.dumps({
"name": (person.get("profile_data") or {}).get("profile_full_name"),
"emails": [{"email": e.get("email"), "status": e.get("status")} for e in emails],
})Search rows carry person_id at meta.source_ids.person_id. Enrich takes exactly one of linkedin_url, email, phone or person_id. Sending the id back saves you carrying a LinkedIn URL between calls.
A search row never holds an address. That is why enrich is a separate tool, and why the agent only pays for people it picked.
The split also keeps the agent honest about cost. Search is cheap per row and broad. Enrich is one record per person and narrow. The task text tells the model to pick three, so it does not enrich all five.
Step 4: Build the agent and run it
Save this as run_agent.py. The agent gets the three tools and a task that tells it the order: count, search a few, enrich three. Set max_iter low so a confused model cannot loop. CrewAI's default is 20 iterations (CrewAI docs, October 2026).
from crewai import Agent, Crew, Task
import leadocean_tools as lo
scout = Agent(
role="Prospect researcher",
goal="Find named decision makers with a verified work email at the lowest record cost.",
backstory="You size an audience before you pull it. You stop the moment a tool says STOP.",
tools=[lo.count_people, lo.search_people, lo.enrich_person],
max_iter=8,
verbose=True,
)
task = Task(
description=(
"Find 3 VPs of Sales and Business Development in the US. "
"Count the audience first. Search for 5 people, pick 3, enrich them. "
"Never enrich more than 3 people."
),
expected_output="A table of 3 people: name, title, company, work email, email status.",
agent=scout,
)
result = Crew(agents=[scout], tasks=[task]).kickoff()
print(result)
print(f"Records spent this run: {lo.spent} of {lo.MAX_RECORDS}")python run_agent.pyCost per run: the count is free, the search of 5 is 5 records, and 3 enrichments are 3. That is 8 records. The 1,000 free records cover about 125 such runs. On a paid plan (Solo is $97 a month, Agency $497) the same runs have no per-record price, only a fair-use pace. See pricing.
Two more limits are worth setting. max_rpm caps model calls a minute, and max_execution_time caps seconds per task. Both are listed in CrewAI's agent reference. They bound your LLM bill the way MAX_RECORDS bounds your data bill.
The cap is the safety net. If the model asks for 100 rows, search_people trims the request to what is left of the 15.
Log the spend on every run. The last line of run_agent.py prints lo.spent, which adds up meta.credits from each answer. Write it to a file with the date and the task text, and you can see which prompts cost more than they should.
To scale the crew, raise MAX_RECORDS and the search limit together. Keep the free count first. A count that comes back in the hundreds of thousands, like the one below, tells the agent to narrow the filters before it spends anything. One search pages to 10,000 rows at most, so a wide audience has to be split by country, level or company size anyway.
What you get
The free count tool answers before any record is spent. This is a live call to leadocean_count_leads on 2026-10-21, with jobLevel VP, jobFunction Sales & Business Development and country US:
{"total":129494,"totalIsExact":true,"totalAll":350517,"credits":0,
"defaults":"Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all), which is this tool's default.",
"unsupportedFilters":[]}credits is 0. This count is the default mailable audience: verified, catch_all_valid or catch_all addresses. It is not a count of verified-only addresses, which is what the Count people tool in Step 3 asks for. totalAll is the same filters with no email condition.
Next, the shape of a search row from the API reference, with placeholder values. This is the response shape from the API reference, not a real record:
{
"success": true,
"data": [{
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true, "email_status": "verified", "email_type": "work",
"contact_current_experiences": [{ "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }]
},
"meta": { "source_ids": { "person_id": "123456789" } }
}],
"meta": { "count": 1, "credits": 1, "nextCursor": "opaque-string" }
}Read email_status before anything sends. LeadOcean gives no refund or credit-back for bounced emails, so treat verified as the sendable status and check the rest before use. A catch_all address is labelled, not confirmed.
After enrich with reveal_email, contact_data.contact_emails[] holds each address with its status. The agent's final answer is a table of three rows built from those fields. The full code is the two files on this page. Since October 2026 the enrich response also carries past roles and education, so you can add them to the table without another call.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-export $LEADOCEAN_API_KEY in the shell that runs Python. The helper sets the stop flag. |
| 402 | Free plan: the 1,000 records are spent | They are a one-off and do not reset. Move to Solo ($97 a month) or Agency ($497 a month) to keep going. |
429 with Retry-After: 1 | Above the plan's rate limit on one key (5 a second on Free, 200 on Agency) | The helper sleeps and retries. Use full pages instead of many small calls. |
429 with Retry-After: 60 | Paid plan: a fair-use window is reached, so calls are paced to one a minute until it resets | Stop and tell the account owner. Do not retry into it. Check X-Quota-State. |
| 400 or an empty list | A filter value is not an exact enum string | Call leadocean_list_enum_values or GET /v1/enums/job_level and copy the value. |
| 503 | The store behind search is briefly unreachable | The helper backs off and retries. It is never billed. |
FAQ
Can I use the LeadOcean MCP server in CrewAI instead?
Not as documented today. CrewAI's MCP page shows MCPServerHTTP authenticating with static headers only (CrewAI docs, October 2026). The LeadOcean MCP server signs you in with OAuth 2.1 in a browser (LeadOcean docs, October 2026). Use the REST tools above: they need only the key, and they carry the record cap.
Does a count cost records?
No. A search with count=true and limit 1 spends nothing, and meta.total carries the number. A count with a larger limit is an ordinary paid page.
What stops the agent from spending my free records?
Two things. MAX_RECORDS trims every search to what is left of the run budget. A 402 sets the stop flag, so no later call goes out.
Can the agent get phone numbers too?
Yes, with GET /v2/people/phone. It costs one record per call, including a call that finds nothing. Add it as a fourth tool and raise MAX_RECORDS to match.
Where do the other framework guides live?
See the same recipe for Pydantic AI, Mastra and LlamaIndex. More recipes are in the blog hub.
Give your CrewAI agent real B2B data today
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →