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

How-to guide

How to Give a LangGraph Agent B2B Data (With LeadOcean)

Three tools, one budget guard and clean handling of 402 and 429. The agent sizes the audience for free, then spends at most 7 records.

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

TL;DR

  • A LangGraph agent gets B2B data through three tools over REST: a free count, a search and an enrichment. All three call https://api.leadocean.io with an x-api-key header.
  • It takes about 25 minutes. One run spends at most 7 LeadOcean records of your 1,000 free ones, and a hard cap in the code stops it at 10.
  • A 402 or a paced 429 stops the graph with a plain message. A rate-limit 429 waits and retries.

Prerequisites

  • A LeadOcean account and API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. A Free key carries both scopes, search and enrich.
  • The key in your shell as $LEADOCEAN_API_KEY.
  • Python 3.10 or newer, and a chat model key for LangChain (this guide uses Anthropic, set as ANTHROPIC_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

Each step names its file. Copy the five files into one folder.

Step 1: Write one LeadOcean client with the error rules in it

Save this as leadocean.py. Put every HTTP call behind one function. That is where 429 and 402 get handled, so the agent never sees a stack trace. The function also counts records against a local budget.

LeadOcean's docs say a rejected request is never metered. A 404 on enrich costs nothing. Search charges one record per person returned.

python
import os, time
import httpx

BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
MAX_RECORDS = 10          # hard cap for one run
spent = 0                 # 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):
    global spent
    if spent + reserve > MAX_RECORDS:
        raise Halt(f"Run budget of {MAX_RECORDS} records reached. Report what 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"):                    # paced or over-limit
                raise Halt("Account reached its fair-use pace and is held 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")))   # rate limit: 1 second
            continue
        if r.status_code == 402:
            raise Halt("Free records are spent. They do not reset. Stop and report.")
        if r.status_code == 503:
            time.sleep(2 ** attempt)                              # index unreachable: back off
            continue
        if r.status_code == 404:
            return None                                           # costs nothing
        r.raise_for_status()
        return r.json()
    raise Halt("LeadOcean did not answer after 4 tries. Stop and report.")

Why a budget guard belongs in the graph

An agent decides how many calls to make. You decide how many records it may spend. Those two facts need a hard line between them.

LeadOcean meters records returned, not requests. A search that asks for 100 rows costs up to 100 records, and a model that loops on search can burn a free allowance in one run. The guard in Step 1 reserves records before each call, so the loop stops at your number and not at the account's.

Two LeadOcean behaviours shape the design. Sizing is free: a search with count=true and limit=1 returns meta.total and no rows. A page is also trimmed to whatever records are left, so a nearly empty account returns fewer rows rather than an error.

Step 2: Define the three tools

Save this as tools.py. Each tool is a plain Python function with a docstring, which is what the model reads. The first is free. Keep it separate from the search so the model sizes before it spends.

Filters are flat JSON and every value is an array. The industry and country values are exact strings, so the docstring tells the model to use them as written.

python
from langchain.tools import tool
import leadocean as lo


def _filters(title, country, industry, employee_range, domain):
    f = {"title": title, "country": country, "industry": industry,
         "employeeRange": employee_range, "domain": domain}
    return {k: v for k, v in f.items() if v}


@tool
def count_people(title: list[str] | None = None, country: list[str] | None = None,
                 industry: list[str] | None = None, employee_range: list[str] | None = None,
                 domain: list[str] | None = None) -> str:
    """Count mailable people for a filter set. Free. Call this before search_people.
    country is ISO alpha-2 such as US. employee_range is one of 1-10, 11-50, 51-200,
    201-500, 501-1000, 1001-5000, 5001-10000, 10001+. industry is an exact LeadOcean string."""
    body = _filters(title, country, industry, employee_range, domain) | {"limit": 1}
    res = lo.call("POST", "/v1/people/search", params={"count": "true"}, body=body)
    return f"{res['meta']['total']} mailable people match. No records spent."


@tool
def search_people(title: list[str] | None = None, country: list[str] | None = None,
                  industry: list[str] | None = None, employee_range: list[str] | None = None,
                  domain: list[str] | None = None, limit: int = 5) -> str:
    """Search people. Costs one record per person returned, so keep limit at 5 or less.
    Returns person_id, name, headline and whether an email exists."""
    limit = max(1, min(limit, 5))
    body = _filters(title, country, industry, employee_range, domain) | {"limit": limit}
    res = lo.call("POST", "/v1/people/search", body=body, 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"),
    } for r in rows])


@tool
def enrich_person(person_id: str) -> str:
    """Reveal emails and phones for one person_id from search_people. Costs one record.
    Skip people whose has_email is false."""
    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
    cd = res["data"]["contact_data"]
    return str({"emails": cd.get("contact_emails"), "email_status": cd.get("email_status")})

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 never needs a name plus domain lookup.

Step 3: Build the graph with a stop condition

Save this as agent.py. The graph is the standard LangGraph tool loop: a model node, a tool node and a conditional edge. Two changes matter here. The tool node catches Halt, and the model node drops its tools once the graph has halted.

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

tools = [count_people, 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 find B2B contacts with LeadOcean. Always call count_people first. "
          "Search at most 5 people. Enrich at most 2 who have has_email true. "
          "Placeholders only: never invent a person.")


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 call in state["messages"][-1].tool_calls:
        try:
            text = by_name[call["name"]].invoke(call["args"])
        except lo.Halt as stop:
            text, halted = str(stop), True
        out.append(ToolMessage(content=text, tool_call_id=call["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()

LangChain's quickstart builds its calculator agent the same way, with StateGraph, a tool node and add_conditional_edges. Sources are listed at the top of this page.

After a halt the model node calls the model without tools. It can only write its summary, so the loop ends.

The tool docstrings do real work. The model never sees the JSON schema of the LeadOcean API. It sees the three function signatures and the text under each one, and it picks arguments from that.

That is why count_people lists the headcount brackets and says country is a two-letter code. A model that guesses industry: "Software" gets zero rows, because enum values are exact strings. Telling it to resolve values first costs a sentence and saves a wasted run.

Step 4: Run it and read the cost

Save this as run.py. Give the agent one task. Print the record count at the end so every run shows its own cost.

python
from agent import agent, HumanMessage
import leadocean as lo

task = ("Find VP Sales or Head of Sales at US software companies with 51-200 staff. "
        "Size the audience, list 5 people, then enrich the 2 best with an email.")
result = agent.invoke({"messages": [HumanMessage(content=task)], "halted": False})
print(result["messages"][-1].content)
print(f"LeadOcean records spent: {lo.spent}")

The cost is easy to read off the tools. The count is 0. The search is 5. Two enrichments are 2. That is 7 records, and the reserve check refuses anything past 10.

LeadOcean records are not your model bill. Token cost for the model depends on the provider you pick, and this guide does not quote it.

Step 5: Swap REST for MCP when you want the hosted tools

LeadOcean also runs a hosted MCP server at https://api.leadocean.io/mcp. It has 13 tools, and seven of them never spend records. LangChain's MCP docs say a string target is reached over streamable HTTP and that auth="oauth" runs discovery, dynamic client registration and a browser redirect.

LeadOcean's OAuth server supports dynamic client registration, so the client can register itself. The sign-in opens a browser once per run unless you pass a token store. Save the code as mcp_tools.py.

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 the list to bind_tools in place of the three REST tools. LangChain's MCP examples run with ainvoke, so move the graph to async if you do. LangChain's docs say a server-reported error arrives as a ToolMessage with status="error", so the model can read it. Your budget guard does not travel with MCP tools. Check records with GET /v1/account or the get_account tool before a long run.

Keep one thing in mind when you mix the two. MCP calls go through your signed-in account, so they draw on the same records as the REST key. There is one balance however you call it.

REST suits a scheduled job with a key in the environment. MCP suits an interactive agent where a person can approve a sign-in.

What you get

Here is the free count for the example audience. On 21 October 2026 the MCP count returned 360 VP Sales and Head of Sales in US software companies with 51-200 staff whose email is verified, catch_all_valid or catch_all. The same filters with no email condition matched 863 people at 317 companies.

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

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

A search row has this shape. This is the response shape from the API reference, with placeholder values.

json
{
  "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme" },
  "contact_data": { "has_email": true, "email_status": "verified", "email_type": "work" },
  "meta": { "source_ids": { "person_id": "<digits>" } }
}

The agent's final message is a short table of the five people, then the emails for the two it enriched. The count in lo.spent is the one number to log. Write it next to the run id, and a month of runs becomes a cost report.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyRe-copy the key from app.leadocean.io and re-export $LEADOCEAN_API_KEY.
429 with Retry-After: 1Above your plan's rate per account (5 a second on Free, 50 on Solo, 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 its fair-use pace and is held to one request a minute until the window resetsDo not retry. Read X-Quota-Reset, tell the owner, and halt. Step 1 does.
402Free plan: the 1,000 records are spentHalt. The free records do not come back, so upgrade rather than wait.
503The index is temporarily unreachableRetry with backoff. Rejected requests are not metered.
400 or a count of 0A filter value outside the catalogue, such as industry: "Software"Resolve it with leadocean_list_enum_values or GET /v1/enums/industry and pass the exact string.

FAQ

How many LeadOcean records does one run cost?

At most 7 in this guide: 5 search rows and 2 enrichments. The count is free. A 404 on enrich costs nothing, and the code refuses to pass 10.

Should I use REST or MCP with LangGraph?

Use REST for unattended jobs, because a key in the environment needs no browser. Use MCP when a person runs the agent and can approve the OAuth sign-in. Records come from the same balance either way.

Does the agent 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, which ends the loop with a message.

Can I use another framework?

Yes. The REST client in Step 1 has no LangGraph in it. See the same recipe for Pydantic AI, Mastra and LlamaIndex. Plans are on pricing.

More recipes live in the blog hub.

Give your LangGraph agent real B2B data today

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

Get your free API key →