What You'll Build
By the end of this tutorial, you'll have a working multi-agent Python system that automatically qualifies, scores, and ranks incoming real estate leads using the Claude API. The system uses three coordinated agents — a qualifier, a scorer, and an orchestrator — each with a specific job, talking to each other through structured tool calls. This is the same pattern we use at Naples AI when building real lead automation pipelines for Southwest Florida real estate teams.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and functions
- pip installed for dependency management
- A terminal and a code editor (VS Code works great)
The complete, production-ready source code is built step by step in the sections below. Every snippet is copy-paste ready and syntactically correct. By the time you reach the end, you'll have all the pieces assembled into a working system you can run locally and extend for your own CRM.
Step 1: Set Up Claude API and Install Dependencies
Start by installing the Anthropic Python SDK. This is the only external dependency you need — the rest of the system uses Python's standard library.
terminalpip install anthropic
Next, set your API key as an environment variable. Never hard-code keys directly in your source files.
terminalexport ANTHROPIC_API_KEY="your-api-key-here"
Create a new project folder and the main file you'll work in throughout this tutorial.
terminalmkdir real-estate-lead-qualifier cd real-estate-lead-qualifier touch lead_qualifier.py
Here's the base setup at the top of your file, including all imports the full system will need:
lead_qualifier.pyimport os import json import anthropic from dataclasses import dataclass, field, asdict from typing import Optional # Initialize the Anthropic client using the ANTHROPIC_API_KEY env variable client = anthropic.Anthropic() MODEL = "claude-sonnet-4-5"
We're using
claude-sonnet-4-5 throughout this tutorial. This is a current production model available through the Anthropic API. Always check Anthropic's model docs for the latest available model names before deploying.
Step 2: Create the Lead Data Schema and Tool Definitions
Before building any agents, you need a clear data structure for what a lead looks like and what tools the qualifier agent can call. Think of the tools as the questions you'd ask a new lead on a intake form.
Here's the lead dataclass and the full tool definitions for the qualifier agent:
lead_qualifier.py@dataclass
class Lead:
name: str
email: str
phone: str
property_type: Optional[str] = None # e.g. "single-family", "condo", "commercial"
budget_min: Optional[float] = None
budget_max: Optional[float] = None
timeline_months: Optional[int] = None # How soon they want to buy/sell
location_preference: Optional[str] = None
is_preapproved: Optional[bool] = None
notes: Optional[str] = None
score: Optional[float] = None
tier: Optional[str] = None # "hot", "warm", "cold"
# Tool definitions the qualifier agent can call during a conversation
QUALIFIER_TOOLS = [
{
"name": "record_contact_info",
"description": "Records the lead's verified contact information including name, email, and phone number.",
"input_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Full name of the lead"
},
"email": {
"type": "string",
"description": "Email address of the lead"
},
"phone": {
"type": "string",
"description": "Phone number of the lead"
}
},
"required": ["name", "email", "phone"]
}
},
{
"name": "record_property_preferences",
"description": "Records the lead's property type interest, location preference, and timeline.",
"input_schema": {
"type": "object",
"properties": {
"property_type": {
"type": "string",
"description": "Type of property: single-family, condo, townhouse, commercial, land"
},
"location_preference": {
"type": "string",
"description": "Preferred neighborhood, city, or zip code"
},
"timeline_months": {
"type": "integer",
"description": "How many months until the lead wants to complete the transaction"
}
},
"required": ["property_type", "timeline_months"]
}
},
{
"name": "record_budget",
"description": "Records the lead's minimum and maximum budget range and pre-approval status.",
"input_schema": {
"type": "object",
"properties": {
"budget_min": {
"type": "number",
"description": "Minimum budget in USD"
},
"budget_max": {
"type": "number",
"description": "Maximum budget in USD"
},
"is_preapproved": {
"type": "boolean",
"description": "Whether the lead has mortgage pre-approval"
}
},
"required": ["budget_max", "is_preapproved"]
}
},
{
"name": "qualify_complete",
"description": "Marks qualification as complete once all key information has been gathered.",
"input_schema": {
"type": "object",
"properties": {
"notes": {
"type": "string",
"description": "Any additional notes or context about this lead"
}
},
"required": []
}
}
]The four tools map directly to the stages of a real qualification call: contact info, property preferences, budget, and wrap-up. Claude will decide which tool to call and in what order based on the conversation — you don't need to hard-wire a script.
Step 3: Build the Lead Qualifier Agent with Tool Use
The qualifier agent runs a conversation with a simulated lead intake form submission, extracts all the relevant fields, and populates the Lead object using tool calls. This is where Claude's tool use feature really shines — it reads unstructured text and maps it to structured data reliably.
lead_qualifier.pyclass LeadQualifierAgent:
def __init__(self, lead: Lead):
self.lead = lead
self.messages = []
self.qualification_complete = False
def process_tool_call(self, tool_name: str, tool_input: dict) -> str:
"""Routes tool calls to the appropriate lead update method."""
if tool_name == "record_contact_info":
self.lead.name = tool_input.get("name", self.lead.name)
self.lead.email = tool_input.get("email", self.lead.email)
self.lead.phone = tool_input.get("phone", self.lead.phone)
return json.dumps({"status": "success", "recorded": "contact_info"})
elif tool_name == "record_property_preferences":
self.lead.property_type = tool_input.get("property_type")
self.lead.location_preference = tool_input.get("location_preference")
self.lead.timeline_months = tool_input.get("timeline_months")
return json.dumps({"status": "success", "recorded": "property_preferences"})
elif tool_name == "record_budget":
self.lead.budget_min = tool_input.get("budget_min")
self.lead.budget_max = tool_input.get("budget_max")
self.lead.is_preapproved = tool_input.get("is_preapproved")
return json.dumps({"status": "success", "recorded": "budget"})
elif tool_name == "qualify_complete":
self.lead.notes = tool_input.get("notes", "")
self.qualification_complete = True
return json.dumps({"status": "success", "recorded": "qualification_complete"})
return json.dumps({"status": "error", "message": f"Unknown tool: {tool_name}"})
def qualify(self, raw_lead_text: str) -> Lead:
"""Runs the qualification loop until all fields are extracted."""
system_prompt = """You are a real estate lead qualification assistant.
Your job is to extract structured information from lead submissions and record it
using the available tools. Always call record_contact_info first, then
record_property_preferences, then record_budget.
Call qualify_complete when you have gathered all available information.
If a field is not mentioned, skip it rather than guessing."""
# Seed the conversation with the raw lead text
self.messages = [
{"role": "user", "content": f"Please qualify this lead: {raw_lead_text}"}
]
# Agentic loop — keeps running until Claude stops using tools
while True:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system=system_prompt,
tools=QUALIFIER_TOOLS,
messages=self.messages
)
# Add Claude's response to the conversation history
self.messages.append({"role": "assistant", "content": response.content})
# If Claude is done with tool calls, break the loop
if response.stop_reason == "end_turn":
break
# Process any tool calls Claude made in this turn
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = self.process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Feed tool results back so Claude can continue
self.messages.append({"role": "user", "content": tool_results})
# Exit if qualification was marked complete
if self.qualification_complete:
break
return self.leadStep 4: Implement the Lead Scorer Agent for Ranking
Once a lead is qualified and the data is structured, the scorer agent evaluates it and produces a numeric score from 0 to 100 plus a tier label. The scoring rubric is baked into the system prompt, so you can tune it without touching the code.
lead_qualifier.pyclass LeadScorerAgent:
def __init__(self):
self.system_prompt = """You are a real estate lead scoring expert.
Score leads from 0-100 based on these weighted criteria:
- Budget size (25 points): $0-300k=5pts, $300k-600k=15pts, $600k-1M=20pts, $1M+=25pts
- Timeline urgency (25 points): 12+ months=5pts, 6-12 months=10pts, 3-6 months=18pts, <3 months=25pts
- Pre-approval status (25 points): pre-approved=25pts, not pre-approved=0pts
- Budget range completeness (15 points): both min and max provided=15pts, max only=8pts
- Location specificity (10 points): specific neighborhood=10pts, city only=5pts, none=0pts
Respond ONLY with a valid JSON object in this exact format:
{
"score": ,
"tier": "",
"reasoning": "",
"recommended_action": ""
}
Tier thresholds: hot=75-100, warm=40-74, cold=0-39"""
def score(self, lead: Lead) -> Lead:
"""Sends lead data to Claude for scoring and parses the response."""
lead_data = json.dumps(asdict(lead), indent=2)
response = client.messages.create(
model=MODEL,
max_tokens=512,
system=self.system_prompt,
messages=[
{
"role": "user",
"content": f"Score this real estate lead:\n\n{lead_data}"
}
]
)
raw_text = response.content[0].text.strip()
# Strip markdown code fences if Claude wraps the JSON
if raw_text.startswith("```"):
raw_text = raw_text.split("```")[1]
if raw_text.startswith("json"):
raw_text = raw_text[4:]
result = json.loads(raw_text)
lead.score = result["score"]
lead.tier = result["tier"]
# Append scoring reasoning to notes
lead.notes = (lead.notes or "") + f" | Score reasoning: {result['reasoning']} | Action: {result['recommended_action']}"
return lead Step 5: Create the Orchestrator Agent to Coordinate Workflows
The orchestrator is the brain of the system. It accepts raw lead submissions, routes them through the qualifier and scorer agents, and returns a final ranked list. It also handles the main event loop that lets you process multiple leads in a single run.
lead_qualifier.pyclass LeadOrchestratorAgent:
def __init__(self):
self.qualifier_agent_class = LeadQualifierAgent
self.scorer_agent = LeadScorerAgent()
self.processed_leads: list[Lead] = []
def process_lead(self, raw_lead_text: str, contact_info: dict) -> Lead:
"""Runs a single lead through the full qualification and scoring pipeline."""
# Create a base Lead object with whatever contact info we already have
lead = Lead(
name=contact_info.get("name", "Unknown"),
email=contact_info.get("email", ""),
phone=contact_info.get("phone", "")
)
print(f"\n[Orchestrator] Qualifying lead: {lead.name}")
# Step 1: Qualify — extract structured data from raw text
qualifier = self.qualifier_agent_class(lead)
lead = qualifier.qualify(raw_lead_text)
print(f"[Orchestrator] Qualification complete for {lead.name}")
print(f" Property: {lead.property_type} | Budget: ${lead.budget_min}-${lead.budget_max} | Timeline: {lead.timeline_months}mo")
# Step 2: Score — evaluate lead quality and assign tier
lead = self.scorer_agent.score(lead)
print(f"[Orchestrator] Scored {lead.name}: {lead.score}/100 ({lead.tier.upper()})")
self.processed_leads.append(lead)
return lead
def get_ranked_leads(self) -> list[Lead]:
"""Returns all processed leads sorted by score descending."""
return sorted(self.processed_leads, key=lambda l: l.score or 0, reverse=True)
def print_lead_report(self):
"""Prints a formatted summary report of all ranked leads."""
ranked = self.get_ranked_leads()
print("\n" + "=" * 60)
print("LEAD QUALIFICATION REPORT")
print("=" * 60)
for i, lead in enumerate(ranked, 1):
tier_emoji = {"hot": "🔥", "warm": "🌤️", "cold": "❄️"}.get(lead.tier, "")
print(f"\n#{i} {tier_emoji} {lead.name} — Score: {lead.score}/100 ({lead.tier.upper()})")
print(f" Contact: {lead.email} | {lead.phone}")
print(f" Property: {lead.property_type} in {lead.location_preference or 'N/A'}")
print(f" Budget: ${lead.budget_min:,.0f} – ${lead.budget_max:,.0f}" if lead.budget_max else f" Budget: Not specified")
print(f" Pre-approved: {'Yes' if lead.is_preapproved else 'No'} | Timeline: {lead.timeline_months} months")
print(f" Notes: {lead.notes}")
print("\n" + "=" * 60)
# ─── Main Event Loop ──────────────────────────────────────────────────────────
def main():
"""Main event loop — processes a batch of incoming leads end to end."""
orchestrator = LeadOrchestratorAgent()
# Simulated raw lead submissions (these would come from your CRM or web form)
lead_submissions = [
{
"contact": {"name": "Sarah Mitchell", "email": "[email protected]", "phone": "239-555-0142"},
"raw_text": """Hi, I'm Sarah Mitchell. I'm looking to buy a single-family home in Naples,
specifically in the Pelican Bay area. My budget is between $850,000 and $1.2 million.
I already have mortgage pre-approval from First National Bank.
I want to close within the next 60 days — we're relocating from Chicago for work."""
},
{
"contact": {"name": "Marcus Delgado", "email": "[email protected]", "phone": "239-555-0287"},
"raw_text": """Marcus here. I'm interested in a condo somewhere in Bonita Springs or Estero.
Budget is probably around $400,000 max. I haven't talked to a lender yet.
No real rush — maybe looking at buying in the next year or so."""
},
{
"contact": {"name": "Jennifer Park", "email": "[email protected]", "phone": "239-555-0391"},
"raw_text": """Jennifer Park. Looking at commercial property or land in Collier County
for a retail development. Budget range is $2 million to $5 million.
We have financing lined up. Timeline is aggressive — we need to be under contract
within 90 days or we lose our construction window for the season."""
}
]
# Process each lead through the full pipeline
for submission in lead_submissions:
orchestrator.process_lead(
raw_lead_text=submission["raw_text"],
contact_info=submission["contact"]
)
# Print the final ranked report
orchestrator.print_lead_report()
if __name__ == "__main__":
main()How It Works
The three agents each have one responsibility, and they hand off to each other through the orchestrator. Here's the plain-English version of what happens when you run the script.
Step 1 — Raw text in: The orchestrator receives a block of unstructured text (a form submission, an email, a phone transcript) and creates a base Lead object with whatever contact info is already known.
Step 2 — Qualifier runs: The LeadQualifierAgent starts a multi-turn conversation with Claude. Claude reads the raw text, then systematically calls tools to extract property type, budget, and timeline — in that order. Each tool call updates the Lead object in real time. The agentic loop keeps running until Claude calls qualify_complete or stops on its own.
Step 3 — Scorer evaluates: Once the Lead is fully structured, the LeadScorerAgent sends it to Claude with a detailed scoring rubric. Claude returns a JSON object with a numeric score, a tier label (hot/warm/cold), reasoning, and a recommended action. No tool use here — just structured JSON output, which is simpler and faster for pure evaluation tasks.
Step 4 — Orchestrator ranks: After all leads are processed, get_ranked_leads() sorts them by score and the report prints them highest to lowest. In production, this is where you'd write to your CRM instead of printing to the terminal.
Here's what the terminal output looks like when you run the script:
sample output[Orchestrator] Qualifying lead: Sarah Mitchell
[Orchestrator] Qualification complete for Sarah Mitchell
Property: single-family | Budget: $850000-$1200000 | Timeline: 2mo
[Orchestrator] Scored Sarah Mitchell: 93/100 (HOT)
[Orchestrator] Qualifying lead: Marcus Delgado
[Orchestrator] Qualification complete for Marcus Delgado
Property: condo | Budget: $None-$400000 | Timeline: 12mo
[Orchestrator] Scored Marcus Delgado: 28/100 (COLD)
[Orchestrator] Qualifying lead: Jennifer Park
[Orchestrator] Qualification complete for Jennifer Park
Property: commercial | Budget: $2000000-$