TL;DR
- A LangChain agent gets B2B data through three plain tools that call the LeadOcean REST API: count, search and enrich.
- It takes about 20 minutes. The example run spends about 7 of your 1,000 free records, and a guard in the code stops it at 25.
- Counting is free. A 402 or a long 429 goes back to the model as a message to stop, not an error to retry.
The full code is in this post, in one file: leadocean_agent.py.
Prerequisites
- A LeadOcean account and API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Free keys get both scopes,
searchandenrich. - The key in your shell as
$LEADOCEAN_API_KEY. - Python 3.10 or newer, LangChain with
create_agent,requests, and the provider package for your model.
export LEADOCEAN_API_KEY="paste-your-key-here"
pip install langchain langchain-anthropic requestsStep 1: Write one call helper that knows the limits
Put every request through one function. It adds the x-api-key header, counts records from meta.credits, and turns failures into short dicts the model can read.
Two rules come from the LeadOcean limits docs. A 429 with Retry-After: 1 means you are over your plan's rate (5 requests a second on Free, 200 on Agency), so wait and retry. A 429 with Retry-After: 60 means a paid plan reached its fair-use pace, and the docs say not to retry into it.
"""leadocean_agent.py"""
import os, time, requests
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
BUDGET = 25 # stop the run after this many records
spent = 0
def call(method, path, body=None, params=None, cost=0):
global spent
if spent + cost > BUDGET:
return {"error": "budget_reached", "spent": spent, "budget": BUDGET}
for attempt in range(3):
r = requests.request(method, BASE + path, headers=HEADERS,
json=body, params=params, timeout=30)
wait = int(r.headers.get("Retry-After", "1"))
if r.status_code == 429 and wait <= 2:
time.sleep(wait) # over the per-second rate
continue
if r.status_code == 503:
time.sleep(2 ** attempt) # transient, never billed
continue
break
try:
data = r.json()
except ValueError:
return {"error": r.status_code, "message": "non-JSON response"}
if r.status_code == 402:
return {"error": "records_spent",
"message": "Free records are used up. Stop and tell the user to upgrade."}
if r.status_code == 429:
return {"error": "paced",
"message": f"Retry-After {wait}s. Stop and tell the user."}
if not r.ok:
return {"error": r.status_code,
"message": data.get("error", {}).get("message")}
spent += data.get("meta", {}).get("credits", 0)
return dataStep 2: Define the three tools
Each tool is a function with type hints and a docstring. LangChain's @tool decorator builds the schema the model sees from them (LangChain tools docs, October 2026). Filter values are exact strings: jobLevel takes VP, and jobFunction takes Sales & Business Development.
from langchain.tools import tool
@tool
def count_people(job_level: str, job_function: str, country: str) -> dict:
"""Count people for a job level, job function and ISO country code.
Free. Always call this before search_people."""
body = {"jobLevel": [job_level], "jobFunction": [job_function],
"country": [country], "limit": 1}
res = call("POST", "/v1/people/search", body, params={"count": "true"})
return res.get("meta", res)
@tool
def search_people(job_level: str, job_function: str, country: str, limit: int = 5) -> list:
"""Search people. Costs one record per person returned. Max 10 per call."""
limit = max(1, min(limit, 10))
body = {"jobLevel": [job_level], "jobFunction": [job_function],
"country": [country], "limit": limit}
res = call("POST", "/v1/people/search", body, cost=limit)
if "error" in res:
return res
return [{"name": p["profile_data"].get("profile_full_name"),
"headline": p["profile_data"].get("profile_headline"),
"email_status": p["contact_data"].get("email_status"),
"person_id": p["meta"]["source_ids"]["person_id"]}
for p in res["data"]]
@tool
def enrich_person(person_id: str) -> dict:
"""Get emails and phones for one person_id from search_people. Costs 1 record.
A person with nothing on file is a 404 and costs nothing."""
res = call("POST", "/v1/people/enrich",
{"person_id": person_id, "reveal_email": True}, cost=1)
if "error" in res:
return res
return res["data"]["contact_data"]The count call sends count=true as a query parameter with limit set to 1. It returns meta.total and no rows, so it spends nothing. Search rows hold email flags, not addresses, so the agent needs enrich_person for those.
Step 3: Build the agent
LangChain describes an agent as a model calling tools in a loop. create_agent takes the model, the tools and a system prompt, and the system prompt is where the cost rules live.
from langchain.agents import create_agent
agent = create_agent(
model="claude-sonnet-5",
tools=[count_people, search_people, enrich_person],
system_prompt=(
"You find B2B contacts with LeadOcean tools. "
"Call count_people first and report the total. "
"Search at most 5 people, then enrich at most 2 who look best. "
"If a tool returns an error, stop and explain it. Never retry a 402 or a pace."
),
)Step 4: Run it and print the cost
if __name__ == "__main__":
result = agent.invoke({"messages": [{"role": "user", "content":
"Find VP-level sales leaders in Canada. Size the audience, "
"show 5, and get contact details for the 2 best."}]})
print(result["messages"][-1].content)
print(f"Records spent this run: {spent} of {BUDGET}")python leadocean_agent.pySearch costs 5 records and two enrichments cost 2, so a normal run lands at 7. On the Free plan, 1,000 records cover about 140 runs of this size. The BUDGET check inside call is the hard stop if the model loops.
GET /v1/account is free and shows what you have left. Paid plans have no per-record price. They have a fair-use pace instead, so a heavy day slows the key rather than billing you. See pricing.
Step 5: Or load the MCP tools instead
The hosted server at https://api.leadocean.io/mcp exposes 13 tools, and seven of them are free. LangChain's MCP docs show MCPAdapter turning a server URL into tools you pass to create_agent (LangChain MCP docs, October 2026).
pip install "langchain[mcp]"from langchain.agents import create_agent
from langchain.mcp import MCPAdapter
async def main():
async with MCPAdapter("https://api.leadocean.io/mcp") as adapter:
tools = await adapter.list_tools()
agent = create_agent("claude-sonnet-5", tools)
return await agent.ainvoke({"messages": [{"role": "user",
"content": "Count VP sales leaders in CA. Do not search."}]})The langchain.mcp namespace needs langchain[mcp] 1.4.0 or newer and is in beta, so the API may change. The LeadOcean server uses OAuth 2.1 with no key to paste. LangChain's MCP docs list OAuth 2.1 under authentication, so set that up before you run this.
Pick MCP when the agent should choose from every tool. Pick REST, as above, when you want your own budget guard and a narrow tool list. MCP exposes tools that spend records, such as search and get_lead, with no cap of your own.
What you get
The free enum lookup, run on 21 October 2026 with leadocean_list_enum_values, confirms the exact job level strings the filters accept.
{"name":"job_level","total":6,"matched":6,"values":["C-Team","Director","Manager","Other","Staff","VP"],"truncated":false}The free leadocean_count_leads tool, run on 21 October 2026 for jobLevel VP, jobFunction Sales & Business Development and country CA, returned 5,574 people with a verified, catch_all_valid or catch_all email. The same filters with no email condition matched 18,800 people. Your number will differ as the data refreshes.
A search row looks like this, with placeholder values (response shape from the API reference):
{
"success": true,
"data": [{
"profile_data": {"profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme"},
"contact_data": {"has_email": true, "email_status": "verified", "email_type": "work"},
"meta": {"source_ids": {"person_id": "1234567890"}}
}],
"meta": {"count": 1, "credits": 1, "nextCursor": "opaque-string"}
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Check $LEADOCEAN_API_KEY is exported in the shell that runs Python. |
| 403 | The key lacks the scope for that endpoint | Use a key with both search and enrich scopes. |
429 with Retry-After: 1 | Faster than your plan's rate (5 a second on Free, 200 on Agency) | Wait the header value and retry, as call does. Ask for full pages, not more calls. |
429 with Retry-After: 60 | A paid plan reached its fair-use pace and is held to one request a minute | Stop the run and read X-Quota-Reset. Do not loop. |
| 402 | Free plan: the 1,000 records are spent and do not reset | Upgrade to a paid plan. |
| 400 or empty results | A filter value is not an exact enum string | Call leadocean_list_enum_values or GET /v1/enums and copy the value. |
A 503 with code: no_source is transient and never billed. The helper backs off and retries it.
FAQ
Should I use MCP or REST with LangChain?
Use REST when you want your own budget guard, retries and a short tool list. Use MCP when you want all 13 LeadOcean tools without writing wrappers. Both draw on the same records.
How many records does one run cost?
One record per person returned and one per enrichment. Counting and a 404 cost nothing. The example run spends about 7, and BUDGET caps it at 25.
Can I use a different model?
Yes. create_agent takes a model string, and the tools do not change. Install the matching provider package.
What does it cost after the free 1,000 records?
Solo is $97 a month for one person, and Agency is $497 a month for teams. Neither has a per-record price. See pricing.
Related guides
Give your LangChain agent real B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →