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 (LangGraph and LeadOcean)

A two-node graph, three tools and a record budget guard. About 25 minutes and 8 records on the free plan.

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

You build a LangGraph graph with a model node and a tool node. The model sizes an audience for free, pulls a few people, enriches the best ones and scores each against your ICP. This guide uses the REST API because a script has no browser for sign-in. The MCP route is in Step 5.

TL;DR

  • Time: about 25 minutes, including the install.
  • Cost: the test run spends 8 LeadOcean records (5 people from search, 3 enrichments). Sizing is free. The free plan's 1,000 records cover about 125 such runs.
  • Tools return errors as data. A 402 stops the run, a 429 waits and retries, a 404 reports "not found".

The full code is in this post.

Prerequisites

  • Python 3.10 or newer.
  • A LeadOcean API key from app.leadocean.io, exported as $LEADOCEAN_API_KEY. The free plan has 1,000 records, one-off, no card.
  • An Anthropic key as $ANTHROPIC_API_KEY. Any tool-calling model works through init_chat_model.
  • Packages: langgraph, langchain, langchain-anthropic and httpx.
bash
pip install langgraph langchain langchain-anthropic httpx
export LEADOCEAN_API_KEY="..."   # from app.leadocean.io
export ANTHROPIC_API_KEY="..."

Step 1: Write one request helper with a record budget

Every tool goes through one function. It sends the x-api-key header and refuses a call that would pass your cap. A runaway loop then costs at most the cap, not the plan.

One person returned is one record. A count, a 404 and a rejected request cost nothing (LeadOcean docs, September 2026). The Free plan allows 5 requests a second per account, so the helper honours Retry-After on a 429.

python
import os, time, httpx

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

def call(method, path, *, params=None, json=None, cost=0):
    """Returns the body, or {"error": ...} the model can read. Never raises."""
    global spent
    if spent + cost > MAX_RECORDS:
        return {"error": f"STOP: budget cap {MAX_RECORDS} reached ({spent} spent)"}
    for attempt in range(3):
        r = httpx.request(method, BASE + path, headers=HEADERS,
                          params=params, json=json, timeout=30)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", "1"))
            if wait > 5:
                return {"error": f"STOP: throttled, retry in {wait}s"}
            time.sleep(wait)
            continue
        if r.status_code == 503:          # transient, never billed
            time.sleep(2 ** attempt)
            continue
        break
    if r.status_code == 402:
        return {"error": "STOP: out of records (402). Tell the user."}
    if r.status_code == 404:
        return {"found": False}
    if r.status_code >= 400:
        return {"error": f"{r.status_code}: {r.text[:200]}"}
    body = r.json()
    spent += (body.get("meta") or {}).get("credits", cost)
    return body

Step 2: Define the three tools

LangGraph runs LangChain tools. The @tool decorator reads the docstring and type hints, so write them for the model (LangGraph quickstart, September 2026).

The search body is flat JSON. Filters such as title, country and jobLevel are arrays of strings, and limit runs from 1 to 100. The free sizing call takes count=true as a query parameter and returns meta.total.

python
from langchain.tools import tool

@tool
def count_people(title: list[str], country: list[str]) -> dict:
    """Free. Count people by job title keywords and ISO country codes,
    for example ["VP Sales"] and ["CA"]. Call this before search_people."""
    body = call("POST", "/v1/people/search", params={"count": "true"},
                json={"title": title, "country": country, "limit": 1})
    return body if "error" in body else {"total": body["meta"]["total"]}

@tool
def search_people(title: list[str], country: list[str], limit: int = 5) -> dict:
    """Spends one record per person returned (max 10). Each row has a person_id.
    Use enrich_person for contact details."""
    limit = max(1, min(limit, 10))
    body = call("POST", "/v1/people/search", cost=limit,
                json={"title": title, "country": country, "limit": limit})
    return body if "error" in body else {"people": body["data"]}

@tool
def enrich_person(person_id: str) -> dict:
    """Spends one record. Enrich one person by person_id from search_people."""
    return call("POST", "/v1/people/enrich", cost=1,
                json={"person_id": person_id, "reveal_email": False})

Enrichment 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 second. The model qualifies from the enriched profile, so reveal_email stays false and no address is returned.

Step 3: Build the graph

The graph is the pattern from the LangGraph quickstart: a model node, a tool node and a conditional edge that loops until the model stops calling tools. The system prompt holds the ICP and the cost rules, because the model cannot see your budget.

python
from typing import Literal
from typing_extensions import TypedDict, Annotated
import operator
from langchain.chat_models import init_chat_model
from langchain.messages import AnyMessage, SystemMessage, ToolMessage
from langgraph.graph import StateGraph, START, END

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

SYSTEM = (
    "You qualify B2B leads. ICP: VP or head of sales at a software company. "
    "Call count_people first and report the total. Search at most 5 people, "
    "enrich at most 3. Score each enriched person 1 to 5 against the ICP with "
    "one line of reasoning. If a tool result starts with STOP, stop and say why."
)

class State(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]

def llm_call(state: State):
    return {"messages": [model.invoke([SystemMessage(content=SYSTEM)] + state["messages"])]}

def tool_node(state: State):
    out = []
    for tc in state["messages"][-1].tool_calls:
        result = tools_by_name[tc["name"]].invoke(tc["args"])
        out.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
    return {"messages": out}

def should_continue(state: State) -> Literal["tool_node", END]:
    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

Invoke the graph with a user message. Set a recursion limit so a confused model cannot loop forever, and print spent at the end.

python
from langchain.messages import HumanMessage

result = agent.invoke(
    {"messages": [HumanMessage(content="Find 5 VPs of sales in Canada, "
                                       "enrich the first 3 and score them.")]},
    {"recursion_limit": 12},
)
print(result["messages"][-1].content)
print(f"LeadOcean records spent: {spent}")

An expected run is one free count, one search of 5 people and three enrichments: 5 + 3 = 8 records. The cap in Step 1 ends a bad run at 25. Your model provider bills tokens on top, and this guide does not measure them.

A search row already says whether an address exists: has_email and email_status come back before you spend a record on enrichment. LeadOcean does not refund bounced emails, so read email_status before sending anything.

Step 5: Use the MCP server instead of REST

The hosted MCP server has 13 tools, including the free count_leads, count_companies, list_filters and list_enum_values. Its URL is https://api.leadocean.io/mcp, and it signs in with OAuth 2.1 instead of a pasted key. The OAuth server supports dynamic client registration at https://api.leadocean.io/oauth/register.

LangGraph consumes MCP tools through LangChain's MCPAdapter, which turns server tools into LangChain tools you pass to bind_tools and to the tool node. It needs langchain[mcp] 1.4.0 or newer and is in beta. It replaces the older langchain-mcp-adapters package (LangChain MCP docs, October 2026). This guide does not test the OAuth sign-in through it. Use REST for unattended jobs, because a key needs no sign-in. Use MCP for an agent a person supervises. Both spend the same records: search_leads is one per person returned, get_lead is one.

What you get

The free count is the first thing the agent does. This is the output of the free leadocean_count_leads MCP tool on 2026-10-21, with the filters from the run in Step 4: title VP Sales and country CA. total counts mailable people (verified, catch_all_valid and catch_all emails). totalAll is the same filters with no email condition. It cost 0 records.

json
{ "total": 658, "totalIsExact": true, "totalAll": 2661, "distinctCompanies": 590, "credits": 0 }

An enrichment returns the documented person shape. This is the response shape from the API reference, with placeholder values.

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe", "...": "headline, location" },
    "contact_data": { "has_email": true, "email_status": "verified" }
  },
  "meta": { "count": 1, "credits": 1 }
}

contact_emails and contact_phones appear only with reveal_email: true. Since 2026-10-17 enrichment also returns resume_data with past roles and education, each when held. Read the full schema in the API reference.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCheck $LEADOCEAN_API_KEY and that the header is x-api-key.
402The free plan's 1,000 records are spentThey do not reset. The tool returns STOP and the graph ends. Move to Solo or Agency.
429Above the plan's rate limit: 5 requests a second on Free, 200 on AgencyWait for Retry-After. The helper retries short waits and stops on long ones.
400 on searchA wrong-case enum such as jobLevel: "vp" instead of VPEnum values are exact strings. List them with leadocean_list_enum_values.
404 on enrichNo matching personCosts nothing. Report "not found" and move on.
Capped exportMore than 50,000 rows in a request, or 500,000 in a daySplit the export by filter. Do not page it from the agent.

FAQ

How many records does one run cost?

The run in this guide spends 8: 5 from search and 3 from enrichment. Counting is free. The MAX_RECORDS guard in Step 1 stops a bad run at 25.

Does the count tool spend records?

No. Sizing with count=true and limit=1 costs nothing, however often you call it. meta.total is capped at 100,000.

Why not use a prebuilt agent helper?

The explicit graph shows where the budget guard and the stop condition sit. Swap in a prebuilt helper later if you prefer. The tools do not change.

Can I run this on the free plan?

Yes. Free keys get the search and enrich scopes inside the 1,000 one-off records. No card is needed.

The same pattern in other frameworks: Pydantic AI, Mastra and LlamaIndex. Browse the full blog or see pricing.

Give your LangGraph agent real B2B data

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

Get your free API key →