You build a lead qualification agent in CrewAI by giving it two LeadOcean tools and forcing a typed verdict for each account. One tool lists the people at a domain. The other enriches only the person the agent picked. Total time is about 30 minutes.
Most qualification agents fail on cost, not on logic. They enrich every contact first and judge later. This build reverses that order. The agent reads titles, which are cheap, and buys an email only for the person who passes.
TL;DR
- Two CrewAI tools call
https://api.leadocean.io. The agent scores each account against your ICP and returns a Pydantic verdict. - It takes about 30 minutes. A run over 3 accounts spends about 15 LeadOcean records, with a hard cap of 25 set in code.
- A 402 or a long 429 stops the run. A 404 costs nothing.
Prerequisites
- A LeadOcean account on the free plan: app.leadocean.io/register. Free gives 1,000 records once, with no card, and both search and enrich scopes.
- An API key exported as
$LEADOCEAN_API_KEY. - Python 3.10 or newer, with CrewAI and requests installed (
pip install crewai requests). - A model key for your LLM. CrewAI defaults to an OpenAI model and reads
OPENAI_API_KEY(CrewAI docs, October 2026).
Step 1: Write the ICP as data
Qualification needs a rule the agent can apply, not a vibe. Put your ideal customer profile in one file and pass it into the task. The agent scores buyers against it.
Save this as icp.py:
ICP = {
"buyer_titles": ["VP Sales", "Head of Sales", "Director of Revenue Operations"],
"buyer_levels": ["VP", "Director", "C-Team"],
"must_have": "A named sales or RevOps leader with a title that owns pipeline",
"disqualify": "Student, intern, advisor, or a role in HR, legal or finance",
"accounts": ["acme.com", "example.org", "sample.io"], # placeholder domains
}Keep the rule short. A rule the model cannot check from a title and a company name will drift between runs. Two disqualifiers and three good titles are enough to start.
Job levels are exact strings: C-Team, VP, Director, Manager, Staff, Other. Check them for free with GET /v1/enums before you edit the list.
curl -s https://api.leadocean.io/v1/enums -H "x-api-key: $LEADOCEAN_API_KEY"Step 2: Write one helper that handles every error
Every tool goes through one function. It sends the x-api-key header, retries short 429s and tracks records spent. A 401, a 402 or a long 429 sets a stop flag, and every later call returns STOP so the crew ends instead of looping. Save it as leadocean_tools.py.
The error shape is the same on every endpoint: { success: false, error: { message } } (LeadOcean docs, October 2026). Rate limits are per plan: 5 requests a second on Free, 200 on Agency. Above that you get a 429 with Retry-After.
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 = 25 # hard cap for one run
spent = 0
stopped = ""
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)
wait = int(r.headers.get("Retry-After", 1))
if r.status_code == 503:
time.sleep(2 ** attempt)
continue
if r.status_code == 429 and wait <= 5:
time.sleep(wait)
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 wait > 5:
stopped = f"429: the account is paced. Retry-After is {wait}s."
if stopped:
return None, f"STOP: {stopped}"
if r.status_code == 429:
return None, "ERROR: still rate limited after retries."
if r.status_code == 404:
return None, "NOT_FOUND: nobody matched. Nothing was charged."
if r.status_code >= 400:
return None, f"ERROR {r.status_code}: {r.text[:300]}"
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, ""Step 3: Define the two tools
CrewAI's @tool decorator turns a function into a tool, and the docstring is what the model reads (CrewAI docs, October 2026). Add these to the same file.
GET /v1/companies/{domain}/people lists people at a company and takes limit and cursor. It costs one record per person returned, so the tool asks for 5. Enrich takes exactly one of linkedin_url, email, phone or person_id. An enrichment that finds nothing is free.
@tool("List people at company")
def list_people(domain: str, limit: int = 5) -> str:
"""Costs 1 record per person returned. Pass a company domain such as acme.com.
Returns person_id, name, title and job level for up to 5 people.
Use it once per account, then judge the titles."""
n = min(limit, 5, MAX_RECORDS - spent)
if n <= 0:
return f"STOP: the run budget of {MAX_RECORDS} records is spent."
data, err = call("GET", f"/v1/companies/{domain}/people", params={"limit": n})
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. Only call it for a person who PASSED the ICP check.
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({
"emails": [{"email": e.get("email"), "status": e.get("status")} for e in emails],
})Notice what the tools do not do. They never return a whole record to the model, only the five fields it needs. That keeps the prompt small and the token bill low. It also stops the model from quoting a field you did not mean to expose.
The split is the point. Listing is broad and cheap per row. Enrich is narrow. The agent pays for an email only after it decides the person is a buyer.
Step 4: Force a typed verdict
A qualification agent should return data, not prose. CrewAI's output_pydantic makes the task output conform to a Pydantic model (CrewAI docs, October 2026). Add the model to run_agent.py.
from pydantic import BaseModel
from typing import List, Optional
class Verdict(BaseModel):
domain: str
qualified: bool
reason: str # one sentence, quotes the title
person_id: Optional[str] = None
email: Optional[str] = None
email_status: Optional[str] = None
class Verdicts(BaseModel):
results: List[Verdict]Validation does the rest. If the model returns a verdict without a domain, Pydantic rejects it and CrewAI treats the output as invalid. You get a clean list or a visible failure, never a paragraph you have to parse.
Step 5: Build the agent and run it
Give the agent both tools and 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
from icp import ICP
qualifier = Agent(
role="Lead qualifier",
goal="Decide which accounts have a real buyer and get that buyer's email.",
backstory="You read titles carefully. You stop the moment a tool says STOP.",
tools=[lo.list_people, lo.enrich_person],
max_iter=10,
verbose=True,
)
task = Task(
description=(
f"ICP: {ICP['must_have']}. Disqualify: {ICP['disqualify']}. "
f"Good titles: {', '.join(ICP['buyer_titles'])}. "
f"For each domain in {ICP['accounts']}: list the people once, pick the best "
"buyer if one passes, enrich only that person, and give a one-sentence reason. "
"If nobody passes, set qualified to false and do not enrich."
),
expected_output="One verdict per domain.",
agent=qualifier,
output_pydantic=Verdicts,
)
result = Crew(agents=[qualifier], tasks=[task]).kickoff()
print(result.pydantic.model_dump_json(indent=2))
print(f"Records spent this run: {lo.spent} of {lo.MAX_RECORDS}")python run_agent.pyThe task text carries the whole procedure: list once, judge, enrich one person, explain in a sentence. Models follow a numbered procedure better than a goal. The reason field is your audit trail, because it must quote the title that decided the verdict.
Cost per run: 3 accounts at 5 people each is 15 records, plus one enrichment for each qualified account, up to 3 more. That is 15 to 18 records. The 1,000 free records cover about 55 runs of this size.
On a paid plan, Solo at $97 a month or Agency at $497 a month, there is no per-record price, only a fair-use pace. See pricing.
Step 6: Use MCP instead of REST tools
CrewAI can load the hosted LeadOcean MCP server as tools, which removes the wrapper code but also the budget cap. The server has 13 tools and uses OAuth 2.1 sign-in, so no key is pasted (LeadOcean docs, October 2026). CrewAI documents MCPServerHTTP for remote servers (CrewAI docs, October 2026).
For unattended qualification runs, stay on the REST tools above. They need only the key and keep the cap in your code.
You can also filter by title before the agent sees anything. POST /v1/people/search takes a flat JSON body with array filters and a limit from 1 to 100. Use it when you start from a persona instead of a list of domains. Sizing that search is free with count=true as a query parameter and limit 1.
What you get
The final print is a JSON list with one verdict per account. This is the shape of one item with placeholder values, not a real record:
{
"results": [
{
"domain": "acme.com",
"qualified": true,
"reason": "Jane Doe is VP Sales, which owns pipeline.",
"person_id": "123456789",
"email": "jane.doe@acme.com",
"email_status": "verified"
}
]
}The list call returns the response shape from the API reference. Rows hold profile_data, contact_data and meta.source_ids.person_id. meta.credits shows what the call cost. Read email_status before anything sends. LeadOcean gives no refund for bounced emails, so treat verified as the sendable status.
The full code is on this page: icp.py, leadocean_tools.py and run_agent.py.
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). |
429 with a short Retry-After | Above the plan's rate limit (5 a second on Free, 200 on Agency) | The helper sleeps and retries. Run accounts one at a time. |
429 with a long Retry-After | A paid account is past its fair-use pace and served slower | Stop and tell the account owner. Do not retry into it. |
| 404 on a domain | Nobody matched. Free | The tool returns NOT_FOUND. The agent marks the account unqualified. |
| Empty results from a filter | A value is not an exact enum string | Check GET /v1/enums and copy the value. |
FAQ
Why list people before enriching?
Listing shows titles, which is what qualification needs. Enrich is where the email comes from. Judging first means you pay for an email only on a person who passed. In this build that cuts the enrich bill to at most one record per account.
Does this need MCP?
No. REST tools give you a record cap and one place for 402 and 429 logic. MCP is fine for interactive sessions where a person signs in.
How do I qualify by more than title?
Add fields to the verdict and rules to the ICP, such as company size or country. Keep each rule checkable from the data the tool returns. Do not ask the model to guess what the tools did not return.
What stops the agent from overspending?
MAX_RECORDS trims every list call to what is left of the budget. A 402 sets the stop flag, so no later call goes out.
Where are the other framework versions?
See the same recipe for Pydantic AI, Mastra and LlamaIndex. More recipes are in the blog hub.
Qualify your first accounts with a CrewAI agent
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →