← Back to Blog

If you're in real estate and you're still manually reading every inbound inquiry to decide who's worth calling back, you're leaving money on the table — and burning time you don't have. The problem isn't the volume of leads. It's that sorting them takes real judgment, and that's exactly what a Claude-powered agent can do for you.

In this tutorial, I'll show you how to build a real estate lead qualifier agent using the Anthropic Claude API and Python. By the end, you'll have a working agentic system that reads a lead inquiry, looks up property data, scores the lead, and returns a structured qualification report — all without a human in the loop.

What You'll Build

You're going to build a Python-based AI agent that takes a raw real estate lead inquiry and runs it through a multi-step qualification workflow. The agent uses Claude's tool-use feature to call custom functions — simulating a CRM lookup and a lead scorer — and then synthesizes everything into a scored, actionable output.

The finished agent runs in roughly 200 lines of Python. It produces a lead score from 0–100, a qualification tier (Hot / Warm / Cold), and a recommended next action for your sales team.

Prerequisites

  • Python 3.10 or higher
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic Python SDK installed (pip install anthropic)
  • python-dotenv for environment variable management (pip install python-dotenv)
📦 Full Source Code
The complete working code for this project is built step by step in the sections below. Each snippet is self-contained and builds on the last. By Step 5, you'll have the entire agent assembled and ready to run. Copy each section in order and you'll have a working qualifier in under 20 minutes.

Step 1: Set Up Claude SDK and Environment Variables

First, let's get the environment configured. You'll store your API key in a .env file so it never ends up hardcoded in your source. This is the right habit to build from day one.

.env
ANTHROPIC_API_KEY=your_api_key_here

Now create your main agent file and bring in the dependencies. The load_dotenv() call pulls those variables into your environment before the Anthropic client initializes.

lead_qualifier.py
import os
import json
from dotenv import load_dotenv
import anthropic

# Load environment variables from .env before anything else
load_dotenv()

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-5"
⚠️ Note on Model Name
We're using claude-sonnet-4-5 throughout this tutorial. This model handles multi-step tool use reliably and keeps latency reasonable for a lead qualification workflow. Check the Anthropic docs for the latest available model slugs if you're reading this after June 2026.

Step 2: Define Tool Schemas for Lead Qualification

Claude's tool-use feature lets the model call functions you define. You describe each tool in a JSON schema, and when Claude decides it needs data, it tells you which tool to call and with what arguments. You run the function and pass the result back.

We're defining two tools here. The first simulates a CRM lookup — in production, this would hit your actual CRM's API. The second runs a scoring calculation based on the lead's attributes.

lead_qualifier.py (continued)
TOOLS = [
    {
        "name": "lookup_crm_contact",
        "description": (
            "Looks up an existing contact in the CRM by email address. "
            "Returns prior interaction history, property interests, and "
            "any notes from previous agents. Returns null fields if the "
            "contact is not found."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "email": {
                    "type": "string",
                    "description": "The email address of the lead to look up."
                }
            },
            "required": ["email"]
        }
    },
    {
        "name": "score_lead",
        "description": (
            "Calculates a numeric lead score from 0 to 100 based on "
            "qualifying attributes. Higher scores indicate higher purchase "
            "intent and financial readiness. Returns a score and a "
            "qualification tier: Hot (75-100), Warm (40-74), Cold (0-39)."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "budget_min": {
                    "type": "number",
                    "description": "Minimum stated budget in USD."
                },
                "budget_max": {
                    "type": "number",
                    "description": "Maximum stated budget in USD."
                },
                "timeline_months": {
                    "type": "number",
                    "description": "How many months until the lead wants to purchase."
                },
                "pre_approved": {
                    "type": "boolean",
                    "description": "Whether the lead has mortgage pre-approval."
                },
                "property_type": {
                    "type": "string",
                    "description": "Type of property: residential, condo, commercial, land."
                },
                "previous_interactions": {
                    "type": "number",
                    "description": "Number of prior touchpoints found in CRM (0 if new)."
                }
            },
            "required": [
                "budget_min", "budget_max", "timeline_months",
                "pre_approved", "property_type", "previous_interactions"
            ]
        }
    }
]

Keep your tool descriptions specific. Claude reads these descriptions when deciding which tool to call — the more precise you are, the better its decisions will be. Vague descriptions lead to the model guessing wrong or skipping tools it should use.

Step 3: Create the Lead Qualifier Agent Class

Now we build the agent class itself. This holds the tool execution logic — the actual Python functions that run when Claude calls a tool. Think of it as the "hands" that Claude controls.

The CRM lookup here returns mock data. In a real deployment, you'd replace the body of lookup_crm_contact with an API call to HubSpot, Salesforce, Follow Up Boss, or whatever CRM your team uses.

lead_qualifier.py (continued)
class LeadQualifierAgent:
    """
    AI agent that qualifies real estate leads using Claude's tool-use
    feature. Orchestrates CRM lookups and lead scoring in an agentic loop.
    """

    def __init__(self):
        self.client = client
        self.model = MODEL

    def lookup_crm_contact(self, email: str) -> dict:
        """
        Simulates a CRM lookup. Replace this with your real CRM API call.
        Returns contact history if found, or an empty record if not.
        """
        # Mock CRM data — swap this block for a real HTTP request in production
        mock_crm_db = {
            "marco@example.com": {
                "found": True,
                "name": "Marco Delgado",
                "previous_interactions": 3,
                "last_contact": "2026-05-10",
                "notes": "Toured two condos in Naples. Mentioned retirement timeline.",
                "prior_interest": "waterfront condos under $800k"
            },
            "sarah@example.com": {
                "found": True,
                "name": "Sarah Kimura",
                "previous_interactions": 1,
                "last_contact": "2026-03-22",
                "notes": "First inquiry, no tour yet.",
                "prior_interest": "single-family homes, North Naples"
            }
        }

        if email in mock_crm_db:
            return mock_crm_db[email]

        # Return a blank record for unknown contacts
        return {
            "found": False,
            "name": None,
            "previous_interactions": 0,
            "last_contact": None,
            "notes": None,
            "prior_interest": None
        }

    def score_lead(
        self,
        budget_min: float,
        budget_max: float,
        timeline_months: float,
        pre_approved: bool,
        property_type: str,
        previous_interactions: int
    ) -> dict:
        """
        Computes a 0-100 lead score based on weighted qualification signals.
        Weights are tunable — adjust them to match your market and pipeline.
        """
        score = 0

        # Budget weight: max 30 points
        if budget_max >= 1_000_000:
            score += 30
        elif budget_max >= 500_000:
            score += 22
        elif budget_max >= 250_000:
            score += 14
        else:
            score += 5

        # Timeline weight: max 25 points (sooner = higher score)
        if timeline_months <= 3:
            score += 25
        elif timeline_months <= 6:
            score += 18
        elif timeline_months <= 12:
            score += 10
        else:
            score += 3

        # Pre-approval weight: max 25 points
        if pre_approved:
            score += 25

        # Prior engagement weight: max 15 points
        if previous_interactions >= 3:
            score += 15
        elif previous_interactions >= 1:
            score += 8

        # Property type weight: max 5 points
        high_value_types = ["commercial", "waterfront", "luxury"]
        if any(t in property_type.lower() for t in high_value_types):
            score += 5
        else:
            score += 2

        # Determine qualification tier
        if score >= 75:
            tier = "Hot"
            recommendation = "Call within 2 hours. Assign to senior agent."
        elif score >= 40:
            tier = "Warm"
            recommendation = "Send personalized property matches today. Schedule follow-up call within 48 hours."
        else:
            tier = "Cold"
            recommendation = "Add to drip email sequence. Re-evaluate in 30 days."

        return {
            "score": min(score, 100),  # Cap at 100
            "tier": tier,
            "recommendation": recommendation,
            "breakdown": {
                "budget_points": min(score, 30),
                "timeline_points": min(timeline_months, 25),
                "pre_approval_points": 25 if pre_approved else 0,
                "engagement_points": previous_interactions
            }
        }

    def execute_tool(self, tool_name: str, tool_input: dict) -> str:
        """
        Routes a tool call from Claude to the correct Python function.
        Returns the result as a JSON string for the API message.
        """
        if tool_name == "lookup_crm_contact":
            result = self.lookup_crm_contact(**tool_input)
        elif tool_name == "score_lead":
            result = self.score_lead(**tool_input)
        else:
            result = {"error": f"Unknown tool: {tool_name}"}

        return json.dumps(result)

Step 4: Implement the Agentic Loop with Tool Use

This is the core of the whole system. The agentic loop sends a message to Claude, checks whether Claude wants to use a tool, runs that tool if so, and feeds the result back — repeating until Claude returns a final text response with no more tool calls.

The loop tracks the full message history so Claude has context at every step. This is what makes it feel like a real conversation rather than a single-shot prompt.

lead_qualifier.py (continued)
    def qualify_lead(self, lead_inquiry: str) -> str:
        """
        Main entry point. Takes a raw lead inquiry string and runs the
        full agentic qualification loop. Returns a structured report.
        """
        system_prompt = """You are a real estate lead qualification specialist for a 
luxury property agency in Southwest Florida. Your job is to analyze 
incoming lead inquiries and produce a structured qualification report.

When you receive a lead inquiry, you must:
1. Extract the lead's email address and use the lookup_crm_contact tool.
2. Extract all qualifying signals (budget, timeline, pre-approval status, 
   property type) from the inquiry text.
3. Use the score_lead tool with those signals plus the CRM interaction count.
4. Return a clean qualification report with the score, tier, recommendation,
   and a one-paragraph summary of why you rated this lead that way.

If information is missing from the inquiry (e.g., no budget stated), make 
a reasonable assumption and note it in your report. Always complete the 
full workflow — do not stop after the CRM lookup."""

        messages = [
            {"role": "user", "content": lead_inquiry}
        ]

        print(f"\n{'='*60}")
        print("LEAD QUALIFIER AGENT — PROCESSING")
        print(f"{'='*60}\n")

        # Agentic loop: keeps running until Claude stops calling tools
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=2048,
                system=system_prompt,
                tools=TOOLS,
                messages=messages
            )

            print(f"Agent step → stop_reason: {response.stop_reason}")

            # If Claude is done (no more tool calls), extract and return the text
            if response.stop_reason == "end_turn":
                final_text = ""
                for block in response.content:
                    if hasattr(block, "text"):
                        final_text += block.text
                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 calls) to message history
                messages.append({
                    "role": "assistant",
                    "content": response.content
                })

                # Build the tool results message
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        print(f"  → Calling tool: {block.name}")
                        print(f"    Input: {json.dumps(block.input, indent=2)}")

                        tool_output = self.execute_tool(block.name, block.input)

                        print(f"    Output: {tool_output}\n")

                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": tool_output
                        })

                # Feed tool results back to Claude in the next iteration
                messages.append({
                    "role": "user",
                    "content": tool_results
                })

            else:
                # Unexpected stop reason — break to avoid an infinite loop
                print(f"Unexpected stop_reason: {response.stop_reason}. Exiting loop.")
                break

        return "Agent loop ended without a final response."

Step 5: Add Scoring and Qualification Logic — Run the Agent

The last piece is the main() function that wires everything together and feeds a sample lead inquiry to the agent. This is where you'd eventually swap in a webhook listener, a form submission handler, or a CRM trigger.

lead_qualifier.py (continued)
def main():
    agent = LeadQualifierAgent()

    # Sample lead inquiry — this could come from a web form, email parser, or CRM webhook
    sample_inquiry = """
    Hi, my name is Marco Delgado and I'm looking to purchase a waterfront condo 
    in Naples within the next 3 months. My email is marco@example.com. 
    My budget is between $650,000 and $850,000. I've already been pre-approved 
    for a mortgage at $800k. Ideally a 2-bed, 2-bath with Gulf views. 
    Let me know what you have available.
    """

    result = agent.qualify_lead(sample_inquiry)

    print("\n" + "="*60)
    print("FINAL QUALIFICATION REPORT")
    print("="*60)
    print(result)


if __name__ == "__main__":
    main()

Run it with python lead_qualifier.py and you'll see the agent's decision-making process print to your console in real time, followed by the final report.

Example Lead Qualification Output

Here's what the agent actually produces when you run it against the sample inquiry above. The console shows each tool call as it happens, then the final report.

terminal output
============================================================
LEAD QUALIFIER AGENT — PROCESSING
============================================================

Agent step → stop_reason: tool_use
  → Calling tool: lookup_crm_contact
    Input: {
  "email": "marco@example.com"
}
    Output: {"found": true, "name": "Marco Delgado", "previous_interactions": 3,
             "last_contact": "2026-05-10", "notes": "Toured two condos in Naples.
             Mentioned retirement timeline.", "prior_interest": "waterfront condos under $800k"}

Agent step → stop_reason: tool_use
  → Calling tool: score_lead
    Input: {
  "budget_min": 650000,
  "budget_max": 850000,
  "timeline_months": 3,
  "pre_approved": true,
  "property_type": "waterfront condo",
  "previous_interactions": 3
}
    Output: {"score": 97, "tier": "Hot", "recommendation": "Call within 2 hours.
             Assign to senior agent.", "breakdown": {"budget_points": 22,
             "timeline_points": 25, "pre_approval_points": 25, "engagement_points": 3}}

Agent step → stop_reason: end_turn

============================================================
FINAL QUALIFICATION REPORT
============================================================

**Lead Qualification Report — Marco Delgado**
Email: marco@example.com
Date: June 15, 2026

**Score: 97 / 100 | Tier: 🔥 HOT**

**Recommended Action:** Call within 2 hours. Assign to senior agent.

**Summary:**
Marco is an exceptionally strong lead. He has a clearly defined budget
($650k–$850k), a tight 3-month purchase timeline, and existing mortgage
pre-approval at $800k — which means there are no financing barriers. His
CRM history shows 3 prior interactions, including tours of Naples condos,
confirming serious intent. His stated interest in Gulf-view waterfront
properties aligns with our current inventory. This lead should be treated
as an immediate priority.

How It Works: Agent Decision-Making Flow

When you call qualify_lead(), the agent sends the raw inquiry to Claude along with the tool definitions. Claude reads the inquiry, figures out what information it needs, and responds with a tool_use block instead of plain text — that's its way of saying "I need to call a function before I can answer."

Your code catches that, runs the actual Python function, and sends the result back as a tool_result message. Claude processes it, may call another tool, and eventually produces a final text response when it has everything it needs. The whole loop is synchronous and deterministic — Claude won't invent data it doesn't have.

The key insight is that you're not prompting Claude to produce a score. You're giving it tools that produce the score, and letting it orchestrate the workflow. That's what makes this an agent rather than just a fancy prompt.

Common Errors and Fixes

Error: anthropic.AuthenticationError: 401 Invalid API key

This means your API key isn't loading correctly. The most common cause is a missing load_dotenv() call before you initialize the client, or a typo in your .env file. Double-check that your .env file is in the same directory you're running the script from, and that the key name matches exactly: ANTHROPIC_API_KEY.

Error: KeyError: 'email' inside execute_tool

This happens when Claude calls a tool but the extracted input doesn't include a required field — usually because the lead inquiry didn't contain enough information (like no email address). Fix this by adding a default fallback in execute_tool: wrap the self.lookup_crm_contact(**tool_input) call in a try/except and return a "not found" record on KeyError. You should also update your system prompt to instruct Claude to use "unknown@unknown.com" as a placeholder if no email is present.

Error: Agent loops forever and never hits end_turn

This usually means your tool result isn't being passed back correctly, so Claude keeps requesting the same tool. Confirm your tool_results list