What You'll Build
If you've ever lost a hot lead because it got buried in a spreadsheet, this tutorial is for you. You're going to build a working lead scoring agent in Python using the Claude API — one that reads raw lead data, calls tools to qualify and score each lead, and routes them into priority buckets automatically. The whole thing runs in under 200 lines of code and you can wire it into any CRM or form backend you already use.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (grab one at console.anthropic.com)
anthropicSDK installed:pip install anthropic- Basic comfort reading Python — you don't need to be an expert
- A
.envfile or environment variable set forANTHROPIC_API_KEY
Step 1: Set Up Your Claude API Client and Environment
First things first — let's get the client connected and verify your key works before we write a single line of agent logic. This also sets up the environment loading pattern you'll use throughout the project.
Create a new file called lead_scorer.py and start here:
import os
import json
from anthropic import Anthropic
# Load your API key from the environment
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
if not ANTHROPIC_API_KEY:
raise ValueError("ANTHROPIC_API_KEY environment variable is not set.")
# Initialize the Anthropic client — this is your connection to Claude
client = Anthropic()
# We'll use claude-sonnet-4-6 throughout this tutorial
MODEL = "claude-sonnet-4-6"
def test_connection():
"""Quick sanity check that your API key and client are working."""
response = client.messages.create(
model=MODEL,
max_tokens=64,
messages=[{"role": "user", "content": "Reply with: Connection successful."}]
)
print(response.content[0].text)
if __name__ == "__main__":
test_connection()Run it with python lead_scorer.py. If you see Connection successful. printed back, you're good to go. If you get an auth error, double-check that your ANTHROPIC_API_KEY is actually exported in your shell session.
export ANTHROPIC_API_KEY=your_key_here in your terminal, or drop it in a .env file and load it with python-dotenv. Never hardcode API keys in source files.
Step 2: Define Lead Scoring Tools
This is where Claude API tool use gets interesting. Instead of asking Claude to just produce text, we give it a set of tools — functions it can call to take structured actions. Think of tools as the buttons Claude is allowed to press on your behalf.
We're defining three tools: one to qualify the lead, one to score them numerically, and one to route them to the right sales bucket. Add these tool definitions to your file:
lead_scorer.py (continued)# Tool definitions — these tell Claude what actions it can take
TOOLS = [
{
"name": "qualify_lead",
"description": (
"Analyzes a lead's basic information to determine if they meet "
"minimum qualification criteria such as budget, intent, and timeline."
),
"input_schema": {
"type": "object",
"properties": {
"meets_budget": {
"type": "boolean",
"description": "True if the lead's stated budget meets the minimum threshold."
},
"has_clear_intent": {
"type": "boolean",
"description": "True if the lead has expressed a specific purchase or service intent."
},
"timeline_days": {
"type": "integer",
"description": "Estimated number of days until the lead wants to take action."
},
"disqualify_reason": {
"type": "string",
"description": "If disqualified, a brief reason. Empty string if qualified."
}
},
"required": ["meets_budget", "has_clear_intent", "timeline_days", "disqualify_reason"]
}
},
{
"name": "score_lead",
"description": (
"Assigns a numeric lead score from 0 to 100 based on qualification "
"signals, engagement level, and fit for the business."
),
"input_schema": {
"type": "object",
"properties": {
"score": {
"type": "integer",
"description": "Lead score from 0 (cold/unfit) to 100 (perfect fit, ready to buy)."
},
"score_rationale": {
"type": "string",
"description": "One to two sentence explanation of why this score was assigned."
}
},
"required": ["score", "score_rationale"]
}
},
{
"name": "route_lead",
"description": (
"Routes the lead to the appropriate sales queue or follow-up action "
"based on their score and qualification status."
),
"input_schema": {
"type": "object",
"properties": {
"queue": {
"type": "string",
"enum": ["hot", "warm", "nurture", "disqualified"],
"description": "The sales queue to assign this lead to."
},
"recommended_action": {
"type": "string",
"description": "Specific next step a sales rep should take with this lead."
},
"follow_up_days": {
"type": "integer",
"description": "How many days until the next follow-up should happen."
}
},
"required": ["queue", "recommended_action", "follow_up_days"]
}
}
]Notice each tool has a strict input_schema — this is how Claude knows exactly what shape of data to return. The enum on the queue field is especially useful because it prevents Claude from inventing new categories. You stay in control of the output structure.
Step 3: Create the Agent Loop with Tool Use
This is the heart of the agent. The loop keeps talking to Claude until it's done using tools and ready to give a final answer. Every time Claude calls a tool, we execute the corresponding Python function and send the result back — then Claude decides what to do next.
Here's the full agent class and its loop:
lead_scorer.py (continued)class LeadScorerAgent:
"""
An agent that qualifies, scores, and routes sales leads
using Claude's tool use capability.
"""
def __init__(self):
self.client = client
self.model = MODEL
# Stores results from each tool call for the final report
self.tool_results_log = {}
def _execute_tool(self, tool_name: str, tool_input: dict) -> str:
"""
Dispatches a tool call to the correct handler function
and returns the result as a JSON string for Claude.
"""
self.tool_results_log[tool_name] = tool_input
if tool_name == "qualify_lead":
return self._handle_qualify_lead(tool_input)
elif tool_name == "score_lead":
return self._handle_score_lead(tool_input)
elif tool_name == "route_lead":
return self._handle_route_lead(tool_input)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def _handle_qualify_lead(self, inputs: dict) -> str:
"""Processes qualification signals and returns a structured result."""
qualified = inputs["meets_budget"] and inputs["has_clear_intent"]
result = {
"qualified": qualified,
"timeline_days": inputs["timeline_days"],
"disqualify_reason": inputs.get("disqualify_reason", "")
}
return json.dumps(result)
def _handle_score_lead(self, inputs: dict) -> str:
"""Accepts the score and rationale, logs them, returns confirmation."""
result = {
"score_recorded": inputs["score"],
"rationale": inputs["score_rationale"]
}
return json.dumps(result)
def _handle_route_lead(self, inputs: dict) -> str:
"""Routes the lead and returns the assigned queue and action."""
result = {
"routed_to": inputs["queue"],
"action": inputs["recommended_action"],
"follow_up_in_days": inputs["follow_up_days"]
}
return json.dumps(result)
def run(self, lead_data: dict) -> dict:
"""
Main agent loop. Sends lead data to Claude, handles tool calls
in a loop until Claude stops calling tools, then returns results.
"""
system_prompt = (
"You are a lead qualification specialist for a real estate and business services agency. "
"When given a lead, you MUST call the tools in this exact order: "
"first qualify_lead, then score_lead, then route_lead. "
"Use all three tools before giving any final response. "
"Base your analysis strictly on the data provided."
)
user_message = (
f"Please qualify, score, and route this lead:\n\n"
f"{json.dumps(lead_data, indent=2)}"
)
messages = [{"role": "user", "content": user_message}]
# Agent loop — runs until Claude stops calling tools
while True:
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=system_prompt,
tools=TOOLS,
messages=messages
)
# If Claude is done (no more tool calls), exit the loop
if response.stop_reason == "end_turn":
break
# Collect all tool uses from this response turn
tool_uses = [b for b in response.content if b.type == "tool_use"]
# If there are no tool uses and stop_reason isn't end_turn, exit anyway
if not tool_uses:
break
# Add Claude's response (including tool calls) to the conversation
messages.append({"role": "assistant", "content": response.content})
# Execute each tool Claude requested and collect results
tool_results = []
for tool_use in tool_uses:
result_text = self._execute_tool(tool_use.name, tool_use.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result_text
})
# Send all tool results back to Claude in a single user turn
messages.append({"role": "user", "content": tool_results})
# Build the final report from logged tool results
return self._build_report(lead_data)
def _build_report(self, lead_data: dict) -> dict:
"""Compiles tool results into a clean final report dictionary."""
qualify_data = self.tool_results_log.get("qualify_lead", {})
score_data = self.tool_results_log.get("score_lead", {})
route_data = self.tool_results_log.get("route_lead", {})
return {
"lead_name": lead_data.get("name", "Unknown"),
"qualified": qualify_data.get("meets_budget", False) and qualify_data.get("has_clear_intent", False),
"score": score_data.get("score", 0),
"score_rationale": score_data.get("score_rationale", ""),
"queue": route_data.get("queue", "nurture"),
"recommended_action": route_data.get("recommended_action", ""),
"follow_up_days": route_data.get("follow_up_days", 7),
"disqualify_reason": qualify_data.get("disqualify_reason", "")
}messages before sending back tool results. If you skip that step, the API will throw a validation error because the conversation history won't make sense to the model.
Step 4: Implement Lead Data Parsing
Your agent needs real lead data to work with. In production, this would come from a webhook, a form submission, or a CRM export. For now, we'll define a simple parser that normalizes raw lead dictionaries into a consistent format the agent expects.
lead_scorer.py (continued)def parse_lead(raw: dict) -> dict:
"""
Normalizes a raw lead dictionary into the standard format
the LeadScorerAgent expects. Handles missing fields gracefully.
"""
return {
"name": raw.get("name", "Unknown"),
"email": raw.get("email", ""),
"phone": raw.get("phone", ""),
"source": raw.get("source", "organic"),
"message": raw.get("message", ""),
# Budget is stored as an integer — default 0 if missing or unparseable
"budget": int(raw.get("budget", 0)),
"timeline": raw.get("timeline", "unknown"),
"property_type": raw.get("property_type", ""),
"location_interest": raw.get("location_interest", "")
}
# Sample leads — these simulate real form submissions from a Southwest Florida real estate site
SAMPLE_LEADS = [
{
"name": "Maria Gonzalez",
"email": "[email protected]",
"phone": "239-555-0101",
"source": "google_ads",
"message": "Looking to buy a single-family home in Naples. Budget around $850K. Want to move in 60 days.",
"budget": 850000,
"timeline": "60 days",
"property_type": "single_family",
"location_interest": "Naples, FL"
},
{
"name": "Tom Ricci",
"email": "[email protected]",
"phone": "",
"source": "facebook_ad",
"message": "Just browsing, not sure what I want yet. Maybe something under 200K someday.",
"budget": 200000,
"timeline": "unknown",
"property_type": "",
"location_interest": "Southwest Florida"
},
{
"name": "Sandra Kim",
"email": "[email protected]",
"phone": "239-555-0188",
"source": "referral",
"message": "Relocating from Chicago for work. Need a 3BR condo in Bonita Springs within 45 days. Budget is $600K.",
"budget": 600000,
"timeline": "45 days",
"property_type": "condo",
"location_interest": "Bonita Springs, FL"
}
]Step 5: Run Your Agent on Sample Leads
Now we tie it all together. This final block loops over the sample leads, parses each one, runs it through the agent, and prints a clean summary. This is what you'd replace with a webhook handler or database write in a real deployment.
lead_scorer.py (continued)def print_report(report: dict):
"""Prints a formatted lead scoring report to the console."""
print("\n" + "=" * 55)
print(f" LEAD REPORT: {report['lead_name']}")
print("=" * 55)
print(f" Qualified: {'✅ Yes' if report['qualified'] else '❌ No'}")
if report["disqualify_reason"]:
print(f" DQ Reason: {report['disqualify_reason']}")
print(f" Score: {report['score']} / 100")
print(f" Queue: {report['queue'].upper()}")
print(f" Follow-up: {report['follow_up_days']} days")
print(f" Action: {report['recommended_action']}")
print(f" Rationale: {report['score_rationale']}")
print("=" * 55)
def main():
agent = LeadScorerAgent()
for raw_lead in SAMPLE_LEADS:
# Reset tool log for each new lead
agent.tool_results_log = {}
lead = parse_lead(raw_lead)
print(f"\n⏳ Processing lead: {lead['name']}...")
report = agent.run(lead)
print_report(report)
if __name__ == "__main__":
main()Run the full script with python lead_scorer.py. Here's what the actual output looks like:
⏳ Processing lead: Maria Gonzalez... ======================================================= LEAD REPORT: Maria Gonzalez ======================================================= Qualified: ✅ Yes Score: 91 / 100 Queue: HOT Follow-up: 1 days Action: Call within 24 hours. Pre-approved buyer with strong budget and tight timeline. Send Naples listings immediately. Rationale: High budget ($850K), clear intent to purchase, and 60-day move-in window signals a serious, motivated buyer. ======================================================= ⏳ Processing lead: Tom Ricci... ======================================================= LEAD REPORT: Tom Ricci ======================================================= Qualified: ❌ No DQ Reason: No clear timeline, low budget relative to market, and vague intent signal low purchase readiness. Score: 18 / 100 Queue: NURTURE Follow-up: 30 days Action: Add to email drip campaign. Send market reports and educational content. Revisit in 30 days. Rationale: No phone number, unclear intent, no property type specified, and indefinite timeline result in low score. ======================================================= ⏳ Processing lead: Sandra Kim... ======================================================= LEAD REPORT: Sandra Kim ======================================================= Qualified: ✅ Yes Score: 87 / 100 Queue: HOT Follow-up: 1 days Action: Call today. Relocation buyer on tight 45-day timeline with $600K budget. Send Bonita Springs condo inventory. Rationale: Relocation urgency, specific property type, defined budget, and referral source all indicate high conversion potential. =======================================================
How It Works
Here's the plain-English version of what's happening under the hood. When you call agent.run(lead), it builds a conversation with Claude that includes your tool definitions. Claude reads the lead data and decides, on its own, to call qualify_lead first — because the system prompt told it to work in that order.
Your Python code receives that tool call, runs the matching handler function, and sends the result back to Claude as a new message. Claude sees the result and decides to call score_lead next, then route_lead. The loop keeps cycling until stop_reason hits end_turn, which means Claude is finished calling tools.
Nothing magical is happening — it's just a while loop that keeps the conversation going until the model is done. The tool outputs get logged in tool_results_log and assembled into a final report. You can replace the report logic with a database insert, a CRM API call, or a Slack notification and it'll work exactly the same way.
Common Errors and Fixes
Error 1: "messages: roles must alternate between user and assistant"
anthropic.BadRequestError: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"messages: roles must alternate between user and assistant"}}This happens when you send tool results without first adding Claude's response (which contains the tool_use block) to your message history. Fix it by appending the assistant's response to messages before you append the tool results — exactly as shown in the agent loop above.
Error 2: "Could not resolve authentication" or 401 Unauthorized
anthropic.AuthenticationError: 401 {"type":"error","error":{"type":"authentication_error",
"message":"invalid x-api-key"}}Your API key isn't being picked up. Run echo $ANTHROPIC_API_KEY in your terminal to confirm it's exported. If it's blank, run export ANTHROPIC_API_KEY=sk-ant-yourkey and try again. Never set it inside the Python file itself.