What You'll Build
By the end of this tutorial, you'll have a working AI lead scorer agent in Python that takes raw lead data, reasons through it using Claude's tool use API, and returns a numeric score plus a plain-English recommendation. The agent handles multi-turn context, calls custom evaluation tools, and runs an autonomous agentic loop without you touching it in between. Under 200 lines of clean Python, production-ready from the start.
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:pip install anthropic- A
.envfile or exported environment variable forANTHROPIC_API_KEY
Step 1: Set Up Your Claude API Client and Environment
Start by creating a new file called lead_scorer.py. The first thing you need is the client initialization and a clean class structure to hold everything together.
I like wrapping agents in a class from the beginning. It makes it much easier to add state, logging, and configuration later without refactoring everything.
lead_scorer.py
import os
import json
from anthropic import Anthropic
class LeadScorerAgent:
"""
An autonomous agent that scores and qualifies sales leads
using Claude's tool use API.
"""
def __init__(self):
# Initialize the Anthropic client using ANTHROPIC_API_KEY from environment
self.client = Anthropic()
self.model = "claude-sonnet-4-6"
self.conversation_history = []
self.system_prompt = """
You are an expert B2B sales analyst specializing in lead qualification.
Your job is to evaluate incoming leads using the tools available to you.
Always call the appropriate tools to gather a complete picture before
assigning a final score. Be objective, data-driven, and concise in your
reasoning. Return scores as integers between 0 and 100.
"""
def reset_conversation(self):
"""Clear conversation history between unrelated leads."""
self.conversation_history = []
The conversation_history list is what enables multi-turn context later in Step 5. Resetting it between unrelated leads keeps the agent focused and avoids score contamination between separate prospects.
os.environ.get("ANTHROPIC_API_KEY") or a .env file loaded with python-dotenv. The Anthropic SDK reads the key automatically from the environment if it's set.
Step 2: Define Lead Scoring Tools and JSON Schema
Tools are how you give Claude the ability to take real actions, not just talk about them. Each tool definition tells Claude what the tool does, what inputs it expects, and which fields are required.
For lead scoring, I'm defining three tools: one to evaluate budget fit, one to assess company size and role authority, and one to assign the final composite score. This separation keeps reasoning modular and easy to debug.
lead_scorer.py (continued)
def get_tool_definitions(self):
"""
Returns the list of tools Claude can call during lead evaluation.
Each tool maps to a local method on this class.
"""
return [
{
"name": "evaluate_budget_fit",
"description": (
"Evaluates whether the lead's stated or implied budget "
"aligns with our service pricing. Returns a budget fit "
"score from 0 to 100 and a short explanation."
),
"input_schema": {
"type": "object",
"properties": {
"annual_revenue": {
"type": "number",
"description": "Lead's estimated or stated annual revenue in USD"
},
"stated_budget": {
"type": "number",
"description": "Lead's stated project budget in USD, if provided"
},
"industry": {
"type": "string",
"description": "Lead's industry vertical"
}
},
"required": ["annual_revenue", "industry"]
}
},
{
"name": "evaluate_authority_and_fit",
"description": (
"Evaluates the lead's decision-making authority and "
"whether their company size and role suggest they can "
"approve a purchase. Returns an authority score 0-100."
),
"input_schema": {
"type": "object",
"properties": {
"job_title": {
"type": "string",
"description": "Lead's job title or role"
},
"company_size": {
"type": "string",
"description": "Number of employees or company size tier"
},
"is_decision_maker": {
"type": "boolean",
"description": "Whether the lead self-identified as a decision maker"
}
},
"required": ["job_title", "company_size"]
}
},
{
"name": "assign_final_score",
"description": (
"Combines all evaluation results into a final lead score "
"and qualification tier. Call this last, after running "
"the other evaluation tools."
),
"input_schema": {
"type": "object",
"properties": {
"budget_score": {
"type": "number",
"description": "Score from evaluate_budget_fit"
},
"authority_score": {
"type": "number",
"description": "Score from evaluate_authority_and_fit"
},
"urgency_notes": {
"type": "string",
"description": "Any urgency signals from the lead's message"
}
},
"required": ["budget_score", "authority_score"]
}
}
]
Notice that each tool has a clear description written for Claude to read, not for you. Claude decides which tool to call based on that natural-language description, so make it specific about when and why the tool should be used.
Step 3: Create the Core Agent Loop with Tool Use
This is the heart of the agent. The agentic loop runs until Claude either finishes its reasoning or hits a maximum iteration limit you set. Without a limit, a misbehaving agent can burn API credits in a runaway loop.
Here's how it works: you send a message, Claude responds with either a final answer or a tool call request, you execute the tool and send the result back, and you repeat until Claude is done.
lead_scorer.py (continued)
def execute_tool(self, tool_name: str, tool_input: dict) -> str:
"""
Routes tool calls to the appropriate local method and
returns results as a JSON string for Claude to read.
"""
if tool_name == "evaluate_budget_fit":
return json.dumps(self._evaluate_budget_fit(**tool_input))
elif tool_name == "evaluate_authority_and_fit":
return json.dumps(self._evaluate_authority_and_fit(**tool_input))
elif tool_name == "assign_final_score":
return json.dumps(self._assign_final_score(**tool_input))
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def run_agent_loop(self, user_message: str, max_iterations: int = 10) -> str:
"""
Runs the agentic loop. Sends the user message, handles tool calls,
and returns Claude's final text response.
"""
# Append the new user message to conversation history
self.conversation_history.append({
"role": "user",
"content": user_message
})
iteration = 0
while iteration < max_iterations:
iteration += 1
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=self.system_prompt,
tools=self.get_tool_definitions(),
messages=self.conversation_history
)
# If Claude is done reasoning, return the final text
if response.stop_reason == "end_turn":
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
# Store assistant response in history for multi-turn support
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
return final_text
# If Claude wants to use tools, process each tool call
if response.stop_reason == "tool_use":
# Add Claude's response (including tool use blocks) to history
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
# Build the tool results to send back
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_output = self.execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": tool_output
})
# Send tool results back as a user message
self.conversation_history.append({
"role": "user",
"content": tool_results
})
return "Agent reached maximum iterations without completing."
The key pattern here is that tool results go back in as a user role message with type tool_result. This is the Anthropic SDK's required format. Get this wrong and you'll hit a validation error immediately.
Step 4: Process and Score Sample Leads
Now you need the actual tool logic and a way to run the agent against real lead data. These tool implementations are intentionally simple scoring functions. In a real deployment, they'd hit your CRM or pull from a database.
lead_scorer.py (continued)
def _evaluate_budget_fit(
self,
annual_revenue: float,
industry: str,
stated_budget: float = None
) -> dict:
"""Scores budget alignment against typical service pricing."""
score = 0
explanation = ""
# Use stated budget if available, otherwise estimate from revenue
budget_signal = stated_budget if stated_budget else annual_revenue * 0.02
if budget_signal >= 50000:
score = 90
explanation = "High budget signal, strong fit for enterprise packages."
elif budget_signal >= 15000:
score = 70
explanation = "Mid-range budget, fits standard service tiers well."
elif budget_signal >= 5000:
score = 45
explanation = "Lower budget range, may fit starter packages."
else:
score = 15
explanation = "Budget signal too low for current service minimums."
# Bonus for high-value industries we specialize in
high_value_industries = ["real estate", "healthcare", "manufacturing", "automotive"]
if any(ind in industry.lower() for ind in high_value_industries):
score = min(score + 10, 100)
explanation += f" Industry ({industry}) is a priority vertical."
return {"budget_score": score, "explanation": explanation}
def _evaluate_authority_and_fit(
self,
job_title: str,
company_size: str,
is_decision_maker: bool = False
) -> dict:
"""Scores decision-making authority based on title and company size."""
score = 50 # baseline
title_lower = job_title.lower()
# C-suite and VP roles score highest
if any(t in title_lower for t in ["ceo", "coo", "cto", "president", "owner", "founder"]):
score = 95
elif any(t in title_lower for t in ["vp", "vice president", "director", "head of"]):
score = 80
elif any(t in title_lower for t in ["manager", "lead", "supervisor"]):
score = 60
else:
score = 35
# Self-identified decision makers get a small boost
if is_decision_maker:
score = min(score + 10, 100)
return {
"authority_score": score,
"job_title_assessed": job_title,
"company_size": company_size
}
def _assign_final_score(
self,
budget_score: float,
authority_score: float,
urgency_notes: str = ""
) -> dict:
"""Combines sub-scores into a final lead score and tier label."""
# Weighted average: budget 50%, authority 50%
composite = (budget_score * 0.5) + (authority_score * 0.5)
# Urgency signals bump the score slightly
if urgency_notes and any(
word in urgency_notes.lower()
for word in ["asap", "urgent", "immediately", "this quarter", "this month"]
):
composite = min(composite + 8, 100)
composite = round(composite)
if composite >= 75:
tier = "Hot"
action = "Assign to senior sales rep. Follow up within 24 hours."
elif composite >= 50:
tier = "Warm"
action = "Add to nurture sequence. Follow up within 3 business days."
elif composite >= 30:
tier = "Cool"
action = "Add to long-term email list. Re-evaluate in 90 days."
else:
tier = "Cold"
action = "Do not pursue at this time. Archive lead."
return {
"final_score": composite,
"tier": tier,
"recommended_action": action
}
def score_lead(lead_data: dict) -> str:
"""
Convenience function to instantiate the agent and score a single lead.
"""
agent = LeadScorerAgent()
prompt = f"""
Please evaluate this incoming sales lead and provide a final score with your recommendation.
Lead Information:
- Name: {lead_data.get('name', 'Unknown')}
- Job Title: {lead_data.get('job_title', 'Unknown')}
- Company: {lead_data.get('company', 'Unknown')}
- Industry: {lead_data.get('industry', 'Unknown')}
- Company Size: {lead_data.get('company_size', 'Unknown')}
- Annual Revenue (estimated): ${lead_data.get('annual_revenue', 0):,}
- Stated Budget: ${lead_data.get('stated_budget', 0):,}
- Is Decision Maker: {lead_data.get('is_decision_maker', False)}
- Lead Message: {lead_data.get('message', 'No message provided')}
Use your evaluation tools to assess this lead fully before giving a final recommendation.
"""
return agent.run_agent_loop(prompt)
if __name__ == "__main__":
# Sample leads to test the agent
sample_leads = [
{
"name": "Maria Gonzalez",
"job_title": "Owner",
"company": "Coastal Realty Group",
"industry": "Real Estate",
"company_size": "25 employees",
"annual_revenue": 4200000,
"stated_budget": 18000,
"is_decision_maker": True,
"message": "We need AI listing automation ASAP before the spring season hits."
},
{
"name": "Derek Holt",
"job_title": "Marketing Coordinator",
"company": "Gulf Coast Auto",
"industry": "Automotive",
"company_size": "12 employees",
"annual_revenue": 800000,
"stated_budget": 2500,
"is_decision_maker": False,
"message": "Curious about chatbot pricing, just gathering info right now."
}
]
for lead in sample_leads:
print(f"\n{'='*60}")
print(f"Scoring lead: {lead['name']} at {lead['company']}")
print("="*60)
result = score_lead(lead)
print(result)
Run this with python lead_scorer.py and you'll see Claude work through both leads, calling tools in sequence before landing on a final score. Here's what the output looks like:
============================================================ Scoring lead: Maria Gonzalez at Coastal Realty Group ============================================================ After evaluating Maria Gonzalez using our scoring tools, here is the full assessment: Budget Fit Score: 80/100 Maria's stated budget of $18,000 aligns well with our standard service tiers, and real estate is one of our priority verticals. Strong budget signal overall. Authority Score: 95/100 As the Owner of the company, Maria has full decision-making authority. No internal approval chain to navigate. Final Lead Score: 88/100 Tier: HOT Urgency detected: "ASAP" language indicates high purchase intent. Recommended Action: Assign to senior sales rep. Follow up within 24 hours. ============================================================ Scoring lead: Derek Holt at Gulf Coast Auto ============================================================ After evaluating Derek Holt using our scoring tools, here is the full assessment: Budget Fit Score: 35/100 The stated budget of $2,500 falls below our typical service minimums. Automotive is a priority vertical, which adds a small bonus, but budget remains the primary limiting factor. Authority Score: 35/100 Marketing Coordinator is not a decision-making role. This contact would need to escalate any purchase to a manager or owner. Final Lead Score: 35/100 Tier: COOL Recommended Action: Add to long-term email list. Re-evaluate in 90 days.
Step 5: Handle Multi-Turn Conversations for Lead Context
Sometimes you need to ask follow-up questions before scoring. The agent already stores conversation history, so adding follow-up context is straightforward. Here's how to run a multi-turn session with the same lead.
multi_turn_example.py
import os
from lead_scorer import LeadScorerAgent
def run_multi_turn_session():
"""
Demonstrates multi-turn lead qualification where additional context
is gathered before producing a final score.
"""
agent = LeadScorerAgent()
# First turn: provide initial lead info
first_message = """
I have a new lead to evaluate:
- Name: James Thornton
- Job Title: Operations Director
- Company: Suncoast Medical Group
- Industry: Healthcare
- Company Size: 200 employees
- Annual Revenue: $12,000,000
- Message: "We're exploring AI options for our patient intake process."
Can you start an initial evaluation?
"""
print("TURN 1:")
print("-" * 40)
response_1 = agent.run_agent_loop(first_message)
print(response_1)
# Second turn: provide additional context without resetting history
followup_message = """
I just got more information from the lead:
- They confirmed James is the final decision maker for technology purchases.
- Their stated budget is $45,000 for this project.
- They said they want to launch before Q1 next year, which is 8 weeks away.
Please update your evaluation and give a final score with this new context.
"""
print("\nTURN 2 (with updated context):")
print("-" * 40)
# No reset_conversation() call here — history carries forward
response_2 = agent.run_agent_loop(followup_message)
print(response_2)
if __name__ == "__main__":
run_multi_turn_session()
Because conversation_history persists between calls to run_agent_loop, Claude remembers everything from the first turn. You don't need to resend the original lead data in the follow-up message.
How It Works
Here's the plain-English version of what's happening under the hood. You send Claude a lead profile in natural language, and Claude reads your tool definitions to figure out which tools to call first. It calls them in a logical order, evaluating budget before assigning a final composite score.
Each tool call comes back to your Python code, which runs the actual scoring logic and returns a JSON result. Claude reads that result and decides whether it has enough information to finish, or whether to call another tool. This loop continues until Claude produces a final text response with stop_reason: "end_turn".
The conversation history list is what connects all the turns together. Every message, every tool call, every tool result gets appended in order. Claude sees the entire thread on each API call, which is how it maintains context across turns without you doing anything special.
Common Errors and Fixes
Naples AI