TL;DR
- You give an OpenAI Agents SDK agent three LeadOcean function tools (count, search, enrich) and a prompt that drafts a first-touch email per lead. It returns drafts for you to review. It sends nothing.
- It takes about 60 minutes to build. Each run spends about 13 LeadOcean records: 10 from one search, 3 enrichments. Counting is free.
- The code calls LeadOcean over REST with an API key. The SDK can also connect to MCP servers, but LeadOcean's hosted MCP server signs in with OAuth, so a headless script is simpler on REST.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off, at 5 requests a second.
- Python 3 and an OpenAI API key in
OPENAI_API_KEY, as in the SDK quickstart. - 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 one HTTP 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 agent can act on.
import os, asyncio, json, httpx
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
SPENT = {"records": 0}
RECORD_CAP = 20 # hard ceiling for one run
async def call(method, path, params=None, body=None):
async with httpx.AsyncClient(timeout=30) as client:
for attempt in range(2):
r = await client.request(method, BASE + path, headers=HEADERS,
params=params, json=body)
if r.status_code == 429 and attempt == 0:
await asyncio.sleep(int(r.headers.get("Retry-After", "1")))
continue
break
if r.status_code == 402:
return None, "The free records are spent. Stop and tell the user."
if r.status_code == 429:
return None, "Rate limited twice. Stop and report what you have."
if r.status_code == 404:
return None, "Nobody matched. This lookup cost nothing."
if r.status_code >= 400:
return None, f"HTTP {r.status_code}: {r.text[:300]}"
return r.json(), NoneA 402 means the 1,000 free records are spent. Paid plans slow down at their pace instead. A 404 on enrich means nobody matched and costs nothing.
Step 2: Define the function tools
The SDK's @function_tool decorator turns a Python function into a tool and reads its schema from the type hints and docstring (tools docs). Each tool here returns a string. A failure comes back as an ERROR: message, so the agent reads your text instead of a stack trace. Search sends a flat JSON body where every filter is an array.
from typing import Optional
from agents import function_tool
def fail(msg: str) -> str:
return "ERROR: " + msg
@function_tool
async def count_audience(job_level: list[str], job_function: list[str],
country: list[str], industry: list[str],
employee_range: list[str]) -> str:
"""Count people for an ICP. Free. Call this first.
Args:
job_level: exact values such as "VP".
job_function: exact values such as "Sales & Business Development".
country: country codes such as "US".
industry: exact values such as "Software Development".
employee_range: bands such as "51-200".
"""
body = {"jobLevel": job_level, "jobFunction": job_function, "country": country,
"industry": industry, "employeeRange": employee_range, "limit": 1}
data, e = await call("POST", "/v1/people/search", params={"count": "true"}, body=body)
return fail(e) if e else json.dumps(data["meta"])
@function_tool
async def search_people(job_level: list[str], job_function: list[str],
country: list[str], industry: list[str],
employee_range: list[str], limit: int = 10) -> str:
"""Search people for an ICP. Costs 1 record per person returned. Max 10.
Args:
job_level: exact values such as "VP".
job_function: exact values such as "Sales & Business Development".
country: country codes such as "US".
industry: exact values such as "Software Development".
employee_range: bands such as "51-200".
limit: how many people to return, 1 to 10.
"""
n = max(1, min(limit, 10))
if SPENT["records"] + n > RECORD_CAP:
return fail("Record cap reached for this run. Do not search again.")
body = {"jobLevel": job_level, "jobFunction": job_function, "country": country,
"industry": industry, "employeeRange": employee_range, "limit": n}
data, e = await call("POST", "/v1/people/search", body=body)
if e:
return fail(e)
SPENT["records"] += len(data["data"])
return json.dumps(data)
@function_tool
async def enrich_person(person_id: str) -> str:
"""Full lookup for one person by person_id from a search row. Costs 1 record."""
if SPENT["records"] + 1 > RECORD_CAP:
return fail("Record cap reached for this run. Do not enrich again.")
data, e = await call("POST", "/v1/people/enrich",
body={"person_id": person_id, "reveal_email": True})
if e:
return fail(e)
SPENT["records"] += 1
return json.dumps(data)The cap lives in your code, not in the prompt. A model that ignores an instruction still cannot spend past RECORD_CAP.
Step 3: Write the SDR instructions
The instructions hold the ICP, the procedure and the writing rules. Acme and its product are placeholders for your own.
INSTRUCTIONS = """You are an SDR 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. Pick the 3 best fits: title closest to "VP Sales", email_status verified.
4. Call enrich_person for those 3 only.
5. For each, write a first-touch email: subject under 6 words, body under
90 words, one specific line taken from resume_data (a past role, school
or certification), one question as the call to action.
Rules: use only facts that came back in a tool result. If a field is missing,
leave it out. If a tool returns ERROR, stop and report it. Do not send anything.
Return the 3 drafts as a numbered list with name, title, company, email.
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
Runner.run drives the loop and returns the result. max_turns stops a runaway loop: the SDK raises MaxTurnsExceeded when the limit is hit (running agents). Catch it and still print what the run spent.
from agents import Agent, Runner
from agents.exceptions import MaxTurnsExceeded
sdr = Agent(
name="Acme SDR",
instructions=INSTRUCTIONS,
tools=[count_audience, search_people, enrich_person],
)
async def main():
try:
result = await Runner.run(sdr, "Draft this week's outreach.", max_turns=12)
print(result.final_output)
except MaxTurnsExceeded:
print("Stopped: hit max_turns before the drafts were done.")
print(f"LeadOcean records spent: {SPENT['records']}")
asyncio.run(main())Set the model on the Agent if you want a specific one. OpenAI token cost is billed by OpenAI and varies with your prompt and model, so this page quotes LeadOcean records only.
Step 5: Read the balance
After a run, read your account. GET /v1/account is free.
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jqOne run searches 10 people (10 records) and enriches 3 (3 records). That is about 13 records, so the free 1,000 covers dozens of test runs. Read each draft against the tool results before you use it.
What you get
Step 2's count tool is free. On 21 October 2026 the filters from the instructions returned this from the free LeadOcean count tool.
{
"total": 1649,
"totalIsExact": true,
"distinctCompanies": 1125,
"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, spread over 1,125 companies. A search page has a different shape. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [
{
"profile_data": { "...": "Jane Doe, VP Sales at Acme" },
"contact_data": { "has_email": true, "email_status": "verified" },
"meta": { "source_ids": { "person_id": "<digits>" } }
}
],
"meta": { "count": 10, "limit": 10, "nextCursor": "<opaque cursor>" }
}Search rows carry 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), which is where the personal line in each draft comes from.
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 | Free plan: the 1,000 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, or no identifier to enrich by | Skip the lead. The call costs nothing. |
MaxTurnsExceeded | The agent looped past max_turns | Raise the limit, or tighten the procedure in the instructions. |
FAQ
Does the agent send the emails?
No. It returns drafts. LeadOcean is a data source, not a sequencer, dialer or CRM, so you paste approved drafts into the tool you already send from.
Can I use LeadOcean's MCP server instead of REST?
Yes, from an interactive client. The SDK connects to a Streamable HTTP server with MCPServerStreamableHttp (MCP docs). LeadOcean's server is at https://api.leadocean.io/mcp, exposes 13 tools and signs in with OAuth 2.1 in a browser, with no API key. A script with no browser is simpler on REST with a key.
How do I stop it spending too many records?
Count in code, as in Step 2. The tools refuse once RECORD_CAP is hit, whatever the prompt says. One person returned counts as one record.
What does a run cost?
On LeadOcean, about 13 records. OpenAI tokens are billed by OpenAI. For regular volume, see pricing: Solo is $97 a month and Agency is $497 a month.
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.
Can I build this on another stack?
Yes. The same recipe runs on the Claude Agent SDK and the Vercel AI SDK. To score leads instead of writing to them, see build a lead qualification agent with the OpenAI Agents SDK. More recipes are in the blog hub.
Give your AI SDR live B2B data
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →