← Back to Blog

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)
📦 Full Source Code
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.

terminal
pip install anthropic

Next, set your API key as an environment variable. Never hard-code keys directly in your source files.

terminal
export ANTHROPIC_API_KEY="your-api-key-here"

Create a new project folder and the main file you'll work in throughout this tutorial.

terminal
mkdir 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.py
import 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"
⚠️ Note on Model Name
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.py
class 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.lead

Step 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.py
class 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.py
class 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": "sarah.m@gmail.com", "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": "mdelgado@outlook.com", "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": "jpark.realty@email.com", "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-$