← Back to Blog

What You'll Build

If you've ever lost a hot lead because your team was too slow to follow up, this tutorial is for you. You're going to build a working AI agent in Python that chats with incoming real estate inquiries, extracts key qualification data, and scores each lead automatically using the Claude API.

By the end, you'll have a fully functional lead qualifier agent that identifies property type, budget range, and buying timeline from a natural conversation — no forms, no friction. The same pattern we use at Naples AI when we build real estate automation for clients here in Southwest Florida.

📦 Full Source Code
The complete, working code for this tutorial is broken into steps below. Each step builds on the last, so by Step 5 you'll have the entire agent assembled. You can copy each block in order into a single lead_qualifier.py file and it will run end-to-end.

Prerequisites

  • Python 3.9 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)
  • python-dotenv for managing your API key (pip install python-dotenv)

Step 1: Set Up Claude API and Your Python Environment

First, get your project folder ready. Create a new directory, set up a virtual environment, and install the dependencies. Keeping your API key out of your source code is non-negotiable — use a .env file.

terminal
mkdir real-estate-qualifier
cd real-estate-qualifier
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install anthropic python-dotenv

Now create a .env file in your project root and add your key. Never commit this file to Git.

.env
ANTHROPIC_API_KEY=sk-ant-your-key-here

Let's do a quick sanity check to make sure your environment is wired up correctly before writing any agent logic.

test_connection.py
import os
from dotenv import load_dotenv
import anthropic

load_dotenv()

client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

message = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=256,
    messages=[{"role": "user", "content": "Say: API connection successful."}]
)

print(message.content[0].text)

Run it with python test_connection.py. You should see API connection successful. printed back. If you get an auth error, double-check your .env file path and that load_dotenv() is being called before the client is created.

Step 2: Define Lead Qualification Tools and Schemas

This is where the real magic happens. Claude's tool use feature lets the model decide when to call a structured function instead of just chatting. We define three tools: one for property type, one for budget, and one to record the buying timeline.

Think of these tools as the data slots you need filled before you can score a lead. Claude will call each one naturally as the user reveals that information in conversation.

tools.py
"""
Tool definitions for the real estate lead qualifier.
Each tool corresponds to a key piece of qualification data.
"""

QUALIFICATION_TOOLS = [
    {
        "name": "record_property_type",
        "description": (
            "Call this tool when the lead mentions what type of property they are "
            "interested in, such as single-family home, condo, townhouse, land, "
            "commercial property, or multi-family unit."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "property_type": {
                    "type": "string",
                    "enum": [
                        "single_family",
                        "condo",
                        "townhouse",
                        "land",
                        "commercial",
                        "multi_family",
                        "unknown"
                    ],
                    "description": "The type of property the lead is interested in."
                },
                "raw_mention": {
                    "type": "string",
                    "description": "The exact phrase the user used when describing the property type."
                }
            },
            "required": ["property_type", "raw_mention"]
        }
    },
    {
        "name": "record_budget",
        "description": (
            "Call this tool when the lead mentions a price range, maximum budget, "
            "or any dollar amount related to their purchase. Convert ranges to min/max values."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "budget_min": {
                    "type": "integer",
                    "description": "Minimum budget in USD. Use 0 if no lower bound mentioned."
                },
                "budget_max": {
                    "type": "integer",
                    "description": "Maximum budget in USD."
                },
                "raw_mention": {
                    "type": "string",
                    "description": "The exact phrase the user used when describing their budget."
                }
            },
            "required": ["budget_min", "budget_max", "raw_mention"]
        }
    },
    {
        "name": "record_timeline",
        "description": (
            "Call this tool when the lead mentions when they want to buy, move, "
            "or close on a property. This includes urgency signals like 'ASAP', "
            "specific months, or vague future references."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "timeline_category": {
                    "type": "string",
                    "enum": [
                        "immediate",    # 0-30 days
                        "short_term",   # 1-3 months
                        "medium_term",  # 3-6 months
                        "long_term",    # 6-12 months
                        "exploring",    # No firm timeline
                        "unknown"
                    ],
                    "description": "Categorized buying timeline."
                },
                "raw_mention": {
                    "type": "string",
                    "description": "The exact phrase the user used when describing their timeline."
                }
            },
            "required": ["timeline_category", "raw_mention"]
        }
    },
    {
        "name": "submit_qualification_score",
        "description": (
            "Call this tool ONLY when you have collected enough information to score "
            "the lead. You must have at least property type and budget before calling this. "
            "This finalizes the qualification and ends the conversation."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "score": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 10,
                    "description": (
                        "Lead quality score from 1-10. Score 8-10 for immediate/short-term "
                        "buyers with clear budget over $400k. Score 5-7 for medium-term "
                        "with defined budget. Score 1-4 for explorers or unclear criteria."
                    )
                },
                "priority": {
                    "type": "string",
                    "enum": ["hot", "warm", "cold"],
                    "description": "Priority tier for the sales team."
                },
                "summary": {
                    "type": "string",
                    "description": "One or two sentence summary of the lead for the agent CRM."
                },
                "recommended_action": {
                    "type": "string",
                    "description": "Specific next step the sales team should take."
                }
            },
            "required": ["score", "priority", "summary", "recommended_action"]
        }
    }
]
💡 Why use enums in your tool schemas?
Enums force Claude to pick from a fixed set of values instead of free-texting things like "3-bedroom home" or "house". That makes your downstream data clean and easy to filter without extra parsing logic.

Step 3: Build the Lead Qualifier Agent Class

Now we wrap everything into a clean Python class. The system prompt is doing a lot of heavy lifting here — it tells Claude to act like a friendly real estate concierge, not a chatbot that fires off tool calls immediately. The conversation should feel natural.

The LeadQualifierAgent class holds the conversation history, tracks what data has been collected, and knows when a lead has been fully qualified.

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

load_dotenv()


SYSTEM_PROMPT = """You are Alex, a friendly real estate concierge for a Southwest Florida 
real estate agency. Your job is to have a natural, warm conversation with potential buyers 
and gently gather the information you need to qualify them.

You MUST collect all three of these before scoring:
1. Property type (what kind of property they want)
2. Budget (price range they are working with)
3. Buying timeline (when they want to purchase)

Rules:
- Ask only ONE question at a time. Never fire multiple questions at once.
- Sound human. Say things like "That's a great area!" or "Got it, that helps a lot."
- Use the record_property_type, record_budget, and record_timeline tools silently 
  as soon as the user mentions relevant information. Do NOT announce that you are 
  recording anything.
- Once you have all three data points, call submit_qualification_score immediately.
- After submitting the score, thank the user warmly and tell them an agent will 
  be in touch shortly.
- If the user seems to be in a hurry, adapt your pace.
- Keep responses under 3 sentences unless the user asks a detailed question.

You are representing a high-end agency. Be professional but not stuffy."""


class LeadQualifierAgent:
    """
    AI agent that qualifies real estate leads through natural conversation
    using Claude's tool use feature.
    """

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-6"
        self.conversation_history = []
        self.qualification_data = {
            "property_type": None,
            "budget_min": None,
            "budget_max": None,
            "timeline_category": None,
        }
        self.final_score = None
        self.is_qualified = False

    def _handle_tool_call(self, tool_name: str, tool_input: dict) -> str:
        """Process tool calls from Claude and update qualification data."""

        if tool_name == "record_property_type":
            self.qualification_data["property_type"] = tool_input["property_type"]
            print(f"  [TOOL] Recorded property type: {tool_input['property_type']}")
            return json.dumps({"status": "recorded", "property_type": tool_input["property_type"]})

        elif tool_name == "record_budget":
            self.qualification_data["budget_min"] = tool_input["budget_min"]
            self.qualification_data["budget_max"] = tool_input["budget_max"]
            print(f"  [TOOL] Recorded budget: ${tool_input['budget_min']:,} - ${tool_input['budget_max']:,}")
            return json.dumps({"status": "recorded", "budget_max": tool_input["budget_max"]})

        elif tool_name == "record_timeline":
            self.qualification_data["timeline_category"] = tool_input["timeline_category"]
            print(f"  [TOOL] Recorded timeline: {tool_input['timeline_category']}")
            return json.dumps({"status": "recorded", "timeline": tool_input["timeline_category"]})

        elif tool_name == "submit_qualification_score":
            self.final_score = {
                "score": tool_input["score"],
                "priority": tool_input["priority"],
                "summary": tool_input["summary"],
                "recommended_action": tool_input["recommended_action"],
                "qualification_data": self.qualification_data.copy()
            }
            self.is_qualified = True
            print(f"\n  [TOOL] ✅ Lead scored: {tool_input['score']}/10 | Priority: {tool_input['priority'].upper()}")
            return json.dumps({"status": "submitted", "score": tool_input["score"]})

        return json.dumps({"status": "unknown_tool"})

    def chat(self, user_message: str) -> str:
        """
        Send a user message and run the agentic loop until Claude
        returns a final text response.
        """
        # Add the user's message to history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        # Run the agentic loop (defined in Step 4)
        return self._run_agentic_loop()

    def get_qualification_report(self) -> dict:
        """Return the final qualification data if scoring is complete."""
        if not self.is_qualified:
            return {"status": "incomplete", "data": self.qualification_data}
        return {"status": "complete", "report": self.final_score}

Step 4: Implement the Agentic Loop with Tool Use

This is the core of how the agent actually works. Claude can return either a text response or a request to call one of your tools — sometimes both in the same turn. The loop keeps running until Claude sends back a pure text response with no pending tool calls.

Add this method inside your LeadQualifierAgent class, right after the chat method above.

lead_qualifier.py (continued — add inside LeadQualifierAgent class)
    def _run_agentic_loop(self) -> str:
        """
        Core agentic loop. Handles tool_use stop reasons by calling
        tools and feeding results back to Claude until we get a final
        end_turn text response.
        """
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=SYSTEM_PROMPT,
                tools=QUALIFICATION_TOOLS,
                messages=self.conversation_history
            )

            # Build a list to collect all content blocks from this response
            assistant_content = []

            # Separate tool use blocks from text blocks
            tool_use_blocks = []
            text_response = ""

            for block in response.content:
                assistant_content.append(block)
                if block.type == "tool_use":
                    tool_use_blocks.append(block)
                elif block.type == "text":
                    text_response = block.text

            # Append Claude's full response (including tool calls) to history
            self.conversation_history.append({
                "role": "assistant",
                "content": assistant_content
            })

            # If Claude wants to use tools, execute them and loop back
            if tool_use_blocks:
                tool_results = []
                for tool_block in tool_use_blocks:
                    result = self._handle_tool_call(tool_block.name, tool_block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_block.id,
                        "content": result
                    })

                # Feed tool results back into the conversation
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })
                # Loop again so Claude can continue the conversation
                continue

            # No tool calls — Claude gave us a final text response
            return text_response
⚠️ Important: The loop must continue after tool results
A common mistake is returning after processing tool calls. You have to feed the tool results back and call the API again. Claude needs to see the tool output before it can decide what to say next. That continue statement is not optional.

Step 5: Test With Sample Real Estate Inquiries

Now let's wire up a test script to watch the whole thing run. I'm using a realistic conversation you'd actually see from a Naples or Bonita Springs buyer inquiry.

run_qualifier.py
import json
from lead_qualifier import LeadQualifierAgent


def run_demo_conversation():
    """Simulate a realistic real estate lead qualification conversation."""

    agent = LeadQualifierAgent()

    # Sample lead conversation — realistic Naples FL buyer inquiry
    sample_conversation = [
        "Hi, I saw your listing on Zillow and I'm interested in buying something in Naples.",
        "I'm looking for a condo, preferably somewhere near the water or a golf course.",
        "My budget is around 600 to 800 thousand dollars.",
        "We're hoping to close before the end of the year, maybe November or December.",
    ]

    print("=" * 60)
    print("REAL ESTATE LEAD QUALIFIER — DEMO RUN")
    print("=" * 60)

    for user_input in sample_conversation:
        print(f"\n👤 Lead:  {user_input}")
        response = agent.chat(user_input)
        print(f"🤖 Alex:  {response}")

        # Stop the demo loop once the lead is scored
        if agent.is_qualified:
            break

    # Print the final qualification report
    print("\n" + "=" * 60)
    print("QUALIFICATION REPORT")
    print("=" * 60)
    report = agent.get_qualification_report()
    print(json.dumps(report, indent=2))


if __name__ == "__main__":
    run_demo_conversation()

Run it with python run_qualifier.py and you'll see output like this:

sample output
============================================================
REAL ESTATE LEAD QUALIFIER — DEMO RUN
============================================================

👤 Lead:  Hi, I saw your listing on Zillow and I'm interested in buying something in Naples.
🤖 Alex:  Welcome! Naples is a fantastic market right now. Are you looking for a specific type of property — like a single-family home, condo, or something else?

👤 Lead:  I'm looking for a condo, preferably somewhere near the water or a golf course.
  [TOOL] Recorded property type: condo
🤖 Alex:  Great choice — Naples has some beautiful waterfront and golf community condos. Do you have a budget range in mind?

👤 Lead:  My budget is around 600 to 800 thousand dollars.
  [TOOL] Recorded budget: $600,000 - $800,000
🤖 Alex:  That's a solid budget that opens up some really nice options in Pelican Bay, Tiburón, and a few other communities. When are you hoping to make a move?

👤 Lead:  We're hoping to close before the end of the year, maybe November or December.
  [TOOL] Recorded timeline: short_term

  [TOOL] ✅ Lead scored: 9/10 | Priority: HOT
🤖 Alex:  That's very helpful — a year-end close is definitely doable. One of our agents will be in touch with you very shortly to discuss some listings that match exactly what you're looking for. Thank you for reaching out!

============================================================
QUALIFICATION REPORT
============================================================
{
  "status": "complete",
  "report": {
    "score": 9,
    "priority": "hot",
    "summary": "Buyer seeking waterfront or golf community condo in Naples with a $600k-$800k budget and a target close date of November-December.",
    "recommended_action": "Call within 2 hours. Share curated condo listings in Pelican Bay, Tiburon, or Talis Park matching budget. Schedule showing this week.",
    "qualification_data": {
      "property_type": "condo",
      "budget_min": 600000,
      "budget_max": 800000,
      "timeline_category": "short_term"
    }
  }
}

How It Works: Agent Reasoning and Tool Calls

Here's what's actually happening under the hood. When a user sends a message, Claude reads both the conversation history and the tool definitions. It decides in real time whether to respond conversationally, call a tool quietly, or do both at once.

The agentic loop is the key piece. Every time Claude returns a tool_use block, your Python code executes that tool, appends the result as a tool_result message, and calls the API again. Claude sees the tool output and picks up the conversation naturally from there.

The system prompt tells Claude to record data silently — that's why the user never sees "I've recorded your budget." The conversation stays friendly while your backend fills up with structured data. Once all three qualification fields are populated, Claude calls submit_qualification_score and the loop terminates.

Common Errors and Fixes

Error 1: anthropic.AuthenticationError

error message
anthropic.Authentication