Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Give a CrewAI Agent B2B Data (With LeadOcean)

Three tools, one error helper and a record budget. A CrewAI agent sizes an audience for free, then spends 8 records on three contacts.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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.io with 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.10 and <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.

bash
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.

bash
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.

python
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.

python
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).

python
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}")
bash
python run_agent.py

Cost 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:

json
{"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:

json
{
  "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

ErrorCauseFix
401Missing, wrong or revoked keyRe-export $LEADOCEAN_API_KEY in the shell that runs Python. The helper sets the stop flag.
402Free plan: the 1,000 records are spentThey 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: 1Above 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: 60Paid plan: a fair-use window is reached, so calls are paced to one a minute until it resetsStop and tell the account owner. Do not retry into it. Check X-Quota-State.
400 or an empty listA filter value is not an exact enum stringCall leadocean_list_enum_values or GET /v1/enums/job_level and copy the value.
503The store behind search is briefly unreachableThe 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 →