What You'll Build
You're going to build a multi-agent lead scoring system in Python that automatically qualifies inbound leads using two specialized Claude agents — one that validates the data and one that scores the lead against your ideal customer profile. The whole thing runs in under 200 lines of code and integrates cleanly with a CRM and email workflow via tool definitions. By the end, you'll have a working agentic pipeline that hands leads off between agents, catches bad data early, and outputs a structured JSON score you can actually use.
The complete working code is built piece by piece in the steps below. Each snippet is standalone and correct — copy them in order and you'll have a fully functional multi-agent lead scoring system by the end of this tutorial. No pseudocode, no hand-waving.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and dictionaries
anthropicPython SDK installed (pip install anthropic)- A terminal and a text editor — that's genuinely all you need
Step 1: Set Up Claude API and Install Dependencies
Start by installing the Anthropic SDK and setting your API key. I keep the key in an environment variable so it never ends up hardcoded in the project files.
terminalpip install anthropic python-dotenv
Create a .env file in your project root with your key:
ANTHROPIC_API_KEY=sk-ant-your-key-here
Now create the main project file. This first block sets up your imports and loads the environment:
lead_scoring.pyimport os import json from anthropic import Anthropic from dotenv import load_dotenv load_dotenv() client = Anthropic() MODEL = "claude-sonnet-4-6"
That's the entire setup. The SDK automatically reads ANTHROPIC_API_KEY from your environment, so no extra configuration is needed.
Step 2: Define the Lead Scoring Agent and Data Validation Agent
Here's where it gets interesting. Most people build one fat prompt that tries to do everything at once — validate the data, score the lead, and format the output. That approach falls apart fast when inputs get messy or edge cases pile up. Instead, we're splitting the work across two agents with distinct system prompts and clear responsibilities.
The Validation Agent checks that incoming lead data is complete and properly formatted before scoring even starts. The Scoring Agent then receives the clean data and evaluates the lead against your ideal customer profile. This separation means errors get caught early and the scoring logic stays clean.
lead_scoring.py (continued)VALIDATION_AGENT_PROMPT = """You are a data validation agent for a CRM lead pipeline. Your job is to inspect incoming lead data and verify it is complete and usable. Check the following fields: - name: must be a non-empty string - email: must contain @ and a valid domain - company: must be a non-empty string - industry: must be one of: real_estate, healthcare, restaurant, automotive, manufacturing, other - annual_revenue: must be a positive number (can be null if unknown) - employees: must be a positive integer (can be null if unknown) - message: must be a non-empty string If validation passes, call the validate_lead tool with status="valid" and an empty errors list. If validation fails, call the validate_lead tool with status="invalid" and a list of specific error messages. Never guess or fill in missing required fields — flag them as errors instead.""" SCORING_AGENT_PROMPT = """You are a lead scoring agent for a B2B AI solutions agency. You receive validated lead data and score the lead from 0 to 100 based on fit. Scoring criteria: - Industry fit (real_estate, healthcare, automotive = high value): up to 30 points - Company size (employees 10-500 = ideal range): up to 25 points - Revenue signal (annual_revenue > 500000 = strong signal): up to 25 points - Message intent (mentions specific pain points, automation, AI = strong): up to 20 points After scoring, call the score_lead tool with: - score: integer 0-100 - tier: "hot" (80-100), "warm" (50-79), "cold" (0-49) - reasoning: a 2-3 sentence plain-English explanation of the score - recommended_action: one of "immediate_outreach", "nurture_sequence", "disqualify" Be honest about low scores. A score of 20 is useful information, not a failure."""
Giving each agent one job makes the system easier to debug and extend. When a lead gets a bad score, you know exactly which agent made which decision. It also lets you swap out the scoring criteria without touching validation logic — and vice versa.
Step 3: Create Tool Definitions for CRM Integration and Email
Claude agents take action through tools. We're defining four tools here: one for validation output, one for scoring output, one to simulate writing to a CRM, and one to trigger an email sequence. In a real deployment, the CRM and email tools would call your actual APIs — HubSpot, Salesforce, ActiveCampaign, whatever you're using.
lead_scoring.py (continued)VALIDATION_TOOLS = [
{
"name": "validate_lead",
"description": "Record the validation result for an incoming lead.",
"input_schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["valid", "invalid"],
"description": "Whether the lead data passed validation."
},
"errors": {
"type": "array",
"items": {"type": "string"},
"description": "List of validation error messages. Empty if valid."
}
},
"required": ["status", "errors"]
}
}
]
SCORING_TOOLS = [
{
"name": "score_lead",
"description": "Record the lead score and recommended action.",
"input_schema": {
"type": "object",
"properties": {
"score": {
"type": "integer",
"description": "Lead score from 0 to 100."
},
"tier": {
"type": "string",
"enum": ["hot", "warm", "cold"],
"description": "Lead tier based on score."
},
"reasoning": {
"type": "string",
"description": "Plain-English explanation of the score."
},
"recommended_action": {
"type": "string",
"enum": ["immediate_outreach", "nurture_sequence", "disqualify"],
"description": "Recommended next action for this lead."
}
},
"required": ["score", "tier", "reasoning", "recommended_action"]
}
},
{
"name": "update_crm",
"description": "Write the lead score and tier to the CRM system.",
"input_schema": {
"type": "object",
"properties": {
"lead_email": {
"type": "string",
"description": "Lead email address used as the CRM record key."
},
"score": {"type": "integer"},
"tier": {"type": "string"},
"recommended_action": {"type": "string"}
},
"required": ["lead_email", "score", "tier", "recommended_action"]
}
},
{
"name": "trigger_email_sequence",
"description": "Enroll the lead in the appropriate email nurture sequence.",
"input_schema": {
"type": "object",
"properties": {
"lead_email": {"type": "string"},
"sequence_name": {
"type": "string",
"enum": ["hot_lead_immediate", "warm_nurture_30day", "cold_archive"],
"description": "The email sequence to enroll the lead in."
}
},
"required": ["lead_email", "sequence_name"]
}
}
]
def handle_crm_update(inputs: dict) -> dict:
"""Simulate CRM write. Replace with real API call in production."""
print(f" [CRM] Updated record for {inputs['lead_email']}: "
f"score={inputs['score']}, tier={inputs['tier']}")
return {"success": True, "crm_record_id": f"CRM-{inputs['lead_email'][:8].upper()}"}
def handle_email_trigger(inputs: dict) -> dict:
"""Simulate email enrollment. Replace with ActiveCampaign/HubSpot call."""
print(f" [EMAIL] Enrolled {inputs['lead_email']} in '{inputs['sequence_name']}'")
return {"success": True, "enrolled": True}
Step 4: Build the Orchestration Loop with Agent Handoffs
This is the core of a multi-agent system — the orchestrator. It holds a tool registry, knows which agent to call at each stage, and handles the handoff from validation to scoring. The loop keeps running until the agent stops calling tools, which is Claude's way of saying "I'm done."
lead_scoring.py (continued)class LeadScoringOrchestrator:
def __init__(self):
# Tool registry maps tool names to their handler functions
self.tool_registry = {
"update_crm": handle_crm_update,
"trigger_email_sequence": handle_email_trigger
}
self.validation_result = None
self.scoring_result = None
def run_agent(
self,
system_prompt: str,
tools: list,
initial_message: str
) -> dict | None:
"""
Run a single agent in a loop until it completes its tool call.
Returns the tool input dict from the agent's final tool call.
"""
messages = [{"role": "user", "content": initial_message}]
tool_result = None
while True:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system=system_prompt,
tools=tools,
messages=messages
)
# Append assistant response to conversation history
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
# Agent finished without calling a tool — shouldn't happen in this design
break
if response.stop_reason == "tool_use":
tool_results_content = []
for block in response.content:
if block.type != "tool_use":
continue
tool_name = block.name
tool_input = block.input
# If it's a side-effect tool (CRM, email), execute it
if tool_name in self.tool_registry:
result = self.tool_registry[tool_name](tool_input)
tool_results_content.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
else:
# It's a structured output tool (validate_lead, score_lead)
tool_result = tool_input
tool_results_content.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps({"received": True})
})
# Feed tool results back to continue the conversation
messages.append({"role": "user", "content": tool_results_content})
# If we got a structured output result, we're done with this agent
if tool_result is not None:
break
return tool_result
def process_lead(self, lead_data: dict) -> dict:
"""
Full pipeline: validate -> score -> CRM update -> email enrollment.
Returns the complete scored lead result.
"""
lead_json = json.dumps(lead_data, indent=2)
print(f"\n{'='*50}")
print(f"Processing lead: {lead_data.get('name', 'Unknown')} "
f"<{lead_data.get('email', 'no-email')}>")
print(f"{'='*50}")
# Stage 1: Validation Agent
print("\n[Stage 1] Running Validation Agent...")
validation_message = f"Please validate this incoming lead data:\n\n{lead_json}"
self.validation_result = self.run_agent(
VALIDATION_AGENT_PROMPT,
VALIDATION_TOOLS,
validation_message
)
if not self.validation_result:
return {"error": "Validation agent failed to return a result."}
print(f" Validation status: {self.validation_result['status']}")
if self.validation_result["status"] == "invalid":
print(f" Errors: {self.validation_result['errors']}")
return {
"lead": lead_data,
"validation": self.validation_result,
"scoring": None,
"final_status": "rejected_invalid_data"
}
# Stage 2: Scoring Agent (only runs on valid leads)
print("\n[Stage 2] Running Scoring Agent...")
scoring_message = (
f"Please score this validated lead and update the CRM and email system:\n\n"
f"{lead_json}\n\n"
f"After scoring, also call update_crm and trigger_email_sequence "
f"using the lead's email and your scoring results."
)
self.scoring_result = self.run_agent(
SCORING_AGENT_PROMPT,
SCORING_TOOLS,
scoring_message
)
if not self.scoring_result:
return {"error": "Scoring agent failed to return a result."}
print(f"\n Score: {self.scoring_result['score']}/100 "
f"({self.scoring_result['tier'].upper()})")
print(f" Action: {self.scoring_result['recommended_action']}")
print(f" Reasoning: {self.scoring_result['reasoning']}")
return {
"lead": lead_data,
"validation": self.validation_result,
"scoring": self.scoring_result,
"final_status": "scored"
}
The orchestrator class holds state across both agent runs and keeps the tool registry in one place. Adding a new tool later just means adding a function and registering it in __init__ — the loop handles everything else automatically.
Example Lead Input and Running the System
Here's how you wire it all up and run it with a real sample lead. I'm including both a high-quality lead and a bad one so you can see the validation path in action.
lead_scoring.py (continued)if __name__ == "__main__":
orchestrator = LeadScoringOrchestrator()
# Example 1: A strong lead — real estate company, clear intent
hot_lead = {
"name": "Maria Gonzalez",
"email": "[email protected]",
"company": "Suncoast Realty Group",
"industry": "real_estate",
"annual_revenue": 2400000,
"employees": 45,
"message": (
"We manually process about 300 listing inquiries a month. "
"Our team spends 2-3 hours a day just on data entry and follow-ups. "
"We heard AI can automate this — we want to talk to someone this week."
)
}
# Example 2: An incomplete lead — will fail validation
bad_lead = {
"name": "",
"email": "notavalidemail",
"company": "Unknown LLC",
"industry": "fintech", # not in allowed list
"annual_revenue": -500,
"employees": 12,
"message": "Interested in your services."
}
result_1 = orchestrator.process_lead(hot_lead)
print("\n--- FINAL OUTPUT (Lead 1) ---")
print(json.dumps(result_1, indent=2))
print("\n\n")
orchestrator2 = LeadScoringOrchestrator()
result_2 = orchestrator2.process_lead(bad_lead)
print("\n--- FINAL OUTPUT (Lead 2) ---")
print(json.dumps(result_2, indent=2))
Here's what the output looks like when you run this against the strong lead:
sample output================================================== Processing lead: Maria Gonzalez================================================== [Stage 1] Running Validation Agent... Validation status: valid [Stage 2] Running Scoring Agent... [CRM] Updated record for maria@su: score=91, tier=hot [EMAIL] Enrolled [email protected] in 'hot_lead_immediate' Score: 91/100 (HOT) Action: immediate_outreach Reasoning: This lead scores exceptionally well across all criteria. Real estate is a high-value industry for AI automation, the company size of 45 employees is in the ideal range, and the $2.4M revenue signals a serious business. The message demonstrates a specific, quantified pain point with clear buying intent and urgency. --- FINAL OUTPUT (Lead 1) --- { "lead": { "name": "Maria Gonzalez", "email": "[email protected]", "company": "Suncoast Realty Group", "industry": "real_estate", "annual_revenue": 2400000, "employees": 45, "message": "We manually process about 300 listing inquiries a month..." }, "validation": { "status": "valid", "errors": [] }, "scoring": { "score": 91, "tier": "hot", "reasoning": "This lead scores exceptionally well across all criteria...", "recommended_action": "immediate_outreach" }, "final_status": "scored" }
How It Works: Agent Collaboration Flow
Here's the plain-English version of what's happening when you call process_lead(). The orchestrator takes your raw lead dictionary and passes it to the Validation Agent first. That agent reads the system prompt, checks every field, and calls the validate_lead tool with a pass/fail result.
If validation fails, the orchestrator stops right there and returns a rejection result with specific error messages — no API call wasted on scoring a garbage lead. If it passes, the orchestrator hands the clean data to the Scoring Agent along with permission to also fire the CRM and email tools.
The Scoring Agent evaluates the lead, calls score_lead to return its structured assessment, then calls update_crm and trigger_email_sequence as side effects. The run_agent loop handles all of this automatically — it keeps feeding tool results back to Claude until the agent stops calling tools. The whole pipeline takes 3-6 seconds on average.
The key design decision here is that the orchestrator owns the handoff logic, not the agents. The agents don't know about each other — they just receive a prompt and call their tools. This makes it trivial to add a third agent (like an enrichment agent that pulls LinkedIn data) without touching any existing code.
Common Errors and Fixes
Error 1: AuthenticationError on first run
Exact error: anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}
This almost always means the environment variable isn't loaded. Check that your .env file is in the same directory you're running the script from, and that you have load_dotenv() called before the Anthropic() client is instantiated. You can also test it directly: print(os.environ.get("ANTHROPIC_API_KEY")) — if it prints None, the file isn't loading.
Error 2: Agent loops indefinitely without returning
Symptom: The script hangs, or you see repeated API calls with no output. This happens when the tool result you feed back to Claude doesn't match the expected format. Make sure every tool result message uses the exact structure: {"type": "tool_result", "tool_use_id": block.id,