Building a Sales Agent That Qualifies Leads Automatically
Why most "lead qualification bots" fail before they ship
Every sales team has the same story. A form fill comes in, it lands in a spreadsheet or a CRM stage called "New," and then it waits for a rep to eyeball it, guess whether it's worth fifteen minutes, and either book a call or let it rot. Multiply that by a few hundred leads a month and you get exactly what most B2B companies have today: a backlog nobody trusts and a sales team that only follows up with the leads that look obviously good, which usually means the ones that emailed from a corporate domain and used the word "budget" in the message box.
A sales agent that qualifies leads automatically is not a chatbot bolted onto a contact form. It's a small, deterministic pipeline wrapped around an LLM, where the model's job is narrow: read unstructured text (an email, a form submission, a chat transcript), extract structured signals, score them against a rubric your sales team actually agreed to, and take one of a few well-defined actions — book a meeting, send a nurture sequence, or flag for human review. The moment you let the model freelance beyond that scope, you get inconsistent scoring, hallucinated company sizes, and a sales director who stops trusting the system within a week.
This article walks through the actual architecture: how to define qualification criteria that an LLM can apply consistently, how to structure the agent so it calls real tools instead of just talking, how to keep a human in the loop without turning the agent into a rubber stamp, and where teams typically get this wrong. We'll use Python with the Claude API as the reference implementation, but the architecture ports cleanly to any framework.
Start with the qualification framework, not the prompt
Before writing a single line of agent code, write down the qualification framework as if you were training a new SDR. Most teams reach for BANT (Budget, Authority, Need, Timeline) or MEDDIC, but the exact framework matters less than making it explicit and machine-checkable. An LLM can't reliably score "authority" from a form submission unless you tell it what signals count as evidence of authority — job title patterns, company size thresholds, whether they mentioned decision-making language.
Here's a practical, scored version that works well as a first pass:
QUALIFICATION_RUBRIC = {
"company_size": {
"weight": 0.20,
"criteria": {
"enterprise (1000+ employees)": 10,
"mid_market (100-999)": 8,
"smb (10-99)": 5,
"micro (<10)": 2,
"unknown": 0,
},
},
"role_seniority": {
"weight": 0.25,
"criteria": {
"c_suite_or_vp": 10,
"director_or_head": 8,
"manager": 5,
"individual_contributor": 2,
"unknown": 0,
},
},
"stated_urgency": {
"weight": 0.20,
"criteria": {
"immediate (this quarter)": 10,
"near_term (next 1-2 quarters)": 6,
"exploratory (no timeline)": 2,
"unstated": 0,
},
},
"problem_fit": {
"weight": 0.25,
"criteria": {
"explicit_pain_matches_product": 10,
"adjacent_use_case": 6,
"vague_interest": 3,
"no_fit_signal": 0,
},
},
"engagement_signal": {
"weight": 0.10,
"criteria": {
"requested_demo_or_pricing": 10,
"downloaded_content_multiple_times": 6,
"single_page_visit": 2,
},
},
}
QUALIFICATION_THRESHOLDS = {
"hot": 7.5, # auto-book a call, notify AE immediately
"warm": 5.0, # add to nurture, notify AE within 24h
"cold": 0.0, # nurture sequence only, no AE notification
}Notice this rubric doesn't ask the model to output "qualified" or "not qualified" as a binary. It asks the model to classify each lead into a category per dimension, and the scoring math is deterministic Python, not something the LLM computes in its head. This is the single most important design decision in the whole system: the LLM classifies, your code scores. LLMs are unreliable at consistent arithmetic and drift over long conversations; a lookup table never drifts.
The extraction step: turning messy text into structured signals
The first real job of the agent is extraction — turning a free-text form submission, email, or chat log into the categories your rubric expects. This is where structured output (tool use / JSON schema) matters more than clever prompting.
import anthropic
import json
client = anthropic.Anthropic()
EXTRACTION_TOOL = {
"name": "extract_lead_signals",
"description": "Extract structured qualification signals from raw lead text.",
"input_schema": {
"type": "object",
"properties": {
"company_size": {
"type": "string",
"enum": ["enterprise (1000+ employees)", "mid_market (100-999)",
"smb (10-99)", "micro (<10)", "unknown"],
},
"role_seniority": {
"type": "string",
"enum": ["c_suite_or_vp", "director_or_head", "manager",
"individual_contributor", "unknown"],
},
"stated_urgency": {
"type": "string",
"enum": ["immediate (this quarter)", "near_term (next 1-2 quarters)",
"exploratory (no timeline)", "unstated"],
},
"problem_fit": {
"type": "string",
"enum": ["explicit_pain_matches_product", "adjacent_use_case",
"vague_interest", "no_fit_signal"],
},
"extracted_pain_point": {
"type": "string",
"description": "One sentence summarizing the lead's stated problem, verbatim where possible.",
},
"confidence_notes": {
"type": "string",
"description": "Brief note on which fields were inferred vs. explicitly stated.",
},
},
"required": ["company_size", "role_seniority", "stated_urgency",
"problem_fit", "extracted_pain_point", "confidence_notes"],
},
}
def extract_signals(lead_text: str, company_context: str = "") -> dict:
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=[EXTRACTION_TOOL],
tool_choice={"type": "tool", "name": "extract_lead_signals"},
messages=[{
"role": "user",
"content": f"""Extract qualification signals from this lead submission.
Only mark a field as known if there is direct or strongly implied evidence in the text.
Default to 'unknown' or 'unstated' rather than guessing.
Company context (if available): {company_context}
Lead submission:
---
{lead_text}
---""",
}],
)
for block in response.content:
if block.type == "tool_use":
return block.input
raise ValueError("Model did not return structured extraction")Forcing tool_choice to the extraction tool guarantees you get valid JSON matching your enum values every time — no parsing free-text responses, no regex fishing for "enterprise" in a paragraph. This is the difference between a demo and something that survives a few thousand real leads.
Scoring: deterministic math, not another LLM call
Once you have structured signals, scoring is plain code. This is intentional — it's testable, auditable, and free.
def score_lead(signals: dict) -> dict:
total_score = 0.0
breakdown = {}
for dimension, config in QUALIFICATION_RUBRIC.items():
value = signals.get(dimension, "unknown")
raw_score = config["criteria"].get(value, 0)
weighted_score = raw_score * config["weight"]
total_score += weighted_score
breakdown[dimension] = {
"value": value,
"raw_score": raw_score,
"weighted_score": round(weighted_score, 2),
}
if total_score >= QUALIFICATION_THRESHOLDS["hot"]:
tier = "hot"
elif total_score >= QUALIFICATION_THRESHOLDS["warm"]:
tier = "warm"
else:
tier = "cold"
return {
"total_score": round(total_score, 2),
"tier": tier,
"breakdown": breakdown,
}Because this function is pure and deterministic, you can unit test it with fixed inputs and know it will never drift, regardless of which model version you're calling upstream. That matters a lot when you're iterating on prompts — you never want a scoring regression to be silently caused by a prompt change six weeks ago that nobody remembers.
Wiring the agent to take action, not just to talk
A qualification score sitting in a log file helps nobody. The agent needs to call real tools — your CRM API, your calendar system, your email sender — based on the tier. This is where "agent" actually earns the word: it's a loop that decides which tool to call next based on state, not a single prompt-response pair.
def route_lead(lead_id: str, signals: dict, score_result: dict, contact_info: dict):
tier = score_result["tier"]
if tier == "hot":
booking_link = create_calendar_hold(
contact_email=contact_info["email"],
ae_id=assign_ae(signals),
priority="high",
)
send_email(
to=contact_info["email"],
template="hot_lead_instant_booking",
variables={"booking_link": booking_link},
)
notify_slack(
channel="#hot-leads",
message=f"Hot lead {lead_id} scored {score_result['total_score']}/10 — "
f"pain point: {signals['extracted_pain_point']}",
)
crm_update_stage(lead_id, stage="sales_qualified", score=score_result)
elif tier == "warm":
add_to_nurture_sequence(lead_id, sequence="warm_lead_14day")
crm_update_stage(lead_id, stage="marketing_qualified", score=score_result)
notify_slack(
channel="#warm-leads",
message=f"Warm lead {lead_id} — review within 24h.",
)
else: # cold
add_to_nurture_sequence(lead_id, sequence="long_term_nurture")
crm_update_stage(lead_id, stage="nurture", score=score_result)
log_qualification_decision(lead_id, signals, score_result)Each of those helper functions (create_calendar_hold, crm_update_stage, notify_slack) is a thin wrapper around a real API — HubSpot, Salesforce, Google Calendar, Slack's webhook API, whatever your stack uses. The agent's intelligence is entirely in the extraction and scoring steps; the routing step is boring, auditable business logic. Keep it that way. Teams that let the LLM decide "should I book a meeting or send an email" on the fly, without a deterministic tier boundary, end up with agents that book meetings for cold leads because the prompt happened to phrase something persuasively.
Handling ambiguity without letting the agent guess
Real lead text is messy. Someone writes "we're a growing team looking into automation tools" and gives you nothing you can map cleanly to company_size or stated_urgency. The temptation is to let the model fill gaps with its best guess — resist it. Instead, build an explicit low-confidence path.
def needs_human_review(signals: dict) -> bool:
unknown_count = sum(
1 for key in ["company_size", "role_seniority", "stated_urgency", "problem_fit"]
if signals.get(key) in ("unknown", "unstated", "no_fit_signal")
)
return unknown_count >= 2
def process_lead(lead_id: str, lead_text: str, contact_info: dict):
signals = extract_signals(lead_text, company_context=lookup_company(contact_info))
if needs_human_review(signals):
crm_update_stage(lead_id, stage="needs_manual_review")
notify_slack(
channel="#lead-review-queue",
message=f"Lead {lead_id} has insufficient signal for auto-scoring. "
f"Notes: {signals['confidence_notes']}",
)
return
score_result = score_lead(signals)
route_lead(lead_id, signals, score_result, contact_info)This single needs_human_review gate is what separates a production-grade agent from a liability. Two or more unknowns means the rubric can't confidently place the lead, so a human sees it before anything is auto-booked or auto-dismissed. Sales leaders will trust this system precisely because it admits uncertainty instead of forcing a score on thin data.
Enriching signals with external tools before scoring
Form text alone rarely has everything you need — company size, for instance, is often better pulled from a data provider than guessed from the submission. Give the agent a tool to look that up before extraction, so the model isn't inferring facts it can instead retrieve.
ENRICHMENT_TOOL = {
"name": "lookup_company_firmographics",
"description": "Look up employee count, industry, and funding stage for a company domain.",
"input_schema": {
"type": "object",
"properties": {
"domain": {"type": "string", "description": "Company website domain"},
},
"required": ["domain"],
},
}
def run_enrichment_and_extraction(lead_text: str, email_domain: str) -> dict:
messages = [{
"role": "user",
"content": f"A lead submitted this message from domain {email_domain}. "
f"Look up their company firmographics first, then extract qualification signals.\n\n"
f"Message:\n{lead_text}",
}]
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=[ENRICHMENT_TOOL, EXTRACTION_TOOL],
messages=messages,
)
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use" and block.name == "lookup_company_firmographics":
result = call_firmographics_api(block.input["domain"])
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
elif block.type == "tool_use" and block.name == "extract_lead_signals":
return block.input
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=[ENRICHMENT_TOOL, EXTRACTION_TOOL],
messages=messages,
)
raise ValueError("Extraction never completed")This is a genuine agentic loop: the model decides it needs firmographic data, calls the tool, gets real numbers back, and only then commits to a company_size classification. That's meaningfully more reliable than asking the model to infer company size from writing style, which is a surprisingly common failure mode in naive implementations.
Measuring whether the agent is actually working
Shipping the agent isn't the finish line — you need a feedback loop that tells you whether the scoring rubric matches reality. The cheapest way to do this is to store every qualification decision alongside the eventual sales outcome, then periodically check calibration.
def calculate_tier_conversion_rates(db_connection, days_back: int = 90):
query = """
SELECT tier, COUNT(*) as total,
SUM(CASE WHEN closed_won THEN 1 ELSE 0 END) as won
FROM lead_qualifications
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY tier
"""
rows = db_connection.execute(query, (days_back,)).fetchall()
return {
row["tier"]: {
"total_leads": row["total"],
"closed_won": row["won"],
"conversion_rate": round(row["won"] / row["total"], 3) if row["total"] else 0,
}
for row in rows
}If your "cold" tier is converting at nearly the same rate as your "warm" tier, your rubric weights are wrong, not your prompts. This is a recurring lesson: most quality problems in these systems trace back to a rubric that was guessed at in a planning meeting and never checked against actual deal outcomes. Re-run this report monthly and adjust the weights in QUALIFICATION_RUBRIC, not the extraction prompt — the prompt's job is to classify accurately, not to encode business priorities.
Guardrails that keep the agent from embarrassing your sales team
A few non-negotiable guardrails before this touches real leads:
- Never let the agent send outbound copy it wrote live. Use pre-approved templates with variable slots (like
hot_lead_instant_bookingabove). Free-form generated emails to prospects are a brand risk that isn't worth the personalization gain. - Log every extraction and score with the raw input text. When a sales rep disputes a score, you need to replay exactly what the model saw, not just the final tier.
- Rate-limit auto-booking. If your calendar tool has a bug or the model misfires, you don't want fifty calendar holds created in a loop. Cap auto-actions per hour and alert if the cap is hit.
- Version your rubric. Store which rubric version scored each lead so you can compare cohorts fairly when you tune weights later.
- Keep a manual override path. Reps should be able to re-tier a lead with one click, and that correction should feed back into your calibration report.
None of these are exotic — they're the same discipline you'd apply to any automated system that touches revenue. The LLM doesn't change that; it just adds a new failure mode (misclassification) on top of the usual ones (API failures, race conditions, bad data).
Where this fits in a larger sales stack
This qualification agent is deliberately narrow — extract, score, route. In practice you'll often chain it with a research agent that pulls recent company news before the first AE call, and a follow-up agent that drafts (but doesn't send) personalized outreach for a human to review. Keep these as separate agents with clear boundaries rather than one sprawling prompt trying to do everything; it's far easier to debug "the extraction agent misclassified urgency" than "the mega-agent did something weird somewhere in a 4,000-token system prompt."
The pattern here — structured extraction via forced tool use, deterministic scoring in plain code, explicit human-review gates for low-confidence cases, and a feedback loop tied to real outcomes — generalizes well beyond sales. It's the same shape you'd use for support ticket triage, applicant screening, or insurance claim routing. Once you've built one of these agents properly, the next one is mostly a new rubric and a new set of tools.
If you want to go deeper on building agents like this one — tool use, multi-step agentic loops, evaluation harnesses, and shipping them into real business workflows rather than notebooks — that's exactly what we cover hands-on in 30 Days of Hermes Agent, our cohort course on building production AI agents from scratch.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.