teachyou.ai academy
← All posts
LangGraph

LangGraph for Sales Qualification Agents

Pramod Dutta · Jun 17, 2026 · 15 min read

AUTHOR: Ira Menon

Why a chatbot isn't enough for sales qualification

Most teams that try to automate lead qualification start with a simple LLM wrapper: take an inbound message, run it through a prompt that asks the model to extract budget, authority, need, and timeline (BANT), and spit out a score. It works for the first message. It falls apart on the second.

Sales qualification is not a single-turn classification task. It's a process with memory, branching logic, and side effects. A real qualification conversation needs to remember what the prospect already said three messages ago, decide whether to ask a clarifying question or move on, check the lead against CRM data, escalate to a human when the deal size crosses a threshold, and gracefully handle a prospect who goes quiet for two days and comes back with a completely different question. A single prompt call can't hold that shape. You need a state machine that an LLM operates inside of, not an LLM that pretends to be a state machine.

This is exactly the gap LangGraph was built to close. It gives you explicit nodes, explicit edges, and a persistent state object that survives across turns, retries, and even process restarts. Instead of stuffing your entire qualification logic into one enormous system prompt and hoping the model remembers to follow it, you encode the logic as a graph and let the LLM handle only the parts that genuinely require judgment: understanding free-text answers, deciding what to ask next, and writing follow-up copy that doesn't sound like a form.

In this article we'll build a sales qualification agent step by step in LangGraph — not a toy demo, but a structure you could actually put in front of inbound leads. We'll cover state design, the qualification graph itself, conditional routing based on lead score, tool calls to enrich data, human handoff, and the operational details that decide whether this survives contact with real prospects.

What a qualification agent actually needs to do

Before touching code, it's worth being precise about the job. A sales qualification agent sits between "someone filled out a form or sent a message" and "a human sales rep gets on a call." Its responsibilities are:

  • Collect missing information without sounding like a wizard-style form. If a prospect already mentioned company size in their first message, don't ask for it again.
  • Score the lead against criteria your sales team actually uses — usually some variant of budget, authority, need, timeline, or company fit signals like industry and headcount.
  • Decide what happens next. A hot, well-qualified lead should route to a human immediately. A cold or clearly-not-a-fit lead should get a polite disqualification message. A partially qualified lead needs another round of questions.
  • Persist state across an asynchronous conversation that might span minutes or days, across page reloads, Slack threads, or email replies.
  • Escalate cleanly. When the agent isn't confident, it should hand off with full context rather than guessing or looping forever.
  • Log everything in a way that a revenue-ops person can audit later — why did this lead get scored a 7 instead of a 9?

Notice that almost none of this is "generate text." Most of it is control flow and data management, with LLM calls embedded at specific decision points. That's the mental model LangGraph encourages, and it's why it fits this problem so much better than a bare chat completion loop.

Modeling qualification as a graph, not a prompt

LangGraph represents your agent as a directed graph of nodes, where each node is a Python function that receives the current state and returns updates to it. Edges connect nodes, and edges can be conditional — the output of a node, or a separate router function, decides which node runs next.

For sales qualification, a natural graph looks like this:

  • intake — parse the incoming message or form submission into structured fields
  • enrich — call out to a CRM or firmographic data API to fill in company details
  • extract_criteria — use the LLM to pull BANT-style signals out of free text
  • score_lead — compute a numeric or categorical score from the criteria collected so far
  • decide_next_action — a router node that inspects the score and missing-field state
  • ask_followup — generate a targeted question for missing information
  • disqualify — send a polite close-out message
  • handoff_to_human — post to Slack or create a CRM task with full context

The graph loops between ask_followup and extract_criteria until either the score crosses a threshold or a maximum number of turns is reached, at which point it forces a decision. This loop is the part that's awkward to express in a plain prompt chain but trivial in LangGraph, because cycles are a first-class part of the graph model, not something you have to fake with recursive function calls.

Setting up state with a typed schema

Everything in LangGraph flows through a state object, typically defined with TypedDict or a Pydantic model. Getting this schema right early saves you from a lot of rework, because every node reads from and writes to this shared structure.

from typing import TypedDict, Optional, Literal
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from typing import Annotated

class QualificationState(TypedDict):
    messages: Annotated[list, add_messages]
    company_name: Optional[str]
    company_size: Optional[int]
    industry: Optional[str]
    budget_range: Optional[str]
    has_authority: Optional[bool]
    timeline: Optional[str]
    pain_points: list[str]
    lead_score: int
    missing_fields: list[str]
    turn_count: int
    decision: Optional[Literal["ask_more", "qualify", "disqualify", "handoff"]]

The messages field uses LangGraph's add_messages reducer, which appends new messages instead of overwriting the list — this is how conversational memory works across graph invocations. Every other field is a plain value that nodes overwrite directly, since qualification data (like lead_score or company_size) should reflect the latest known value, not a running history.

Keeping turn_count in state is a small detail that matters a lot in production. Without a hard cap on qualification turns, an LLM-driven loop can happily ask a prospect the same kind of question five different ways because it's still "not confident enough." A turn limit forces the graph to make a decision with whatever information it has, which is closer to how a human rep actually behaves.

Building the extraction and scoring nodes

The extraction node is the one place where you genuinely want an LLM, because parsing "we're a 40-person fintech startup, I'm the head of ops and honestly budget isn't locked yet" into structured fields is a language understanding problem, not a control-flow problem.

from langchain_anthropic import ChatAnthropic
from pydantic import BaseModel, Field

llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0)

class ExtractedCriteria(BaseModel):
    company_size: Optional[int] = Field(None, description="Employee count if mentioned")
    industry: Optional[str] = Field(None, description="Industry or vertical")
    budget_range: Optional[str] = Field(None, description="Budget signal if mentioned")
    has_authority: Optional[bool] = Field(None, description="Does this person sound like a decision maker")
    timeline: Optional[str] = Field(None, description="Buying timeline if mentioned")
    pain_points: list[str] = Field(default_factory=list, description="Problems the prospect described")

structured_llm = llm.with_structured_output(ExtractedCriteria)

def extract_criteria(state: QualificationState) -> dict:
    last_message = state["messages"][-1].content
    result = structured_llm.invoke(
        f"Extract sales qualification signals from this prospect message. "
        f"Only fill in fields that are clearly stated or strongly implied. "
        f"Message: {last_message}"
    )
    updates = {}
    if result.company_size:
        updates["company_size"] = result.company_size
    if result.industry:
        updates["industry"] = result.industry
    if result.budget_range:
        updates["budget_range"] = result.budget_range
    if result.has_authority is not None:
        updates["has_authority"] = result.has_authority
    if result.timeline:
        updates["timeline"] = result.timeline
    if result.pain_points:
        updates["pain_points"] = state.get("pain_points", []) + result.pain_points
    return updates

Using with_structured_output here instead of asking the model to return free text and parsing it yourself removes an entire class of brittle regex parsing. It also gives you a hard contract: if the model can't confidently fill a field, it returns None, and your node logic can treat that as "still missing" rather than guessing from a malformed string.

The scoring node is deliberately simple and deterministic — resist the urge to have the LLM assign the score directly. A rules-based score is auditable, consistent across runs, and won't drift if you change model providers later.

def score_lead(state: QualificationState) -> dict:
    score = 0
    missing = []

    if state.get("company_size"):
        score += 20 if state["company_size"] >= 50 else 10
    else:
        missing.append("company_size")

    if state.get("has_authority"):
        score += 25
    elif state.get("has_authority") is False:
        score += 5
    else:
        missing.append("has_authority")

    if state.get("budget_range"):
        score += 25
    else:
        missing.append("budget_range")

    if state.get("timeline") in ("this_quarter", "this_month", "asap"):
        score += 20
    elif state.get("timeline"):
        score += 10
    else:
        missing.append("timeline")

    if state.get("pain_points"):
        score += min(10, len(state["pain_points"]) * 5)

    return {"lead_score": score, "missing_fields": missing}

This keeps the LLM's job narrow — interpretation — and keeps your business logic in plain Python where your sales-ops team can actually review and change the thresholds without touching a prompt.

Routing with conditional edges

This is where LangGraph earns its keep. The decide_next_action node doesn't call the LLM at all; it's a router that inspects the state and returns a string, which LangGraph uses to pick the next node via a conditional edge.

def decide_next_action(state: QualificationState) -> dict:
    score = state["lead_score"]
    missing = state["missing_fields"]
    turns = state["turn_count"]

    if score >= 70:
        decision = "handoff"
    elif score < 20 and turns >= 2:
        decision = "disqualify"
    elif missing and turns < 4:
        decision = "ask_more"
    elif score >= 40:
        decision = "handoff"
    else:
        decision = "disqualify"

    return {"decision": decision}

def route_from_decision(state: QualificationState) -> str:
    return state["decision"]

graph = StateGraph(QualificationState)
graph.add_node("extract_criteria", extract_criteria)
graph.add_node("score_lead", score_lead)
graph.add_node("decide_next_action", decide_next_action)
graph.add_node("ask_followup", ask_followup)
graph.add_node("disqualify", disqualify)
graph.add_node("handoff_to_human", handoff_to_human)

graph.set_entry_point("extract_criteria")
graph.add_edge("extract_criteria", "score_lead")
graph.add_edge("score_lead", "decide_next_action")

graph.add_conditional_edges(
    "decide_next_action",
    route_from_decision,
    {
        "ask_more": "ask_followup",
        "disqualify": "disqualify",
        "handoff": "handoff_to_human",
    },
)

graph.add_edge("ask_followup", END)
graph.add_edge("disqualify", END)
graph.add_edge("handoff_to_human", END)

Each invocation of the graph processes one incoming message and ends either by asking a follow-up (waiting for the next prospect reply) or by reaching a terminal state. The loop across multiple prospect messages happens at the application layer: you invoke the graph again with the updated state and the new message each time the prospect replies, rather than trying to keep the whole conversation inside one long-running graph execution.

This separation matters for a channel like email or Slack, where replies can come hours or days apart. You're not holding a process open waiting for a reply — you're persisting state and resuming the graph when a new message arrives.

Adding enrichment as a tool call

Real qualification usually benefits from data the prospect didn't type — company size from a firmographic API, existing CRM history, or whether this domain already has an open deal. LangGraph handles this cleanly as a node that calls an external tool before or alongside extraction.

def enrich_from_crm(state: QualificationState) -> dict:
    domain = extract_domain_from_email(state["messages"][-1])
    if not domain:
        return {}

    existing = crm_client.lookup_company(domain)
    if not existing:
        return {}

    updates = {}
    if not state.get("company_size") and existing.get("employee_count"):
        updates["company_size"] = existing["employee_count"]
    if not state.get("industry") and existing.get("industry"):
        updates["industry"] = existing["industry"]
    return updates

Place this node before extract_criteria in the graph, and have extract_criteria only fill in fields that enrichment left empty. This ordering means the LLM is never asked to guess at something a database already knows, which both improves accuracy and shortens the conversation — nobody wants to be asked their company's headcount when it's public information.

If you're using LangGraph with the prebuilt ReAct-style agent instead of a fully custom graph, the same enrichment logic can be exposed as a bound tool and the LLM will decide when to call it. For qualification specifically, I'd lean toward the explicit node approach shown above rather than letting the model decide whether to enrich — you want enrichment to happen deterministically on every new lead, not conditionally based on model judgment.

Human handoff with full context

The handoff node is where a lot of teams under-invest, and it's usually the single biggest driver of whether reps trust the automation. A handoff that says "here's a hot lead, good luck" gets ignored. A handoff with structured context gets acted on.

def handoff_to_human(state: QualificationState) -> dict:
    summary = (
        f"Lead score: {state['lead_score']}/100\n"
        f"Company: {state.get('company_name', 'unknown')} "
        f"({state.get('company_size', '?')} employees, {state.get('industry', 'unknown industry')})\n"
        f"Authority signal: {state.get('has_authority')}\n"
        f"Budget: {state.get('budget_range', 'not stated')}\n"
        f"Timeline: {state.get('timeline', 'not stated')}\n"
        f"Pain points: {', '.join(state.get('pain_points', [])) or 'none captured'}\n"
        f"Turns to qualify: {state['turn_count']}"
    )
    slack_client.post_message(
        channel="#sales-handoff",
        text=summary,
        thread_context=state["messages"],
    )
    crm_client.create_task(
        lead_id=state.get("company_name"),
        priority="high" if state["lead_score"] >= 70 else "medium",
        notes=summary,
    )
    return {}

The point isn't the specific Slack or CRM API calls — it's that the state object you've been carefully building through the graph is exactly the payload a rep needs, with zero extra summarization work. This is a direct payoff of modeling qualification as structured state instead of a raw transcript: by the time a human gets involved, the ambiguous parts have already been resolved.

Persistence and checkpointing

Because qualification conversations span an unpredictable amount of time, you need the graph's state to survive between invocations. LangGraph's checkpointer system handles this without you having to build your own session store.

from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string(DATABASE_URL)
compiled_graph = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": lead_id}}
result = compiled_graph.invoke(
    {"messages": [new_prospect_message], "turn_count": current_turn_count + 1},
    config=config,
)

Using the lead's unique identifier as the thread_id means every subsequent message from that prospect resumes exactly where the conversation left off — same scores, same missing fields, same message history — regardless of whether the reply comes back in ten seconds or ten days. This is one of the more understated advantages of LangGraph over a hand-rolled agent loop: you get durable, resumable state without writing your own serialization layer, and you get it in a form (Postgres, SQLite, or Redis-backed checkpointers) that's easy to inspect and debug when a qualification run behaves unexpectedly.

It's also worth checkpointing at every node, not just at the end of a turn. If your enrich_from_crm node fails because the CRM API timed out, you don't want to re-run extract_criteria and burn another LLM call — you want to resume from right after the failure. LangGraph's node-level checkpointing gives you that for free, which matters more than it sounds once you're running this against real inbound volume with real third-party API flakiness.

Testing the graph without waiting on real leads

Because the graph is just a Python object with a defined interface, you can test qualification logic deterministically without any LLM calls at all, by injecting state directly.

def test_high_score_triggers_handoff():
    state = {
        "lead_score": 85,
        "missing_fields": [],
        "turn_count": 2,
    }
    result = decide_next_action(state)
    assert result["decision"] == "handoff"

def test_low_score_after_two_turns_disqualifies():
    state = {"lead_score": 10, "missing_fields": ["budget_range"], "turn_count": 2}
    result = decide_next_action(state)
    assert result["decision"] == "disqualify"

This is worth calling out because it's a real advantage over prompt-only agents: your routing logic is plain functions you can unit test like any other code. Reserve your (slower, costlier) end-to-end tests with actual LLM calls for verifying that extraction handles messy real-world phrasing correctly, and keep the routing tests fast and deterministic. In practice this split cuts CI time dramatically once you have more than a handful of qualification scenarios to cover.

Common failure modes to design around

A few things go wrong repeatedly when teams put these agents in front of real leads, and it's worth designing for them up front rather than patching after a bad week.

  • Infinite follow-up loops. Without a turn cap and a forced-decision fallback, a cautious scoring threshold can cause the graph to ask for the same missing field indefinitely if the prospect keeps giving vague answers.
  • Re-asking known information. If enrichment or extraction silently fails and you don't check missing_fields before generating a follow-up question, you'll ask a prospect something they already told you, which reads as broken to a human.
  • Score inflation from repeated fields. If your extraction node appends to pain_points every turn without deduplication, a prospect who restates the same problem twice can inflate their own score.
  • Silent enrichment failures. Treat a CRM lookup timeout as "no data," not as an error that halts the graph — qualification should degrade gracefully to asking the prospect directly.
  • Handoff without dedup. Make sure a lead that qualifies on turn 3 doesn't get handed off again on turn 4 if the graph gets re-invoked with stale state; check for an existing handoff flag before posting again.

None of these are exotic problems, but they're the difference between a demo that works in a sales meeting and a system that survives two months of real inbound traffic without generating complaints from either prospects or reps.

Where this fits into a bigger sales stack

A qualification graph like this is rarely the whole system — it usually sits behind a webhook that receives form submissions, email replies, or chat widget messages, and in front of whatever the rep-facing tools are (CRM, Slack, calendar booking). The graph doesn't need to know about any of that; its job is to turn unstructured conversation into a scored, structured decision, and LangGraph is what lets you express that decision process as something explicit and reviewable rather than a prompt that happens to work most of the time.

If you're building agents that need to hold state across turns, branch on business logic, and hand off cleanly to humans, sales qualification is one of the clearest use cases to learn the pattern on, because the requirements are concrete and the failure modes are easy to observe. The same graph structure — extract, score, route, act — shows up again in support triage, application screening, and onboarding flows, so the time spent getting the state schema and routing logic right here pays off well beyond this one use case.

If you want to go deeper into building graphs like this — checkpointing strategies, multi-agent handoff patterns, streaming partial state to a frontend, and debugging graph execution with LangGraph's tooling — that's exactly what we cover hands-on in the LangGraph Tutorial course on teachyou.ai.