← Back to Blog

What You'll Build

By the end of this tutorial, you'll have a working AI agent that automatically qualifies real estate leads by asking the right questions, scoring each prospect, and returning a structured recommendation — all in under 150 lines of Python. The agent uses Claude's tool_use feature to call custom functions that evaluate budget, timeline, and property fit. This is the same pattern we use at Naples AI to build production lead-qualification systems for Southwest Florida real estate teams.

📦 Full Source Code
The complete, working code for this project is broken into steps below. Each step builds on the last, so by Step 5 you'll have the full agent ready to run. If you want to jump straight to testing, you can copy each snippet in order and paste them into a single lead_qualifier.py file.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic Python SDK installed (pip install anthropic)
  • A terminal or IDE you're comfortable running scripts from

Step 1: Set Up Your Claude API Environment

First, install the Anthropic SDK and set your API key as an environment variable. Never hardcode API keys in source files — that's the fastest way to accidentally leak credentials to GitHub.

terminal
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-your-key-here"

Now create your project file and pull in the libraries you need. We're also importing json because Claude's tool results need to be serialized before you send them back.

lead_qualifier.py
import os
import json
import anthropic

# Initialize the Anthropic client using the ANTHROPIC_API_KEY env variable
client = anthropic.Anthropic()

MODEL = "claude-sonnet-4-6"
⚠️ Common Mistake
If you get an AuthenticationError, your environment variable isn't set correctly. Run echo $ANTHROPIC_API_KEY in your terminal to confirm it's there before moving on.

Step 2: Define Lead Qualification Tools

This is where the agent gets its real power. Tools tell Claude what functions it can call — and Claude decides when to call them based on the conversation. We're defining two tools: one to look up property details and one to score the lead.

The tool definitions follow Anthropic's JSON schema format. Get these right and the agent will call them cleanly every time. Get them wrong and Claude will either ignore the tools or hallucinate arguments.

lead_qualifier.py
TOOLS = [
    {
        "name": "get_property_details",
        "description": (
            "Retrieves available property listings that match a buyer's criteria. "
            "Use this when the lead has provided a budget and preferred location."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "budget_max": {
                    "type": "number",
                    "description": "The buyer's maximum budget in USD."
                },
                "location": {
                    "type": "string",
                    "description": "The target neighborhood or city, e.g. 'Naples, FL'."
                },
                "property_type": {
                    "type": "string",
                    "description": "Type of property: 'single_family', 'condo', or 'townhouse'.",
                    "enum": ["single_family", "condo", "townhouse"]
                }
            },
            "required": ["budget_max", "location", "property_type"]
        }
    },
    {
        "name": "score_lead",
        "description": (
            "Calculates a qualification score for a real estate lead based on "
            "financial readiness, timeline urgency, and property match quality. "
            "Always call this after gathering enough information from the lead."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_name": {
                    "type": "string",
                    "description": "Full name of the lead."
                },
                "budget": {
                    "type": "number",
                    "description": "Stated budget in USD."
                },
                "pre_approved": {
                    "type": "boolean",
                    "description": "Whether the lead has mortgage pre-approval."
                },
                "timeline_months": {
                    "type": "integer",
                    "description": "How many months until the lead wants to purchase."
                },
                "property_match_count": {
                    "type": "integer",
                    "description": "Number of available listings that match their criteria."
                }
            },
            "required": [
                "lead_name", "budget", "pre_approved",
                "timeline_months", "property_match_count"
            ]
        }
    }
]

Now write the Python functions that actually execute when Claude calls those tools. These simulate a real database lookup and scoring algorithm — in production you'd swap these out for your CRM or MLS API calls.

lead_qualifier.py
def get_property_details(budget_max: float, location: str, property_type: str) -> dict:
    """
    Simulates an MLS database query. In production, replace with
    a real API call to Zillow, MLS, or your internal property database.
    """
    # Mock property inventory keyed by location and type
    mock_inventory = {
        ("naples, fl", "single_family"): [
            {"address": "1204 Gulf Shore Blvd", "price": 875000, "beds": 4, "baths": 3},
            {"address": "340 Pelican Bay Dr",   "price": 950000, "beds": 3, "baths": 2},
        ],
        ("naples, fl", "condo"): [
            {"address": "800 Vanderbilt Beach Rd #502", "price": 620000, "beds": 2, "baths": 2},
            {"address": "3000 Gulf Shore Blvd #301",    "price": 540000, "beds": 2, "baths": 1},
            {"address": "550 Binnacle Dr #204",          "price": 480000, "beds": 1, "baths": 1},
        ],
        ("naples, fl", "townhouse"): [
            {"address": "2110 Kings Lake Blvd", "price": 410000, "beds": 3, "baths": 2},
        ],
    }

    key = (location.lower(), property_type.lower())
    all_matches = mock_inventory.get(key, [])

    # Filter listings within the lead's budget
    affordable = [p for p in all_matches if p["price"] <= budget_max]

    return {
        "location": location,
        "property_type": property_type,
        "budget_max": budget_max,
        "matches_found": len(affordable),
        "listings": affordable
    }


def score_lead(
    lead_name: str,
    budget: float,
    pre_approved: bool,
    timeline_months: int,
    property_match_count: int
) -> dict:
    """
    Scores a lead from 0-100 based on three weighted criteria.
    Adjust the weights to match your agency's qualification standards.
    """
    score = 0

    # Financial readiness: pre-approval is the strongest buying signal (40 pts)
    if pre_approved:
        score += 40
    elif budget >= 500000:
        score += 20  # High budget without pre-approval still shows intent
    else:
        score += 10

    # Timeline urgency (30 pts): closer purchase date = hotter lead
    if timeline_months <= 1:
        score += 30
    elif timeline_months <= 3:
        score += 22
    elif timeline_months <= 6:
        score += 15
    else:
        score += 5

    # Property match quality (30 pts): more options = less likely to stall
    if property_match_count >= 3:
        score += 30
    elif property_match_count == 2:
        score += 20
    elif property_match_count == 1:
        score += 10
    else:
        score += 0  # No matches = likely to churn

    # Determine tier label based on final score
    if score >= 75:
        tier = "HOT"
        recommendation = "Priority follow-up within 24 hours. Assign to senior agent."
    elif score >= 50:
        tier = "WARM"
        recommendation = "Follow up within 3 days. Send curated listing email."
    else:
        tier = "COLD"
        recommendation = "Add to drip campaign. Re-engage in 30 days."

    return {
        "lead_name": lead_name,
        "score": score,
        "tier": tier,
        "recommendation": recommendation,
        "breakdown": {
            "financial_readiness": 40 if pre_approved else (20 if budget >= 500000 else 10),
            "timeline_urgency": 30 if timeline_months <= 1 else (22 if timeline_months <= 3 else 15 if timeline_months <= 6 else 5),
            "property_match": 30 if property_match_count >= 3 else (20 if property_match_count == 2 else 10 if property_match_count == 1 else 0)
        }
    }

Step 3: Create the Agent Logic with tool_use

Now we build the class that wraps everything together. The LeadQualifierAgent holds the conversation history and knows how to dispatch tool calls back to the right Python function. Think of it as the traffic controller between Claude and your business logic.

lead_qualifier.py
class LeadQualifierAgent:
    """
    An agentic lead qualifier that converses with a prospect description,
    calls tools to gather data, and returns a structured qualification report.
    """

    def __init__(self):
        self.client = anthropic.Anthropic()
        self.model = MODEL
        self.tools = TOOLS
        # Map tool names to their Python implementations
        self.tool_handlers = {
            "get_property_details": get_property_details,
            "score_lead": score_lead,
        }
        self.system_prompt = """You are a real estate lead qualification specialist for a 
Southwest Florida luxury property agency. Your job is to assess incoming leads and determine 
how ready they are to purchase.

When given information about a lead, you MUST:
1. Call get_property_details to find matching listings based on their budget, location, and property type.
2. Call score_lead using what you've learned — including the number of property matches returned.
3. Return a clear, concise qualification summary with the score, tier, and your recommendation.

Be direct and professional. Do not ask clarifying questions — work with the information provided."""

    def execute_tool(self, tool_name: str, tool_input: dict) -> str:
        """Executes the appropriate tool function and returns a JSON string result."""
        if tool_name not in self.tool_handlers:
            return json.dumps({"error": f"Unknown tool: {tool_name}"})

        handler = self.tool_handlers[tool_name]
        result = handler(**tool_input)
        return json.dumps(result)

    def qualify_lead(self, lead_info: str) -> str:
        """
        Main entry point. Pass a natural-language description of a lead
        and get back a structured qualification report.
        """
        messages = [
            {"role": "user", "content": lead_info}
        ]

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

        # Kick off the agentic loop
        return self._run_agent_loop(messages)

Step 4: Implement the Agent Loop

The agent loop is the heart of how tool_use works with Claude. Claude returns a response, you check if it wants to use a tool, you run the tool, and you feed the result back. You keep doing this until Claude gives you a final text answer with no more tool calls.

This pattern is exactly what makes agentic workflows different from a basic API call. The model is driving the process — you're just executing what it asks for.

lead_qualifier.py
    def _run_agent_loop(self, messages: list) -> str:
        """
        Iterates between Claude and tool execution until Claude
        produces a final text response with no pending tool calls.
        """
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=self.system_prompt,
                tools=self.tools,
                messages=messages
            )

            print(f"\n[Agent] Stop reason: {response.stop_reason}")

            # If Claude is done, extract and return the final text
            if response.stop_reason == "end_turn":
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text
                return "No text response generated."

            # Claude wants to use one or more tools
            if response.stop_reason == "tool_use":
                # Add Claude's full response (including tool_use blocks) to history
                messages.append({
                    "role": "assistant",
                    "content": response.content
                })

                # Process every tool call Claude requested
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        print(f"[Tool Call] {block.name}({json.dumps(block.input, indent=2)})")

                        result_str = self.execute_tool(block.name, block.input)
                        result_data = json.loads(result_str)

                        print(f"[Tool Result] {json.dumps(result_data, indent=2)}")

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

                # Return all tool results to Claude in a single user message
                messages.append({
                    "role": "user",
                    "content": tool_results
                })

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

        return "Agent loop ended without a final response."
💡 Why the loop matters
Claude might call multiple tools in sequence — for example, get_property_details first, then score_lead using the match count from the first call. The loop handles this automatically without you needing to hardcode the sequence. That's the whole point of agentic design.

Step 5: Test with Sample Leads

Let's wire everything together and run the agent against two different lead profiles — one hot, one cold. This is the part where you see if all the pieces connect correctly.

lead_qualifier.py
if __name__ == "__main__":
    agent = LeadQualifierAgent()

    # Lead 1: Strong buyer — pre-approved, short timeline, clear criteria
    lead_one = """
    Lead Name: Sandra Whitmore
    Budget: $700,000
    Location: Naples, FL
    Property Type: Condo
    Pre-Approved: Yes
    Purchase Timeline: 2 months
    Notes: Relocating from Chicago for retirement. Wants a waterfront view if possible.
    """

    result_one = agent.qualify_lead(lead_one)
    print(f"\n{'='*60}")
    print("QUALIFICATION REPORT — Sandra Whitmore")
    print(f"{'='*60}")
    print(result_one)

    print("\n\n")

    # Lead 2: Weak buyer — no pre-approval, vague timeline, limited inventory match
    lead_two = """
    Lead Name: Marcus Bell
    Budget: $350,000
    Location: Naples, FL
    Property Type: Single Family
    Pre-Approved: No
    Purchase Timeline: 18 months
    Notes: First-time buyer, still exploring options, not sure about location yet.
    """

    result_two = agent.qualify_lead(lead_two)
    print(f"\n{'='*60}")
    print("QUALIFICATION REPORT — Marcus Bell")
    print(f"{'='*60}")
    print(result_two)

Run the script with python lead_qualifier.py and you'll see output like this:

sample output
============================================================
LEAD QUALIFIER AGENT — Processing...
============================================================

[Agent] Stop reason: tool_use
[Tool Call] get_property_details({
  "budget_max": 700000,
  "location": "Naples, FL",
  "property_type": "condo"
})
[Tool Result] {
  "location": "Naples, FL",
  "property_type": "condo",
  "budget_max": 700000,
  "matches_found": 3,
  "listings": [
    {"address": "800 Vanderbilt Beach Rd #502", "price": 620000, "beds": 2, "baths": 2},
    {"address": "3000 Gulf Shore Blvd #301",    "price": 540000, "beds": 2, "baths": 1},
    {"address": "550 Binnacle Dr #204",          "price": 480000, "beds": 1, "baths": 1}
  ]
}

[Agent] Stop reason: tool_use
[Tool Call] score_lead({
  "lead_name": "Sandra Whitmore",
  "budget": 700000,
  "pre_approved": true,
  "timeline_months": 2,
  "property_match_count": 3
})
[Tool Result] {
  "lead_name": "Sandra Whitmore",
  "score": 92,
  "tier": "HOT",
  "recommendation": "Priority follow-up within 24 hours. Assign to senior agent.",
  "breakdown": {
    "financial_readiness": 40,
    "timeline_urgency": 22,
    "property_match": 30
  }
}

[Agent] Stop reason: end_turn

============================================================
QUALIFICATION REPORT — Sandra Whitmore
============================================================
**Lead Qualification Report: Sandra Whitmore**

**Score: 92/100 — HOT Lead**

Sandra is an exceptionally strong buyer. She comes pre-approved with a $700,000 
budget, a 2-month purchase timeline, and there are 3 available condo listings 
that fit her criteria in Naples, FL — including options near the waterfront 
she's looking for.

**Recommendation:** Priority follow-up within 24 hours. Assign to senior agent.

**Available Listings:**
- 800 Vanderbilt Beach Rd #502 — $620,000 (2 bed / 2 bath)
- 3000 Gulf Shore Blvd #301 — $540,000 (2 bed / 1 bath)
- 550 Binnacle Dr #204 — $480,000 (1 bed / 1 bath)

---

**Lead Qualification Report: Marcus Bell**

**Score: 15/100 — COLD Lead**

Marcus is early in his journey. No pre-approval, an 18-month timeline, and 
his $350,000 budget for a single-family home in Naples returns zero matching 
listings in current inventory.

**Recommendation:** Add to drip campaign. Re-engage in 30 days.

How It Works

Here's the plain-English version of what's happening under the hood. When you call qualify_lead(), the agent sends your lead description to Claude along with the tool definitions. Claude reads the lead info and decides it needs to call get_property_details first — it doesn't guess, it reasons through what information it's missing.

Your code executes that tool and sends the results back. Claude now knows how many listings match, so it calls score_lead with all the data it has. After the second tool result comes back, Claude has everything it needs to write the final report — so it stops calling tools and returns the text.

The entire sequence is driven by Claude's reasoning, not hardcoded if-else logic on your end. That's why this is an agent instead of just a script. You could add five more tools and Claude would figure out when and how to use them without you rewriting the loop.

Common Errors and Fixes

Error 1: anthropic.BadRequestError — "messages: roles must alternate"

This happens when you accidentally add two consecutive user or assistant messages to the history. Claude's API requires strict role alternation.

Fix: Make sure every tool result goes into a single user message with a list under content. Never append multiple separate {"role": "