You build the agent as a LlamaIndex FunctionAgent with three plain Python tools. Each tool wraps one LeadOcean REST call. The agent sizes a list, pulls people, and enriches only the best fits.
TL;DR
- Time: about 30 minutes, most of it reading your own ICP into a prompt.
- Cost: sizing is free. A run searches 10 people and enriches at most 3, so it spends 13 records of the 1,000 on the Free plan.
- Failure handling: retry on 429 using
Retry-After, stop and report on 402.
Prerequisites
- Python 3.10 or newer.
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The Free plan gives 1,000 records once, no card. - An LLM key for the model you pick (the example uses OpenAI, set
OPENAI_API_KEY). pip install llama-index-core llama-index-llms-openai httpx
Step 1: Write a small LeadOcean client
All three tools share one function. It sends the x-api-key header, retries on 429, and turns 402 into an error the agent can read.
import os, time, httpx
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
class OutOfRecords(Exception):
pass
def call(method, path, params=None, json=None, retries=3):
for attempt in range(retries + 1):
r = httpx.request(method, BASE + path, headers=HEADERS,
params=params, json=json, timeout=30)
if r.status_code == 429 and attempt < retries:
time.sleep(float(r.headers.get("Retry-After", 1)))
continue
if r.status_code == 402:
raise OutOfRecords("LeadOcean records are spent. Stop and tell the user.")
if r.status_code == 404:
return {"success": False, "data": None, "note": "nobody matched"}
r.raise_for_status()
return r.json()A 404 on enrich means nobody matched. It costs nothing, so the agent treats it as an answer, not a failure.
Step 2: Define the three tools
The first tool is free. It sends count=true as a query parameter with limit=1, so you learn the list size before spending anything.
def size_list(titles: list[str], countries: list[str]) -> dict:
"""Free. Count people matching job titles and ISO country codes."""
res = call("POST", "/v1/people/search",
params={"count": "true"},
json={"title": titles, "country": countries, "limit": 1})
return {"total": res["meta"]["total"],
"distinctCompanies": res["meta"].get("distinctCompanies")}
def search_people(titles: list[str], countries: list[str], limit: int = 10) -> list:
"""Spends 1 record per person returned. Max 10 per call."""
res = call("POST", "/v1/people/search",
json={"title": titles, "country": countries,
"limit": min(limit, 10)})
return res["data"]
def enrich_person(person_id: str) -> dict:
"""Spends 1 record. Use only on people that pass the ICP check."""
res = call("POST", "/v1/people/enrich",
json={"person_id": person_id, "reveal_email": True})
return res["data"] or {"note": "nobody matched"}The body is flat JSON and filters are arrays. Search returns a thin row with no contact points. Each row carries meta.source_ids.person_id, and enrich takes exactly that id.
Step 3: Build the agent
Pass the three functions to FunctionAgent. LlamaIndex reads each docstring and type hint as the tool schema, so write them for the model.
import asyncio
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.openai import OpenAI
SYSTEM = """You qualify leads for an outbound team.
ICP: VP or Director of Sales, B2B software, 50 to 500 employees.
1. Call size_list first. If total is above 100,000, narrow the titles.
2. Call search_people once with limit 10.
3. Score each person 1 to 5 against the ICP using title and company.
4. Call enrich_person only for scores of 4 or 5, at most 3 people.
5. If a tool reports records are spent, stop and say so.
Return a table: name, title, company, score, reason, email status."""
agent = FunctionAgent(
tools=[size_list, search_people, enrich_person],
llm=OpenAI(model="gpt-4o-mini"),
system_prompt=SYSTEM,
)
async def main():
out = await agent.run("Qualify VP Sales titles in the US.")
print(str(out))
if __name__ == "__main__":
asyncio.run(main())The prompt asks for at most 3 enrichments. Step 4 enforces it in code.
Step 4: Add a record budget guard
A prompt is a request, not a limit. Put the cap in code so a confused model cannot spend your allowance.
SPENT = {"records": 0}
BUDGET = 13
def guarded(fn, cost_of):
def wrapper(*args, **kwargs):
if SPENT["records"] >= BUDGET:
return {"note": "run budget reached, stop calling tools"}
out = fn(*args, **kwargs)
SPENT["records"] += cost_of(out)
return out
wrapper.__name__, wrapper.__doc__ = fn.__name__, fn.__doc__
return wrapper
search_people = guarded(search_people, lambda out: len(out))
enrich_person = guarded(enrich_person, lambda out: 1)Pass the guarded versions to FunctionAgent. The wrapper keeps the original name and docstring, so the tool schema does not change. Reset SPENT at the start of each run.
The budget also protects you from a loop. If the model keeps calling search, the counter stops it after one page of 10.
Step 5: Use the MCP server instead of REST
LeadOcean also runs a hosted MCP server at https://api.leadocean.io/mcp with 13 tools. LlamaIndex's llama-index-tools-mcp package turns an MCP server into a list of tools.
from llama_index.tools.mcp import BasicMCPClient, aget_tools_from_mcp_url
client = BasicMCPClient.with_oauth(
"https://api.leadocean.io/mcp",
client_name="Lead qualifier",
redirect_uris=["http://localhost:3000/callback"],
redirect_handler=lambda url: print(f"Sign in here: {url}"),
callback_handler=lambda: (input("Paste the code: "), None),
)
tools = await aget_tools_from_mcp_url(
"https://api.leadocean.io/mcp",
client=client,
allowed_tools=["leadocean_count_leads", "leadocean_search_leads",
"leadocean_get_lead"],
)
agent = FunctionAgent(tools=tools, llm=OpenAI(model="gpt-4o-mini"),
system_prompt=SYSTEM)BasicMCPClient.with_oauth and the allowed_tools filter come from the LlamaIndex MCP docs (checked October 2026). LeadOcean's MCP server signs in with OAuth 2.1 and takes no pasted key. It supports dynamic client registration, so the client can register itself. The sign-in needs a person once, and we have not tested this client against LeadOcean's server. If it fails, use the REST tools from Step 2.
Choose REST for unattended jobs, because you control retries and the record cap. Choose MCP when a person drives the agent interactively.
The MCP route reaches the free count tool too. That is how the count in the next section was produced.
What you get
The sizing call is free. This count ran through the LeadOcean MCP leadocean_count_leads tool on 2026-10-21.
Filters: jobLevel VP, jobFunction Sales & Business Development, country US. It counts mailable addresses only (verified, catch_all_valid or catch_all).
{
"total": 129494,
"totalIsExact": true,
"totalAll": 350517,
"distinctCompanies": 58121,
"distinctCompaniesIsExact": false,
"credits": 0
}credits: 0 confirms the count spent nothing. Without the email filter the same list is 350,517 people.
A search row comes back thin. This is the response shape from the API reference, with placeholder values:
{
"success": true,
"data": [
{
"profile_data": { "...": "name, title, company" },
"contact_data": { "has_email": true, "has_phone": false },
"meta": { "source_ids": { "person_id": "1234567890" } }
}
],
"meta": { "count": 10, "limit": 10, "credits": 10, "nextCursor": "..." }
}Enrich with reveal_email: true adds contact_emails, contact_phones and email_status to contact_data. Read email_status before you send. Bounced emails get no refund.
A run costs 10 records for the search plus up to 3 for enrichment, so 13 at most. A search that returns fewer than 10 people costs less. A 404 on enrich costs nothing.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported in the shell that runs Python. |
| 429 | Requests above the plan's rate limit: 5 a second on Free, 200 on Agency | Honor Retry-After, as call does. Keep the agent to one tool call at a time. |
| 402 | Records spent. On Free the 1,000 are a one-off and do not reset | Stop the run. Move to a paid plan, see pricing. |
Empty data array | A filter value that does not match an exact string, such as jobLevel spelled in lowercase | Pull valid values from GET /v1/enums or the free leadocean_list_enum_values tool. |
depthCapped true, no nextCursor | One search pages to 10,000 rows | Narrow the filters and run smaller searches. |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export into several requests. |
FAQ
Can the agent run on the Free plan?
Yes. Free keys get search and enrich inside the 1,000 one-off records, at 5 requests a second. That is about 75 runs of 13 records.
Does LlamaIndex need MCP to call LeadOcean?
No. FunctionAgent takes any Python function as a tool, so plain REST calls work. MCP is an option when your client supports the OAuth sign-in.
How do I stop the agent overspending?
Cap spend in code, as in Step 4. Sizing with count=true is free, so size before every search.
Where is the full code?
It is all in the blocks above: the client, the three tools, the guard and the agent. Paste them into one file and run it. Apply the guard lines before you build the FunctionAgent.
How is this different from the same agent in other frameworks?
The tools are identical. Only the wrapper changes. See the Pydantic AI, Mastra and LangGraph versions, or browse the blog.
Give your LlamaIndex agent live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →