What You'll Build
If you're manually reading through form submissions and deciding which leads are worth a call, you're burning time that could go toward closing deals. In this tutorial, you'll build a fully autonomous lead qualification agent using the Claude API that scores incoming prospects, reasons through your criteria, and tells you exactly who to call first.
The finished agent runs in under 200 lines of Python, uses tool calling to simulate CRM lookups and scoring logic, and outputs a structured qualification decision with a confidence score and reasoning. You can wire it directly into your existing intake workflow.
The complete, working code for this agent is spread across the steps below in order. Copy each snippet in sequence and you'll have a fully functional lead qualification agent by the end. Every block is syntactically correct and tested against
claude-sonnet-4-6.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and dictionaries
anthropicSDK installed via pip (pip install anthropic)- A
.envfile or environment variable set forANTHROPIC_API_KEY
Step 1: Set Up the Anthropic SDK and Environment
Start by installing the SDK and verifying your API key is loading correctly. This is the foundation everything else runs on, so get this right before moving forward.
setup_check.pyimport os
import anthropic
from dotenv import load_dotenv
# Load your API key from a .env file in the project root
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# Quick sanity check — send a minimal message to confirm the key works
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Reply with: API connection successful."}]
)
print(response.content[0].text)
# Expected output: API connection successful.
If you see API connection successful. printed in your terminal, you're good to go. If you get an authentication error, double-check that your .env file has ANTHROPIC_API_KEY=sk-ant-... with no extra spaces.
Install
python-dotenv with pip install python-dotenv if you don't have it. Never hardcode your API key directly in source files — especially if you're pushing to GitHub.
Step 2: Define Lead Qualification Criteria and Tools
This is where you encode your actual sales logic. The agent needs two tools: one to look up enriched lead data (simulating a CRM call) and one to score the lead against your criteria. In a real deployment, the CRM lookup tool would hit your HubSpot or Salesforce API instead of returning mock data.
Tool definitions in the Anthropic SDK follow a JSON schema format. Claude reads these definitions, decides when to call them, and passes back structured arguments you can act on.
tools.pyimport anthropic
# These are the tool definitions Claude will use to reason about leads
LEAD_TOOLS = [
{
"name": "lookup_lead_data",
"description": (
"Retrieves enriched information about a lead from the CRM database. "
"Returns company size, industry, annual revenue estimate, job title, "
"and previous interactions."
),
"input_schema": {
"type": "object",
"properties": {
"lead_id": {
"type": "string",
"description": "The unique identifier for the lead record."
}
},
"required": ["lead_id"]
}
},
{
"name": "score_lead",
"description": (
"Scores a lead on a 0-100 scale based on qualification criteria: "
"budget fit, authority (decision-maker status), need (pain point match), "
"and timeline (purchase urgency). Returns a BANT score breakdown."
),
"input_schema": {
"type": "object",
"properties": {
"lead_id": {
"type": "string",
"description": "The unique identifier for the lead."
},
"budget_fit": {
"type": "integer",
"description": "Budget alignment score from 0 to 25."
},
"authority": {
"type": "integer",
"description": "Decision-maker authority score from 0 to 25."
},
"need": {
"type": "integer",
"description": "Pain point relevance score from 0 to 25."
},
"timeline": {
"type": "integer",
"description": "Purchase urgency score from 0 to 25."
}
},
"required": ["lead_id", "budget_fit", "authority", "need", "timeline"]
}
}
]
def lookup_lead_data(lead_id: str) -> dict:
"""
Simulates a CRM lookup. Replace this with a real API call in production.
"""
mock_crm_data = {
"lead_001": {
"name": "Sandra Kowalski",
"company": "Gulf Coast Auto Group",
"job_title": "General Manager",
"company_size": 85,
"industry": "Automotive Dealership",
"annual_revenue_estimate": 12000000,
"pain_points": ["slow lead follow-up", "manual inventory reporting"],
"timeline_to_purchase": "30-60 days",
"previous_interactions": 3,
"budget_discussed": True,
"budget_range": "$8,000 - $15,000/year"
},
"lead_002": {
"name": "Marcus Delray",
"company": "Beachside Bites LLC",
"job_title": "Owner",
"company_size": 12,
"industry": "Restaurant",
"annual_revenue_estimate": 480000,
"pain_points": ["no online ordering system"],
"timeline_to_purchase": "6+ months",
"previous_interactions": 1,
"budget_discussed": False,
"budget_range": "Unknown"
}
}
# Return empty dict if lead not found rather than crashing
return mock_crm_data.get(lead_id, {"error": f"Lead {lead_id} not found in CRM."})
def score_lead(lead_id: str, budget_fit: int, authority: int, need: int, timeline: int) -> dict:
"""
Aggregates the BANT scores and returns a qualification decision.
"""
total_score = budget_fit + authority + need + timeline
qualified = total_score >= 60 # Our threshold for a sales-ready lead
return {
"lead_id": lead_id,
"total_score": total_score,
"breakdown": {
"budget_fit": budget_fit,
"authority": authority,
"need": need,
"timeline": timeline
},
"qualified": qualified,
"disposition": "QUALIFIED — Schedule discovery call" if qualified else "DISQUALIFIED — Move to nurture sequence"
}
# Router that maps tool names to their Python functions
TOOL_FUNCTIONS = {
"lookup_lead_data": lookup_lead_data,
"score_lead": score_lead
}
Step 3: Create the Agent Agentic Loop
The agentic loop is the core of any autonomous AI agent. Claude looks at the lead data, decides which tools to call, processes the results, and keeps going until it has enough information to give you a final answer. This is what separates a simple chatbot from a real AI agent framework.
The pattern here is straightforward: send a message, check if Claude wants to use a tool, execute that tool, feed the result back, and repeat until you get a plain text response.
agent.pyimport os
import json
import anthropic
from dotenv import load_dotenv
from tools import LEAD_TOOLS, TOOL_FUNCTIONS
load_dotenv()
SYSTEM_PROMPT = """You are a lead qualification specialist for a Southwest Florida AI agency.
Your job is to evaluate incoming leads using the BANT framework (Budget, Authority, Need, Timeline).
For every lead:
1. Call lookup_lead_data to retrieve their CRM profile.
2. Analyze the profile and assign BANT sub-scores (each 0-25, total out of 100).
3. Call score_lead with your scores and the lead_id.
4. Summarize your qualification decision in 3-4 sentences, explaining your reasoning.
Be direct. Sales reps will act on your output immediately."""
class LeadQualificationAgent:
def __init__(self):
self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
self.model = "claude-sonnet-4-6"
def qualify_lead(self, lead_id: str) -> str:
"""
Runs the full agentic loop for a single lead and returns the qualification summary.
"""
messages = [
{
"role": "user",
"content": f"Please qualify lead ID: {lead_id}"
}
]
print(f"\n{'='*60}")
print(f"Starting qualification for lead: {lead_id}")
print(f"{'='*60}")
# Agentic loop — continues until Claude stops calling tools
while True:
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=SYSTEM_PROMPT,
tools=LEAD_TOOLS,
messages=messages
)
# Check why Claude stopped generating
if response.stop_reason == "end_turn":
# No more tool calls — extract the final text response
final_text = next(
block.text for block in response.content
if hasattr(block, "text")
)
print(f"\n📋 QUALIFICATION RESULT:\n{final_text}")
return final_text
if response.stop_reason == "tool_use":
# Claude wants to call one or more tools
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
print(f"\n🔧 Tool called: {tool_name}")
print(f" Input: {json.dumps(tool_input, indent=2)}")
# Execute the actual tool function
tool_fn = TOOL_FUNCTIONS.get(tool_name)
if tool_fn:
result = tool_fn(**tool_input)
else:
result = {"error": f"Unknown tool: {tool_name}"}
print(f" Result: {json.dumps(result, indent=2)}")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": json.dumps(result)
})
# Append Claude's response and all tool results to the message history
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
else:
# Unexpected stop reason — break to avoid infinite loop
print(f"Unexpected stop reason: {response.stop_reason}")
break
return "Qualification could not be completed."
Always append both the assistant's tool-use message AND your tool results back into the
messages list. If you skip either one, the API will throw a validation error about mismatched message roles. This is the most common mistake when building LLM agents for the first time.
Step 4: Implement Tool Calling for Lead Scoring
Now let's wire everything together with a runner script that processes multiple leads in a batch. This is what you'd actually call from a webhook handler or a scheduled cron job when new leads come in from your website or CRM.
run_qualification.pyimport json
from agent import LeadQualificationAgent
def run_batch_qualification(lead_ids: list[str]) -> list[dict]:
"""
Qualifies a list of leads and returns structured results.
"""
agent = LeadQualificationAgent()
results = []
for lead_id in lead_ids:
summary = agent.qualify_lead(lead_id)
results.append({
"lead_id": lead_id,
"summary": summary
})
return results
if __name__ == "__main__":
# Test with two leads — one that should qualify, one that shouldn't
leads_to_process = ["lead_001", "lead_002"]
batch_results = run_batch_qualification(leads_to_process)
print("\n" + "="*60)
print("BATCH QUALIFICATION COMPLETE")
print("="*60)
for result in batch_results:
print(f"\nLead ID: {result['lead_id']}")
print(f"Summary: {result['summary'][:200]}...")
Step 5: Test with Real Lead Data
Run the script with python run_qualification.py and you should see the agent working through each lead in real time. Watch the terminal output — you can see exactly which tools Claude calls and what data it's reasoning over.
Here's what the actual output looks like for both leads:
sample_output.txt============================================================
Starting qualification for lead: lead_001
============================================================
🔧 Tool called: lookup_lead_data
Input: {
"lead_id": "lead_001"
}
Result: {
"name": "Sandra Kowalski",
"company": "Gulf Coast Auto Group",
"job_title": "General Manager",
"company_size": 85,
"industry": "Automotive Dealership",
"annual_revenue_estimate": 12000000,
"pain_points": ["slow lead follow-up", "manual inventory reporting"],
"timeline_to_purchase": "30-60 days",
"previous_interactions": 3,
"budget_discussed": true,
"budget_range": "$8,000 - $15,000/year"
}
🔧 Tool called: score_lead
Input: {
"lead_id": "lead_001",
"budget_fit": 22,
"authority": 24,
"need": 23,
"timeline": 22
}
Result: {
"lead_id": "lead_001",
"total_score": 91,
"breakdown": {
"budget_fit": 22,
"authority": 24,
"need": 23,
"timeline": 22
},
"qualified": true,
"disposition": "QUALIFIED — Schedule discovery call"
}
📋 QUALIFICATION RESULT:
Sandra Kowalski from Gulf Coast Auto Group is a strong qualified lead
with a BANT score of 91/100. As General Manager, she has full purchasing
authority and a confirmed budget range of $8,000-$15,000/year that aligns
well with our AI automation packages. Her pain points around slow lead
follow-up and manual inventory reporting are problems we solve directly,
and her 30-60 day purchase timeline indicates genuine urgency.
Recommendation: Schedule a discovery call within 48 hours.
============================================================
Starting qualification for lead: lead_002
============================================================
🔧 Tool called: lookup_lead_data
Input: {
"lead_id": "lead_002"
}
Result: {
"name": "Marcus Delray",
"company": "Beachside Bites LLC",
"job_title": "Owner",
"company_size": 12,
"industry": "Restaurant",
"annual_revenue_estimate": 480000,
"pain_points": ["no online ordering system"],
"timeline_to_purchase": "6+ months",
"previous_interactions": 1,
"budget_discussed": false,
"budget_range": "Unknown"
}
🔧 Tool called: score_lead
Input: {
"lead_id": "lead_002",
"budget_fit": 8,
"authority": 25,
"need": 12,
"timeline": 6
}
Result: {
"lead_id": "lead_002",
"total_score": 51,
"breakdown": {
"budget_fit": 8,
"authority": 25,
"need": 12,
"timeline": 6
},
"qualified": false,
"disposition": "DISQUALIFIED — Move to nurture sequence"
}
📋 QUALIFICATION RESULT:
Marcus Delray from Beachside Bites LLC scores 51/100 and does not meet
the qualification threshold at this time. While he is the owner and has
full authority, budget has not been discussed and the $480K revenue
estimate suggests limited capacity for our service tier. His 6+ month
timeline and single prior interaction indicate he is still in early
research mode. Recommendation: Enroll in 90-day email nurture sequence
and re-evaluate in Q4.
How It Works
The agent isn't magic — it's a loop with a decision point. Claude reads your system prompt, which tells it exactly how to evaluate a lead. Then it calls tools in the order that makes sense to gather the data it needs before making a judgment call.
The stop_reason field is the key to the whole thing. When it's "tool_use", Claude is asking you to run something and report back. When it's "end_turn", it's done reasoning and is giving you the final answer. Everything in between is just passing data back and forth through the messages array.
The BANT scoring tool forces the model to commit to explicit numerical scores rather than vague language. That's intentional — you want defensible, consistent decisions you can audit, not qualitative hunches that change with every run.
Common Errors and Fixes
Error 1: Invalid API Key
anthropic.AuthenticationError: {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}Fix: Your .env file isn't loading or the key is malformed. Make sure you're calling load_dotenv() before initializing the client, and that your key starts with sk-ant-. Print os.environ.get("ANTHROPIC_API_KEY") to confirm it's actually being read.
Error 2: Mismatched Tool Result Message
anthropic.BadRequestError: messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row
Fix: You're appending tool results incorrectly. Tool results must be wrapped in a user message with "type": "tool_result" immediately after the assistant message containing the tool_use block. Make sure you're appending the assistant response first, then the tool results — never skip either one.
Error 3: Tool Input Schema Mismatch
anthropic.BadRequestError: tools.1.input_schema.properties.budget_fit: Input should be a valid integer
Fix: Claude occasionally returns a score as a string like "22" instead of an integer 22 when your schema isn't strict enough. Add input coercion in your tool function: budget_fit = int(budget_fit) at the top of score_lead(). You can also add "type": "integer" more explicitly in the schema to guide the model.
Next Steps
Once this is working locally, here are a few ways to extend it into something production-ready:
- Connect a real CRM: Replace the mock
lookup_lead_datafunction with an actual HubSpot or Salesforce API call using their Python SDKs. The tool interface stays exactly the same. - Add a webhook endpoint: Wrap the agent in a FastAPI route so it fires automatically when a new lead submits your contact form. You can have qualification results in under 10 seconds of form submission.
- Persist results to a database: Write the final score and disposition to a Postgres or Supabase table so sales reps can see a live dashboard of ranked leads without opening their CRM.
- Build a multi-agent pipeline: Feed qualified leads from this agent into a second agent that drafts a personalized outreach email based on the lead's specific pain points and industry. That's a full