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

How-to guide

How to Give an OpenAI Agents SDK Agent B2B Data (REST Tools and MCP)

A Python agent that sizes an audience for free, searches 5 people and enriches up to 2. At most 7 records a run, with 402 and 429 handled in code.

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

You can give an OpenAI Agents SDK agent live B2B data with four @function_tool functions and one HTTP helper. This guide builds that agent against the LeadOcean REST API. It also shows the MCP route.

The agent sizes an audience for free, searches a small page, and enriches a couple of people. Errors 402 and 429 are handled in code, and a record budget stops a runaway loop.

TL;DR

  • Four tools (list_values, count_people, search_people, enrich_person) and one shared helper. About 25 minutes.
  • One run spends 5 to 7 LeadOcean records: 0 for the count, 5 for the search page, up to 2 for enrichments. A budget of 25 is the hard stop.
  • 402 and 429 come back as plain tool results the model can act on, so the agent stops instead of looping.

Prerequisites

  • Python 3.10 or later and pip.
  • A LeadOcean API key from the app, exported as $LEADOCEAN_API_KEY. The free plan has 1,000 records, one-off, no card, and both scopes (search and enrich).
  • An OpenAI API key as $OPENAI_API_KEY. The SDK reads it from the environment (quickstart, September 2026).
  • The openai-agents package and httpx.

Full code for this guide is in the post: lo.py and agent.py.

Step 1: Install and set your keys

The SDK installs from PyPI as openai-agents.

bash
pip install openai-agents httpx
export LEADOCEAN_API_KEY="..."   # from app.leadocean.io
export OPENAI_API_KEY="..."

Keys stay in the environment. Nothing below holds a literal key.

Step 2: Write one HTTP helper

Put every LeadOcean call through one function. It owns the retry rules and turns each status code into a message the model can read. Save it as lo.py.

python
import asyncio
import os
from dataclasses import dataclass, field

import httpx

BASE_URL = "https://api.leadocean.io"


@dataclass
class Ctx:
    budget: int = 25  # records this run may spend
    spent: int = 0
    http: httpx.AsyncClient = field(default_factory=lambda: httpx.AsyncClient(
        base_url=BASE_URL,
        headers={"x-api-key": os.environ["LEADOCEAN_API_KEY"]},
        timeout=30,
    ))


async def call(ctx: Ctx, method: str, path: str, **kw) -> dict:
    """One LeadOcean request. Failures return {"error": ...}, never raise."""
    for attempt in range(3):
        r = await ctx.http.request(method, path, **kw)
        wait = int(r.headers.get("Retry-After", "1"))
        if r.status_code == 429 and wait <= 5:  # above the plan's per-second limit
            await asyncio.sleep(wait)
            continue
        if r.status_code == 503:  # transient, never billed
            await asyncio.sleep(2**attempt)
            continue
        break

    if r.status_code == 200:
        body = r.json()
        ctx.spent += body.get("meta", {}).get("credits", 0)
        return body
    if r.status_code == 404:  # nobody matched, costs nothing
        return {"found": False}

    try:
        message = r.json().get("error", {}).get("message", r.text)
    except ValueError:
        message = r.text
    if r.status_code == 400:
        return {"error": f"Rejected: {message}. Check enum values with list_values."}
    if r.status_code == 402:
        return {"error": "STOP. Out of records on the Free plan. Tell the user."}
    if r.status_code == 429:
        return {"error": f"STOP. Rate limited, retry after {wait} seconds."}
    return {"error": f"LeadOcean returned {r.status_code}: {message}"}

The Agents SDK has its own error path for tools. A function tool that crashes returns a generic error message to the model unless you pass failure_error_function (tools docs, September 2026). The helper skips that path. It returns the real message as the tool result, so the model sees what went wrong.

The mapping follows the LeadOcean limits page:

  • 400 is a validation error. The model gets the message and a pointer to list_values.
  • 402 means the free plan is spent. Those records never reset, so the message says stop.
  • 429 means the key went above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. The helper waits for Retry-After and goes again.
  • 429 that holds after two short waits becomes a stop message, so the model does not hammer the key.
  • 503 is transient and never billed, so back off and retry.
  • 404 on enrich means nobody matched and costs nothing.

Step 3: Define the four tools

@function_tool reads the type hints and the docstring to build the schema the model sees. The shared object travels in the run context, and each tool receives it as RunContextWrapper[Ctx] (context docs, September 2026). Save this as agent.py.

python
import asyncio

from agents import Agent, RunContextWrapper, Runner, function_tool

from lo import Ctx, call


@function_tool
async def list_values(ctx: RunContextWrapper[Ctx], name: str, q: str = "") -> str:
    """Free. Exact values for a filter.

    Args:
        name: One of job_level, job_function, country, seniority, industry.
        q: Optional text to narrow the list.
    """
    params = {"limit": 50, **({"q": q} if q else {})}
    return str(await call(ctx.context, "GET", f"/v1/enums/{name}", params=params))


@function_tool
async def count_people(ctx: RunContextWrapper[Ctx], job_level: str, country: str) -> str:
    """Free. Size a segment before searching.

    Args:
        job_level: Exact value, for example VP.
        country: Two-letter code, for example CA.
    """
    body = await call(ctx.context, "POST", "/v1/people/search",
                      params={"count": "true"},
                      json={"jobLevel": [job_level], "country": [country], "limit": 1})
    return str(body["meta"]["total"]) if "meta" in body else str(body)


def trim(row: dict) -> dict:
    """Keep six fields. The model does not need the whole record."""
    contact = row.get("contact_data", {})
    job = (contact.get("contact_current_experiences") or [{}])[0]
    return {
        "person_id": row.get("meta", {}).get("source_ids", {}).get("person_id"),
        "name": row.get("profile_data", {}).get("profile_full_name"),
        "title": job.get("job_title"),
        "company": job.get("company_name"),
        "domain": job.get("company_domain"),
        "email_status": contact.get("email_status"),
    }


@function_tool
async def search_people(ctx: RunContextWrapper[Ctx], job_level: str, country: str,
                        limit: int = 5) -> str:
    """Costs 1 record per person returned. Returns name, title, company, person_id.

    Args:
        job_level: Exact value, for example VP.
        country: Two-letter code, for example CA.
        limit: People to return, 1 to 25.
    """
    c = ctx.context
    left = c.budget - c.spent
    if left <= 0:
        return "STOP. Record budget for this run is spent."
    body = await call(c, "POST", "/v1/people/search", json={
        "jobLevel": [job_level], "country": [country], "limit": min(limit, left, 25)})
    return str([trim(r) for r in body["data"]]) if "data" in body else str(body)


@function_tool
async def enrich_person(ctx: RunContextWrapper[Ctx], person_id: str) -> str:
    """Costs 1 record. Returns the profile and revealed emails for one person_id.

    Args:
        person_id: The person_id from a search row.
    """
    c = ctx.context
    if c.budget - c.spent < 1:
        return "STOP. Record budget for this run is spent."
    body = await call(c, "POST", "/v1/people/enrich",
                      json={"person_id": person_id, "reveal_email": True})
    return str(body.get("data", body))


agent = Agent(
    name="Prospector",
    instructions=(
        "You build small B2B prospect lists with LeadOcean. Always call "
        "count_people first. Search only when the count is under 100,000. "
        "Enrich at most 2 people, and only when asked for contact details. "
        "Look up enum values with list_values instead of guessing. "
        "If a tool result starts with STOP, stop and tell the user why."
    ),
    tools=[list_values, count_people, search_people, enrich_person],
)


async def main() -> None:
    ctx = Ctx(budget=25)
    try:
        result = await Runner.run(
            agent,
            "Find 5 VPs in Canada and tell me how many exist in total.",
            context=ctx,
            max_turns=10,
        )
        print(result.final_output)
        print(f"LeadOcean records spent: {ctx.spent}")
    finally:
        await ctx.http.aclose()


if __name__ == "__main__":
    asyncio.run(main())

Four choices matter here.

Count before you search. count_people sends count=true as a query parameter with limit=1 in the body. That is a free sizing call and spends no records. meta.total is capped at 100,000. The instructions tell the model to call it first.

Enum values are exact strings. jobLevel takes VP, not vp or Vice President. list_values is free, and the 400 message points the model at it when it guesses wrong.

The row is trimmed to six fields. A search row is large. The model only needs the person_id to enrich someone, so trim keeps the rest short and saves model tokens.

The budget lives in the context. Each response carries meta.credits, the records counted for that call. The helper adds it to ctx.spent, and the tools refuse to run once the budget is gone. max_turns=10 separately caps model turns. Past that limit the SDK raises MaxTurnsExceeded (running agents docs, September 2026).

Step 4: Run it

Run the file. The agent counts the segment, searches 5 people and prints how many records it spent.

bash
python agent.py

Enrichment needs a person_id from search. There is no name plus domain lookup, so the agent searches first and enriches second. Each enrichment that finds a person is one record.

Cost per run is the part to plan for. The search page is 5 records. Two enrichments add 2. On Free, 1,000 records cover about 140 runs of the larger size. Paid plans have no per-record price: Solo is $97 a month and Agency is $497 a month, and a key that reaches its fair-use pace is slowed, not cut off. See pricing.

Step 5: Or connect over MCP

The hosted MCP server at https://api.leadocean.io/mcp exposes 13 tools. It signs in with OAuth 2.1 in a browser, and there is no API key to paste. That suits an interactive client. The LeadOcean MCP docs give the Codex setup in config.toml:

toml
[mcp_servers.leadocean]
url = "https://api.leadocean.io/mcp"

Then run codex mcp login leadocean to sign in. In ChatGPT, add it as a plugin: chatgpt.com/plugins, then Add custom MCP server with the same URL and OAuth.

The Agents SDK connects to remote servers with MCPServerStreamableHttp and can limit the tool list with tool_filter (MCP docs, September 2026). A headless script has no browser for the sign-in, so keep the function tools above for a server-side agent.

Filter the tool list in any MCP client. leadocean_count_leads, leadocean_count_companies, leadocean_list_filters and leadocean_list_enum_values are free. leadocean_search_leads, leadocean_get_lead, leadocean_find_phone, leadocean_get_company, leadocean_search_companies and leadocean_export_leads cost one record per person, company or row returned.

What you get

The list_values tool returns exact enum strings. Output from leadocean_list_enum_values for job_level, run on 2026-10-21 (description field trimmed):

json
{"name":"job_level","total":6,"matched":6,"values":["C-Team","Director","Manager","Other","Staff","VP"],"truncated":false}

The count tool returns one integer. On 2026-10-21, leadocean_count_leads for jobLevel VP, jobFunction Sales & Business Development and country CA returned this:

json
{"total": 5574, "totalIsExact": true, "totalAll": 18800, "distinctCompanies": 3873, "credits": 0}

The 5,574 counts people whose email is verified, catch-all valid or catch-all. Without an email filter the same filters match 18,800. A REST count without an emailStatus filter can differ.

A search row looks like this. Response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": [
    {
      "profile_data": { "profile_full_name": "Jane Doe" },
      "contact_data": {
        "has_email": true,
        "email_status": "verified",
        "contact_current_experiences": [
          { "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }
        ]
      },
      "meta": { "source_ids": { "person_id": "12345678" } }
    }
  ],
  "meta": { "count": 1, "limit": 5, "credits": 1, "nextCursor": "..." }
}

Search rows carry flags, not addresses. has_email and email_status say whether an address exists and whether it is safe to send. The address comes from enrich_person, which is why the instructions say to enrich only when asked. LeadOcean does not refund bounced emails, so read email_status before a list goes to a sequencer.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCheck $LEADOCEAN_API_KEY. Create a new key in the app.
402Free plan: the 1,000 records are spent and do not resetMove to Solo ($97 a month) or Agency ($497 a month). The helper returns a STOP message, so the agent halts.
429More requests a second than the plan allows (5 on Free, 50 on Solo, 200 on Agency)The helper waits for Retry-After and retries twice. Ask for full pages, not more calls, or move up a plan.
400 or empty dataA filter value is not an exact enum stringLook it up with GET /v1/enums/{name}. Pass it exactly.
400 export_limit_rowsAn export asked for more than 50,000 rows in one requestSplit the pull. The daily ceiling is 500,000 rows.

FAQ

Should I use function tools or the MCP server with the OpenAI Agents SDK?

Use function tools for server-side agents. You control the schemas, the key and the record cap. Use MCP from an interactive client such as Codex or ChatGPT, and filter the tool list to the free tools.

How many records does one run cost?

With these instructions, 5 to 7: a free count, a search page of 5 and up to 2 enrichments. The budget of 25 is the hard limit. Each person or company returned counts one record.

Why enrich by person_id and not by name?

There is no name plus domain lookup. Search by domain or title first, then pass the person_id from the row to POST /v1/people/enrich. Enrich also takes one of linkedin_url, email or phone.

What happens when the free records run out?

The API answers 402 and the helper returns a STOP message. The model reads it and tells the user. Rejected requests are never metered. A 402 on Free means upgrade, not wait.

Can the agent pull thousands of rows?

Not through search. A page holds 100 rows at most, and one search walk stops at 10,000. For big pulls use POST /v1/exports: up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on Free.

Related guides: Vercel AI SDK, Claude Agent SDK, Pydantic AI. More agent guides live on the LeadOcean blog.

Give your OpenAI Agents SDK agent real B2B data

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →