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 LangGraph)

A LangGraph agent that sizes an audience for free, finds 10 people, enriches the best 3 and drafts a first-touch email for each. It never sends.

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

TL;DR

  • A LangGraph graph with three LeadOcean tools (count, search, enrich) finds leads and drafts one first-touch email each. It returns drafts for you to review and sends nothing.
  • It takes about 45 minutes. A run spends about 13 LeadOcean records: 10 from one search and 3 enrichments. The count is free, and the code refuses to pass 15.
  • A 402 or a paced 429 stops the graph with a plain message. A rate-limit 429 waits and retries.

Prerequisites

  • A LeadOcean account from app.leadocean.io. The free plan has no card, 1,000 records (one-off) and 5 requests a second. A Free key carries both scopes, search and enrich.
  • Python 3.10 or newer and an Anthropic API key for the chat model.
  • Your LeadOcean key in the shell as $LEADOCEAN_API_KEY.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"
export ANTHROPIC_API_KEY="paste-your-model-key-here"
pip install langgraph langchain langchain-anthropic httpx

The code is four files: leadocean.py, tools.py, agent.py and run.py. All four are below in full.

Step 1: Put every LeadOcean call behind one client

Save this as leadocean.py. One function sends the x-api-key header, counts records and turns each failure into a message the agent can act on. A 402 and a paced 429 raise Halt. A rate-limit 429 sleeps and retries.

LeadOcean never meters a rejected request. A 404 on enrich costs nothing either.

python
import os, time
import httpx

BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
RECORD_CAP = 15          # hard ceiling for one run
spent = 0                # LeadOcean records spent in this run


class Halt(Exception):
    """Stop the graph. The message goes back to the model."""


def call(method, path, *, params=None, body=None, reserve=0):
    if spent + reserve > RECORD_CAP:
        raise Halt(f"Run cap of {RECORD_CAP} records reached. Write the drafts you have.")
    for attempt in range(4):
        r = httpx.request(method, BASE + path, params=params, json=body,
                          headers=HEADERS, timeout=30)
        if r.status_code == 429:
            if r.headers.get("X-Quota-State") in ("paced", "over-limit"):
                raise Halt("The account hit its fair-use pace and is slowed to one request "
                           f"a minute until {r.headers.get('X-Quota-Reset')}. Stop and report.")
            time.sleep(int(r.headers.get("Retry-After", "1")))   # plan rate limit
            continue
        if r.status_code == 402:
            raise Halt("The free records are spent. They do not reset. Stop and report.")
        if r.status_code == 503:
            time.sleep(2 ** attempt)                              # transient, never billed
            continue
        if r.status_code == 404:
            return None                                           # nobody matched, free
        r.raise_for_status()
        return r.json()
    raise Halt("LeadOcean did not answer after 4 tries. Stop and report.")

Step 2: Define the three tools

Save this as tools.py. The docstring is what the model reads, so each one says what the tool costs. Search takes a flat JSON body where every filter is an array, and limit runs from 1 to 100. Here it is held to 10.

Enrich takes exactly one of linkedin_url, email, phone or person_id. Search rows carry the id at meta.source_ids.person_id, so the agent passes that on.

python
from langchain.tools import tool
import leadocean as lo

FILTER_KEYS = ("jobLevel", "jobFunction", "country", "industry", "employeeRange", "title")


def _body(args):
    return {k: v for k, v in args.items() if k in FILTER_KEYS and v}


@tool
def count_audience(jobLevel: list[str] | None = None, jobFunction: list[str] | None = None,
                   country: list[str] | None = None, industry: list[str] | None = None,
                   employeeRange: list[str] | None = None, title: list[str] | None = None) -> str:
    """Count mailable people for an ICP. Free. Call this first.
    Values are exact strings: jobLevel "VP", country "US", employeeRange "51-200"."""
    args = dict(jobLevel=jobLevel, jobFunction=jobFunction, country=country,
                industry=industry, employeeRange=employeeRange, title=title)
    res = lo.call("POST", "/v1/people/search", params={"count": "true"},
                  body=_body(args) | {"limit": 1})
    return f"{res['meta']['total']} people match. No records spent."


@tool
def search_people(jobLevel: list[str] | None = None, jobFunction: list[str] | None = None,
                  country: list[str] | None = None, industry: list[str] | None = None,
                  employeeRange: list[str] | None = None, title: list[str] | None = None,
                  limit: int = 10) -> str:
    """Search people for an ICP. Costs 1 record per person returned. Max 10.
    Returns person_id, name, headline and whether an email exists."""
    limit = max(1, min(limit, 10))
    args = dict(jobLevel=jobLevel, jobFunction=jobFunction, country=country,
                industry=industry, employeeRange=employeeRange, title=title)
    res = lo.call("POST", "/v1/people/search", body=_body(args) | {"limit": limit},
                  reserve=limit)
    rows = res["data"]
    lo.spent += len(rows)
    return str([{
        "person_id": r["meta"]["source_ids"]["person_id"],
        "name": r["profile_data"].get("profile_full_name"),
        "headline": r["profile_data"].get("profile_headline"),
        "has_email": r["contact_data"].get("has_email"),
        "email_status": r["contact_data"].get("email_status"),
    } for r in rows])


@tool
def enrich_person(person_id: str) -> str:
    """Full lookup for one person_id from search_people. Costs 1 record.
    Returns emails and career history (past roles, education, certifications)."""
    res = lo.call("POST", "/v1/people/enrich",
                  body={"person_id": person_id, "reveal_email": True}, reserve=1)
    if res is None:
        return "Not found. No record spent."
    lo.spent += 1
    d = res["data"]
    return str({"contact": d.get("contact_data"), "resume": d.get("resume_data")})

The cap lives in code, not in the prompt. A model that ignores an instruction still cannot spend past RECORD_CAP.

Step 3: Build the graph with a halt path

Save this as agent.py. It is the standard LangGraph tool loop from the LangChain quickstart: a model node, a tool node and a conditional edge. Two changes matter. The tool node catches Halt. After a halt the model node drops its tools, so the only thing it can do is write its answer.

python
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, ToolMessage
from langgraph.graph import StateGraph, MessagesState, START, END
import leadocean as lo
from tools import count_audience, search_people, enrich_person

tools = [count_audience, search_people, enrich_person]
by_name = {t.name: t for t in tools}
model = init_chat_model("claude-sonnet-5", temperature=0)
model_with_tools = model.bind_tools(tools)

SYSTEM = """You are an SDR for Acme, which sells project management software.
ICP: VP of Sales at US software companies with 51 to 200 staff.

1. Call count_audience with the ICP filters and report the total.
2. Call search_people with the same filters and limit 10.
3. Pick the 3 best fits: has_email true and email_status verified first.
4. Call enrich_person for those 3 only.
5. For each, draft a first-touch email: subject under 6 words, body under 90 words,
   one line taken from the career history, one question as the call to action.

Use only facts from tool results. If a field is missing, leave it out. Send nothing.
Filter values are exact strings: jobLevel "VP", jobFunction "Sales & Business Development",
country "US", industry "Software Development", employeeRange "51-200"."""


class State(MessagesState):
    halted: bool


def llm_call(state: State):
    m = model if state.get("halted") else model_with_tools
    return {"messages": [m.invoke([SystemMessage(content=SYSTEM)] + state["messages"])]}


def tool_node(state: State):
    out, halted = [], False
    for tc in state["messages"][-1].tool_calls:
        try:
            text = by_name[tc["name"]].invoke(tc["args"])
        except lo.Halt as stop:
            text, halted = str(stop), True
        out.append(ToolMessage(content=text, tool_call_id=tc["id"]))
    return {"messages": out, "halted": halted}


def should_continue(state: State):
    return "tool_node" if state["messages"][-1].tool_calls else END


g = StateGraph(State)
g.add_node("llm_call", llm_call)
g.add_node("tool_node", tool_node)
g.add_edge(START, "llm_call")
g.add_conditional_edges("llm_call", should_continue, ["tool_node", END])
g.add_edge("tool_node", "llm_call")
agent = g.compile()

Step 4: Run it and read the cost

Save this as run.py. It prints the record count at the end, so every run shows its own cost.

python
from langchain.messages import HumanMessage
from agent import agent
import leadocean as lo

result = agent.invoke({"messages": [HumanMessage(content="Draft this week's outreach.")],
                       "halted": False})
print(result["messages"][-1].content)
print(f"LeadOcean records spent: {lo.spent}")

The cost reads straight off the tools. The count is 0, the search is 10 and three enrichments are 3. That is 13 records, so the free 1,000 covers dozens of test runs.

On a paid plan there is no per-record price. Solo is $97 a month and Agency is $497 a month. Each has a fair-use pace instead of credits, and the plans are on pricing. Model tokens are billed by your model provider and are not quoted here.

Step 5: Swap REST for the hosted MCP server

LeadOcean also runs an MCP server at https://api.leadocean.io/mcp with 13 tools. LangChain's MCPAdapter reaches a URL over streamable HTTP, and auth="oauth" runs discovery, dynamic client registration and a browser redirect. LeadOcean's OAuth server supports dynamic client registration.

python
from fastmcp.client import Client
from langchain.mcp import MCPAdapter


async def load_leadocean_tools():
    async with MCPAdapter(Client("https://api.leadocean.io/mcp", auth="oauth")) as adapter:
        return await adapter.list_tools()

Pass that list to bind_tools in place of the three REST tools, and run the graph with ainvoke. Your RECORD_CAP does not travel with MCP tools. Read leadocean_get_account before a long run. MCP draws on the same records as your key, so there is one balance.

Use REST for an unattended job with a key in the environment. Use MCP when a person runs the agent and can approve the sign-in once.

What you get

The free count for the Step 3 audience, run on 21 October 2026 with leadocean_count_leads:

text
leadocean_count_leads  title ["VP Sales","Head of Sales"]  country ["US"]
  industry ["Software Development"]  employeeRange ["51-200"]

2026-10-21  total: 360 (mailable: verified, catch_all_valid, catch_all)
            totalAll: 863  distinctCompanies: 317  records spent: 0

That is a title filter on VP Sales and Head of Sales. The Step 3 prompt asks for jobLevel VP instead, so its count will differ. A search page has this shape. This is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": [
    {
      "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "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. Enrich returns the contact points and the career history in resume_data. That is where the personal line in each draft comes from. The agent's last message is three numbered drafts with name, title, company and email.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again.
429 with Retry-After: 1Above the plan's rate limit (5 a second on Free, 200 on Agency)Sleep the header value and retry. Step 1 does. Ask for full pages rather than more calls.
429 with X-Quota-State: pacedA paid plan reached a fair-use window and is slowed to one request a minuteDo not retry. Read X-Quota-Reset, tell the account owner and halt. Step 1 does.
402Free plan: the 1,000 records are spentHalt. They do not reset, 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 leadocean_list_enum_values or GET /v1/enums.
Search stops before you expectOne search pages to 10,000 rows, then meta.depthCapped is true with no cursorNarrow the filters and run a smaller search. Paging harder returns no more cursors.

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 send from.

How do I stop it spending too many records?

Count in code, as in Step 1. Every tool reserves records before it calls, so the graph stops at RECORD_CAP whatever the prompt says. One person returned is one record.

Does it loop forever on a 429?

No. A rate-limit 429 retries up to four times after the Retry-After wait. A paced 429 or a 402 raises Halt, and the next model call has no tools.

Should I use REST or MCP?

REST for scheduled jobs, because a key needs no browser. MCP for an interactive agent where a person signs in once.

Can I use another framework?

Yes. Step 1 has no LangGraph in it. See the same build on Pydantic AI, Mastra and LlamaIndex.

More recipes live in the blog hub.

Give your LangGraph SDR live B2B data

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

Get your free API key →