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

How-to guide

How to Build a Lead Qualification Agent (With the OpenAI Agents SDK)

Three function tools, one instruction block and a hard record cap. The agent sizes an audience for free, pulls 10 people, and enriches the 3 best.

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

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:
bash
pip install openai-agents httpx
  • Your LeadOcean key in the shell:
bash
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.

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

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

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

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

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

bash
curl -s https://api.leadocean.io/v1/account -H "x-api-key: $LEADOCEAN_API_KEY" | jq

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

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

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

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again.
429Above 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.
402The plan's records are spentCheck GET /v1/account. Free records are one-off, so move to a paid plan on pricing.
Total of 0 or a 400A filter value outside the catalogue, such as industry: "Software"Resolve the exact string with GET /v1/enums or leadocean_list_enum_values.
404 on enrichNobody matched that person_idSkip the lead. The call costs nothing.
Agent keeps calling toolsThe instructions do not say when to stopKeep 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.

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 →