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

How-to guide

How to Build an AI SDR (With LlamaIndex and LeadOcean)

A LlamaIndex agent that sizes a list for free, searches prospects and enriches the best ones, with a hard cap on spend.

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

You build an AI SDR in LlamaIndex by handing a FunctionAgent three Python functions: count, search and enrich. LeadOcean serves all three over REST. The agent sizes the list for free, pulls ten people and enriches the best five.

TL;DR

  • Time: 30 to 40 minutes to a working agent.
  • Records: about 15 per run. Ten from search, five from enrich. The count call is free.
  • Stack: Python, llama-index, requests, and a LeadOcean key from the free plan.

Prerequisites

  • A LeadOcean account on the free plan: 1,000 records for life, no card, 5 requests a second. Create a key at app.leadocean.io and export it as $LEADOCEAN_API_KEY.
  • Python 3.10 or newer.
  • A model key for the LLM you pick, for example OPENAI_API_KEY.
  • pip install llama-index requests, plus the LlamaIndex package for your model provider.

LeadOcean also runs a hosted MCP server at https://api.leadocean.io/mcp with 13 tools. LlamaIndex has BasicMCPClient and McpToolSpec, which convert MCP tools into FunctionTool objects (checked September 2026). This guide uses REST because a REST wrapper is easier to cap and test.

Step 1: Wrap the API in one small client

Every call goes to https://api.leadocean.io with the x-api-key header. Put the 402 and 429 handling in one place so each tool stays short.

python
import os, time, requests

BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}

class OutOfRecords(Exception):
    pass

def call(method, path, params=None, body=None, retries=3):
    for attempt in range(retries):
        r = requests.request(method, BASE + path, headers=HEADERS,
                             params=params, json=body, timeout=30)
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After", 1)))
            continue
        if r.status_code == 402:
            raise OutOfRecords("LeadOcean records are spent. Stop the run.")
        if r.status_code == 404:
            return None
        r.raise_for_status()
        return r.json()
    raise RuntimeError("Still rate limited after retries")

A 429 means you went above the plan's rate limit (5 requests a second on Free). The Retry-After header says how long to wait. A 402 means the records are gone, so retrying does nothing.

Step 2: Define the three tools

LlamaIndex wraps plain Python functions as tools (checked September 2026). The docstring and type hints become the tool schema, so write them for the model.

The count tool sends count=true as a query parameter with limit=1. That call is free and returns meta.total, capped at 100,000. Filters are arrays of strings, and enum values are exact.

python
def count_prospects(job_level: list[str], job_function: list[str],
                    country: list[str]) -> dict:
    """Free. Count people matching filters. Call this before searching.
    job_level e.g. ["VP"]; job_function e.g. ["Sales & Business Development"];
    country e.g. ["US"]."""
    body = {"jobLevel": job_level, "jobFunction": job_function,
            "country": country, "limit": 1}
    res = call("POST", "/v1/people/search", params={"count": "true"}, body=body)
    return res["meta"]

def search_prospects(job_level: list[str], job_function: list[str],
                     country: list[str], limit: int = 10) -> list:
    """Spends 1 record per person returned. Max 100 per page."""
    body = {"jobLevel": job_level, "jobFunction": job_function,
            "country": country, "limit": min(limit, 100)}
    data = call("POST", "/v1/people/search", body=body)["data"]
    guard(len(data))
    return data

def enrich_prospect(person_id: str) -> dict:
    """Spends 1 record. Returns profile, current role and the work email."""
    res = call("POST", "/v1/people/enrich",
               body={"person_id": person_id, "reveal_email": True})
    if res is None:
        return {"result": "No match. Nothing was spent."}
    guard(1)
    return res

Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, so the agent searches first and enriches by person_id, which each search row carries at meta.source_ids.person_id. Step 5 defines guard.

Step 3: Build the agent

The LlamaIndex docs build an agent with FunctionAgent, passing a list of tools, an llm and a system_prompt, then call await agent.run(...) (checked September 2026). The system prompt is where the spending rules live.

python
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.openai import OpenAI

SYSTEM = """You are an SDR research agent.
1. Always call count_prospects first. If total is over 5,000, narrow the filters.
2. Search at most 10 people. Never page further.
3. Enrich at most 5 people, the best title fits only.
4. Stop and report if a tool says records are spent.
Return a table: name, title, company, work email."""

agent = FunctionAgent(
    tools=[count_prospects, search_prospects, enrich_prospect],
    llm=OpenAI(model="gpt-4o-mini"),
    system_prompt=SYSTEM,
)

Swap OpenAI for any LlamaIndex LLM class you have a key for. The caps in the prompt are a guide only. Step 5 adds a hard stop in code.

Step 4: Run it

python
import asyncio

async def main():
    response = await agent.run(
        "Find US VPs of Sales. Enrich the top 5.")
    print(str(response))

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

The agent counts, searches ten, picks five and enriches them. Expect four to eight model turns.

Step 5: Cap spend in code

A prompt is not a budget. Add a counter so a loop cannot burn the 1,000 free records. Define it above the tools.

python
SPENT = {"records": 0}
LIMIT = 25

def guard(n):
    SPENT["records"] += n
    if SPENT["records"] > LIMIT:
        raise OutOfRecords(f"Run cap of {LIMIT} records reached")

One record is one person you receive. A page of 10 is 10 records, and an enrichment that finds nothing costs nothing. Cost per run is the sum: ten searched plus five enriched is about 15 records, so the free plan covers around 65 runs.

Step 6: Handle 402 and 429 as agent-visible errors

Raising out of a tool can end the whole run. Return a plain message instead, so the agent can stop and report.

python
def safe(fn):
    def wrapper(*args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except OutOfRecords as e:
            return {"stop": True, "reason": str(e)}
        except RuntimeError as e:
            return {"stop": True, "reason": str(e)}
    wrapper.__name__ = fn.__name__
    wrapper.__doc__ = fn.__doc__
    wrapper.__annotations__ = fn.__annotations__
    return wrapper

tools = [safe(count_prospects), safe(search_prospects), safe(enrich_prospect)]

Pass tools=tools to FunctionAgent. The system prompt already tells the agent to stop when a tool reports spent records.

What you get

The count call is free. On 2026-10-21 leadocean_count_leads returned this for VPs in Sales & Business Development in the US. It counts verified, catch_all_valid and catch_all emails by default:

json
{
  "total": 129494,
  "totalIsExact": true,
  "totalAll": 350517,
  "distinctCompanies": 58121
}

That is 129,494 people with a mailable address. Without an email filter the same filters match 350,517. Over REST, meta.total stops at 100,000, so the agent sees 100,000 for this list and should narrow it before it searches.

A search page then follows the documented shape. This is the response shape from the API reference, with placeholder values:

json
{
  "data": [{
    "profile_data": { "...": "Jane Doe, VP Sales, Acme" },
    "meta": { "source_ids": { "person_id": "per_000000" } }
  }],
  "meta": { "total": 1, "credits": 1, "nextCursor": "..." }
}

Enrich returns the current role in contact_current_experiences[], phones in contact_phones[].phone and, since October 2026, career history in resume_data.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-keyCheck $LEADOCEAN_API_KEY is exported in the shell that runs Python
429Above the plan's rate limit (5 a second on Free, 200 on Agency)Sleep for Retry-After and retry, as in Step 1
402Free plan: the 1,000 records are spent. They do not resetStop the run. Paid plans slow down at their pace instead of returning 402. See pricing
Empty dataA bad enum value, such as vp for jobLevelCall leadocean_list_enum_values and copy the exact string (VP)
404 on enrichNobody matched. It costs nothingReturn "no match" to the agent and move on
Export refusedA request over 50,000 rows, or past 500,000 a daySplit the export or run it tomorrow

FAQ

Can I use LeadOcean's MCP server instead of REST?

Yes. The server at https://api.leadocean.io/mcp exposes 13 tools and signs in with OAuth 2.1. LlamaIndex's McpToolSpec converts MCP tools into FunctionTool objects. REST is simpler to cap and test.

How many records does one run cost?

About 15 in this guide: ten from search and five from enrich. The count call, list_filters and list_enum_values cost nothing.

Does this send emails?

No. LeadOcean is a data API. It has no sequencer, dialer or CRM. Pass the table the agent returns to the sender you already use.

Which plan do I need?

Free covers building and testing. Solo at $97 a month suits one person running this daily. Agency at $497 a month fits teams that need 200 requests a second. See pricing.

Where do the other frameworks stand?

The same three tools port to Pydantic AI, Mastra and LangGraph. Only the wiring changes.

Give your LlamaIndex agent real prospect data

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

Get your free API key →