TL;DR
- You write three Python function tools (count, search, enrich) and give them to an OpenAI Agents SDK agent. The agent scores each lead against your ICP and returns a ranked list.
- It takes about 40 minutes. The example run spends about 13 of your 1,000 free LeadOcean records. Counting is free.
- Use REST for the tools. The SDK can also attach MCP servers, but LeadOcean's MCP server signs in with OAuth, and a headless script has no browser for that.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off, with 5 requests a second.
- Python 3.10 or newer and an OpenAI API key in
OPENAI_API_KEY. Install the packages:
pip install openai-agents httpx- Your LeadOcean key in the shell:
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Write the REST helper with 402 and 429 handling
Every tool shares one function. It sends the x-api-key header, retries a 429 once after Retry-After, and turns a 402 into a message the model can act on. It also refuses any call that would pass your record cap.
import os, time, httpx
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
SPENT = {"records": 0} # running total for this process
RECORD_CAP = 20 # hard ceiling for one run
def call(method, path, *, params=None, body=None, cost=0):
if SPENT["records"] + cost > RECORD_CAP:
return {"error": "Record cap reached for this run. Do not call this tool again."}
for attempt in range(2):
r = httpx.request(method, BASE + path, headers=HEADERS,
params=params, json=body, timeout=30)
if r.status_code == 429 and attempt == 0:
time.sleep(int(r.headers.get("Retry-After", "1")))
continue
break
if r.status_code == 402:
return {"error": "Out of records on this plan. Stop and tell the user."}
if r.status_code == 429:
return {"error": "Rate limited twice. Stop and report what you have."}
if r.status_code == 404:
return {"error": "Nobody matched. This lookup cost nothing."}
if r.status_code >= 400:
return {"error": f"HTTP {r.status_code}: {r.text[:300]}"}
return r.json()A 402 means the plan has no records left. A 404 on enrich means nobody matched, and it costs nothing. The cap lives in your code, not the prompt, so a model that ignores an instruction still cannot overspend.
Step 2: Define the three function tools
The SDK builds each tool's name, description and input schema from the Python function: its name, docstring and type hints (OpenAI Agents SDK docs, September 2026). Write the docstrings for the model. Return the error dict as the tool result so the agent reads your message, not a stack trace.
from agents import function_tool
@function_tool
def count_audience(job_level: list[str] | None = None,
job_function: list[str] | None = None,
country: list[str] | None = None,
industry: list[str] | None = None,
employee_range: list[str] | None = None) -> dict:
"""Count mailable people for an ICP. Free. Call this first.
Args:
job_level: Exact values such as "VP", "Director", "C-Team".
job_function: Exact values such as "Sales & Business Development".
country: ISO codes such as "US".
industry: Exact industry labels such as "Software Development".
employee_range: Brackets such as "51-200".
"""
body = {"limit": 1, "jobLevel": job_level, "jobFunction": job_function,
"country": country, "industry": industry, "employeeRange": employee_range}
body = {k: v for k, v in body.items() if v}
data = call("POST", "/v1/people/search", params={"count": "true"}, body=body)
return data if "error" in data else data["meta"]
@function_tool
def search_people(limit: int,
job_level: list[str] | None = None,
job_function: list[str] | None = None,
country: list[str] | None = None,
industry: list[str] | None = None,
employee_range: list[str] | None = None) -> dict:
"""Search people for an ICP. Costs 1 record per person returned. Max 10.
Args:
limit: How many people to return, 1 to 10.
"""
n = max(1, min(limit, 10))
body = {"limit": n, "jobLevel": job_level, "jobFunction": job_function,
"country": country, "industry": industry, "employeeRange": employee_range}
body = {k: v for k, v in body.items() if v}
data = call("POST", "/v1/people/search", body=body, cost=n)
if "error" not in data:
SPENT["records"] += len(data["data"])
return data
@function_tool
def enrich_person(person_id: str) -> dict:
"""Full lookup for one person by person_id. Costs 1 record.
Args:
person_id: The meta.source_ids.person_id value from a search row.
"""
data = call("POST", "/v1/people/enrich",
body={"person_id": person_id, "reveal_email": True}, cost=1)
if "error" not in data:
SPENT["records"] += 1
return dataCount and search share the filter names. The count call sends count=true as a query parameter with limit 1, which is free. Filters are arrays, and enum values must match exactly.
Step 3: Write the qualification rubric
The agent's instructions hold the ICP and the scoring rules. Keep the rules checkable. Acme is a placeholder for your own company.
INSTRUCTIONS = """You qualify leads for Acme, which sells project management software.
ICP: VP of Sales at US software companies with 51 to 200 staff.
Procedure:
1. Call count_audience with the ICP filters. Report the total.
2. Call search_people with the same filters and limit 10.
3. Score each person 0 to 3: +1 title matches "VP Sales" or "VP of Sales",
+1 contact_data.email_status is verified, +1 the company is a software company.
4. Call enrich_person for the 3 highest scores only.
5. Return a ranked list: name, title, company, score, one-line reason.
Rules: use only fields that came back in a tool result. If a field is missing,
write "unknown". Never call a tool more often than the procedure says.
Filter values are exact strings: job_level "VP", job_function "Sales & Business Development",
country "US", industry "Software Development", employee_range "51-200"."""Step 4: Run the agent and read the cost
Create the agent with the three tools and run it with Runner.run. max_turns stops a loop: the SDK raises MaxTurnsExceeded when the limit is passed (OpenAI Agents SDK docs, September 2026).
import asyncio
from agents import Agent, Runner
from agents.exceptions import MaxTurnsExceeded
agent = Agent(
name="Lead qualifier",
instructions=INSTRUCTIONS,
tools=[count_audience, search_people, enrich_person],
)
async def main():
try:
result = await Runner.run(agent, "Qualify this week's leads.", max_turns=12)
print(result.final_output)
usage = result.context_wrapper.usage
print(f"OpenAI requests: {usage.requests}, tokens: {usage.total_tokens}")
except MaxTurnsExceeded:
print("Agent hit max_turns. Check the instructions for a loop.")
print(f"LeadOcean records spent: {SPENT['records']}")
asyncio.run(main())The run spends two kinds of money. LeadOcean records: about 13, because search returns 10 people and you enrich 3. OpenAI tokens: the SDK reports them in result.context_wrapper.usage (OpenAI Agents SDK docs, September 2026). Multiply by your model's rate from OpenAI's pricing page.
Step 5: Check the balance
After the run, read your account. GET /v1/account is free.
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jqCompare the balance with SPENT. If they differ, a tool call ran outside your helper.
Step 6: Optional, attach the MCP server instead
The SDK can connect to a remote MCP server over Streamable HTTP with MCPServerStreamableHttp, passing a URL and headers (OpenAI Agents SDK docs, September 2026). LeadOcean's server is at https://api.leadocean.io/mcp and uses OAuth 2.1 sign-in. It exposes 13 tools. Use it when a person signs in; use REST for a scheduled script. The REST route also gives you the code-side record cap from Step 1.
What you get
Step 2's count tool is free. On 21 October 2026 the same filters as the instructions, run through the free LeadOcean count tool, returned this.
{
"total": 1649,
"totalIsExact": true,
"distinctCompanies": 1125,
"totalAll": 4141,
"credits": 0,
"defaults": "Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)"
}That is 1,649 VPs of Sales at US software companies of 51 to 200 staff, with a verified or catch-all email. Without the email condition the same filters match 4,141 people.
A search page has a different shape. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [
{
"profile_data": { "...": "name, headline, location" },
"contact_data": { "has_email": true, "email_status": "verified", "email_type": "work",
"contact_current_experiences": [ { "...": "current role and company" } ] },
"meta": { "source_ids": { "person_id": "<digits>" } }
}
],
"meta": { "count": 10, "limit": 10, "nextCursor": "<opaque cursor>" }
}Search rows are thin: no addresses. The agent passes meta.source_ids.person_id to enrich_person, which returns the contact points. Person enrich also returns career history in resume_data (past roles, education, certifications), so the agent can cite a previous employer.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again. |
| 429 | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Wait Retry-After seconds. The helper retries once, then tells the agent to stop. |
| 402 | The plan's records are spent | Check GET /v1/account. Free records are one-off, so move to a paid plan on pricing. |
| Total of 0 or a 400 | A filter value outside the catalogue, such as industry: "Software" | Resolve the exact string with GET /v1/enums or leadocean_list_enum_values. |
| 404 on enrich | Nobody matched that person_id | Skip the lead. The call costs nothing. |
| Agent keeps calling tools | The instructions do not say when to stop | Keep max_turns set and keep the cap in call. |
FAQ
Why REST and not LeadOcean's MCP server?
The MCP server uses OAuth 2.1, which needs a person to sign in. A cron job has no one to do that. REST with an API key runs headless, and your code can enforce a record cap.
How do I keep the agent from overspending records?
Count in code, as in Step 1. The helper refuses once RECORD_CAP is reached, whatever the instructions say. A person returned counts as one record.
Can I run it on a schedule?
Yes. It is a plain Python script, so cron or any job runner can start it. Print SPENT each run and check GET /v1/account after the first few.
Does the agent send emails?
No. It ranks leads and returns data. LeadOcean is a data source, not a sequencer, dialer or CRM. To add outreach, see build an AI SDR with the OpenAI Agents SDK.
What does it cost to run?
LeadOcean: about 13 records a run, so the free 1,000 covers dozens of test runs. OpenAI tokens are billed by OpenAI and vary with your model and prompt. For more volume, see pricing.
Related reading
Same recipe on other stacks: Vercel AI SDK and Claude Agent SDK. More recipes are in the blog hub.
Give your qualification agent live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →