← Back to Blog

What You'll Build

If you've ever watched a real estate team manually sort through dozens of inbound leads trying to figure out who's actually serious, you know how much time that wastes. In this tutorial, you'll build a Python-based AI lead qualifier that holds a multi-turn conversation with a prospect, extracts key qualification signals, and outputs a scored ranking — all using the Claude API.

By the end, you'll have a production-ready agent that asks about budget, timeline, and location preferences, then classifies leads as hot, warm, or cold with a numeric score. No machine learning background required — just Python and an Anthropic API key.

📦 Full Source Code
The complete, working code for this project is broken into steps below. Each snippet builds on the last, so by the time you reach Step 4, you'll have a fully functional lead qualifier. Copy the pieces together into one file as you go, or grab the full combined version from Step 4.

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)
  • A .env file or environment variable set for ANTHROPIC_API_KEY

Step 1: Set Up the Claude API Client and Authentication

First things first — let's get the Anthropic client initialized and make sure authentication is working before we write a single line of agent logic. This keeps the setup isolated and easy to debug.

Create a new file called lead_qualifier.py and start with this:

lead_qualifier.py
import os
import json
from anthropic import Anthropic

# Load API key from environment — never hardcode this
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")

if not ANTHROPIC_API_KEY:
    raise EnvironmentError(
        "ANTHROPIC_API_KEY not found. Set it in your environment or .env file."
    )

client = Anthropic(api_key=ANTHROPIC_API_KEY)

# Quick sanity check — confirms auth works before building anything on top of it
def test_connection():
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=64,
        messages=[{"role": "user", "content": "Say 'API connected' and nothing else."}]
    )
    print(response.content[0].text)

if __name__ == "__main__":
    test_connection()

Run this with python lead_qualifier.py. You should see API connected printed to your terminal. If you get an authentication error, double-check that your environment variable is set correctly in the same shell session you're running Python from.

💡 Tip: Use python-dotenv to load a .env file automatically. Just run pip install python-dotenv and add from dotenv import load_dotenv; load_dotenv() at the top of your file before reading the environment variable.

Step 2: Define Lead Qualification Criteria and Tool Schemas

This is where things get interesting. Instead of parsing free-form text with regex or if-statements, we're going to give Claude structured tools it can call when it collects a qualifying data point. Think of tools as forms that Claude fills out as the conversation progresses.

We need three core qualification signals for real estate: budget, timeline, and target location. Here's how we define them as tool schemas the Anthropic SDK understands:

lead_qualifier.py (add below the imports)
import os
import json
from anthropic import Anthropic

ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
if not ANTHROPIC_API_KEY:
    raise EnvironmentError("ANTHROPIC_API_KEY not found.")

client = Anthropic(api_key=ANTHROPIC_API_KEY)

# Tool definitions — these are the structured signals our agent extracts
QUALIFICATION_TOOLS = [
    {
        "name": "record_budget",
        "description": (
            "Call this tool when the prospect has stated or clearly implied "
            "their purchase budget for a property. Extract the numeric range."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "min_budget": {
                    "type": "number",
                    "description": "Minimum budget in USD"
                },
                "max_budget": {
                    "type": "number",
                    "description": "Maximum budget in USD"
                },
                "budget_confidence": {
                    "type": "string",
                    "enum": ["firm", "flexible", "uncertain"],
                    "description": "How confident the prospect seems about their budget"
                }
            },
            "required": ["min_budget", "max_budget", "budget_confidence"]
        }
    },
    {
        "name": "record_timeline",
        "description": (
            "Call this tool when the prospect has indicated when they want "
            "to buy or move. Capture urgency so we can score lead priority."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "timeline_months": {
                    "type": "number",
                    "description": "How many months until the prospect wants to complete a purchase"
                },
                "is_pre_approved": {
                    "type": "boolean",
                    "description": "Whether the prospect has mortgage pre-approval"
                }
            },
            "required": ["timeline_months", "is_pre_approved"]
        }
    },
    {
        "name": "record_location",
        "description": (
            "Call this tool when the prospect mentions specific neighborhoods, "
            "cities, or geographic preferences for their property search."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "preferred_areas": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of neighborhoods, cities, or zip codes the prospect mentioned"
                },
                "flexibility": {
                    "type": "string",
                    "enum": ["specific", "somewhat_flexible", "open"],
                    "description": "How flexible the prospect is on location"
                }
            },
            "required": ["preferred_areas", "flexibility"]
        }
    },
    {
        "name": "finalize_qualification",
        "description": (
            "Call this tool when you have collected enough information to "
            "make a qualification decision. Use this to end the conversation."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "ready_to_qualify": {
                    "type": "boolean",
                    "description": "Set to true when qualification data is complete"
                },
                "summary": {
                    "type": "string",
                    "description": "Brief summary of what was learned about this lead"
                }
            },
            "required": ["ready_to_qualify", "summary"]
        }
    }
]

Each tool maps to a real qualification signal. Claude will call these automatically mid-conversation when the prospect says something that satisfies the trigger condition in the description field. You don't have to write any parsing logic — the model handles extraction for you.

Step 3: Build the Agent Loop with Multi-Turn Conversations

Now we'll build the actual agent class. The key design here is an agentic loop — a pattern where we keep sending messages back to Claude until it either runs out of tools to call or decides the conversation is done. This is what makes it an agent rather than just a single API call.

The loop also maintains conversation history so Claude remembers what was said earlier in the chat, which is critical for natural qualification conversations.

lead_qualifier.py (add the agent class)
class RealEstateLeadQualifier:
    """
    Conversational AI agent that qualifies real estate leads
    by extracting budget, timeline, and location via tool use.
    """

    SYSTEM_PROMPT = """You are a friendly real estate intake specialist for a Southwest Florida agency.
Your job is to have a natural conversation with prospective buyers and sellers to understand their needs.

Ask about:
1. Their budget range for a property purchase
2. Their desired timeline to buy or move
3. Which neighborhoods or areas in Southwest Florida interest them

Keep the conversation warm and conversational — not like an interrogation.
Ask one question at a time. When you've gathered budget, timeline, and location data,
call the finalize_qualification tool to wrap up the conversation.
Use the recording tools (record_budget, record_timeline, record_location) as soon as
the prospect provides that information — even mid-conversation."""

    def __init__(self):
        self.client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.conversation_history = []
        self.collected_data = {
            "budget": None,
            "timeline": None,
            "location": None,
            "summary": None
        }
        self.qualification_complete = False

    def _process_tool_call(self, tool_name: str, tool_input: dict) -> str:
        """Handle tool calls from Claude and store the extracted data."""
        if tool_name == "record_budget":
            self.collected_data["budget"] = tool_input
            return f"Budget recorded: ${tool_input['min_budget']:,} - ${tool_input['max_budget']:,} ({tool_input['budget_confidence']})"

        elif tool_name == "record_timeline":
            self.collected_data["timeline"] = tool_input
            pre_approved = "pre-approved" if tool_input["is_pre_approved"] else "not pre-approved"
            return f"Timeline recorded: {tool_input['timeline_months']} months, {pre_approved}"

        elif tool_name == "record_location":
            self.collected_data["location"] = tool_input
            areas = ", ".join(tool_input["preferred_areas"])
            return f"Location recorded: {areas} (flexibility: {tool_input['flexibility']})"

        elif tool_name == "finalize_qualification":
            self.collected_data["summary"] = tool_input["summary"]
            self.qualification_complete = tool_input["ready_to_qualify"]
            return "Qualification data finalized."

        return "Tool executed."

    def chat(self, user_message: str) -> str:
        """
        Send a message and run the agentic loop until Claude
        either responds to the user or finishes qualifying.
        """
        # Add user message to history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        # Agentic loop — keeps running while Claude wants to use tools
        while True:
            response = self.client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1024,
                system=self.SYSTEM_PROMPT,
                tools=QUALIFICATION_TOOLS,
                messages=self.conversation_history
            )

            # Check if Claude is done or wants to use a tool
            if response.stop_reason == "end_turn":
                # Claude sent a message to the user — extract text and return it
                assistant_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    ""
                )
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })
                return assistant_text

            elif response.stop_reason == "tool_use":
                # Claude called one or more tools — process each one
                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
                        })

                # Add Claude's tool call message and our results back to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })

                # If qualification is complete, we can break out of the loop
                if self.qualification_complete:
                    # One final call to get Claude's closing message
                    final_response = self.client.messages.create(
                        model="claude-sonnet-4-6",
                        max_tokens=256,
                        system=self.SYSTEM_PROMPT,
                        tools=QUALIFICATION_TOOLS,
                        messages=self.conversation_history
                    )
                    closing_text = next(
                        (block.text for block in final_response.content if hasattr(block, "text")),
                        "Thank you for your time! An agent will follow up shortly."
                    )
                    return closing_text

                # Otherwise loop back and let Claude continue the conversation
                continue

            else:
                # Unexpected stop reason — bail out gracefully
                return "I'm sorry, something went wrong. Please try again."

The loop is the heart of the whole thing. Every time Claude decides to call a tool, we process it, feed the result back, and let Claude keep going. This is exactly how production agentic systems work — you're not just calling an LLM once, you're orchestrating a back-and-forth that mimics how a real intake agent would think through a conversation.

Step 4: Implement Scoring Logic and Lead Ranking

Now we tie everything together. Once the conversation is complete and we have structured data from the tools, we run a scoring function that turns the collected signals into a numeric score and a classification.

Here's the complete file with scoring logic and a demo runner so you can see it all working end to end:

lead_qualifier.py (complete file)
import os
import json
from anthropic import Anthropic

ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
if not ANTHROPIC_API_KEY:
    raise EnvironmentError("ANTHROPIC_API_KEY not found.")

QUALIFICATION_TOOLS = [
    {
        "name": "record_budget",
        "description": (
            "Call this tool when the prospect has stated or clearly implied "
            "their purchase budget for a property. Extract the numeric range."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "min_budget": {"type": "number", "description": "Minimum budget in USD"},
                "max_budget": {"type": "number", "description": "Maximum budget in USD"},
                "budget_confidence": {
                    "type": "string",
                    "enum": ["firm", "flexible", "uncertain"],
                    "description": "How confident the prospect seems about their budget"
                }
            },
            "required": ["min_budget", "max_budget", "budget_confidence"]
        }
    },
    {
        "name": "record_timeline",
        "description": (
            "Call this tool when the prospect has indicated when they want "
            "to buy or move. Capture urgency to score lead priority."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "timeline_months": {
                    "type": "number",
                    "description": "How many months until the prospect wants to complete a purchase"
                },
                "is_pre_approved": {
                    "type": "boolean",
                    "description": "Whether the prospect has mortgage pre-approval"
                }
            },
            "required": ["timeline_months", "is_pre_approved"]
        }
    },
    {
        "name": "record_location",
        "description": (
            "Call this tool when the prospect mentions specific neighborhoods, "
            "cities, or geographic preferences for their property search."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "preferred_areas": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of neighborhoods, cities, or zip codes mentioned"
                },
                "flexibility": {
                    "type": "string",
                    "enum": ["specific", "somewhat_flexible", "open"],
                    "description": "How flexible the prospect is on location"
                }
            },
            "required": ["preferred_areas", "flexibility"]
        }
    },
    {
        "name": "finalize_qualification",
        "description": (
            "Call this tool when you have collected enough information to "
            "make a qualification decision. Use this to end the conversation."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "ready_to_qualify": {
                    "type": "boolean",
                    "description": "Set to true when qualification data is complete"
                },
                "summary": {
                    "type": "string",
                    "description": "Brief summary of what was learned about this lead"
                }
            },
            "required": ["ready_to_qualify", "summary"]
        }
    }
]


class RealEstateLeadQualifier:
    SYSTEM_PROMPT = """You are a friendly real estate intake specialist for a Southwest Florida agency.
Your job is to have a natural conversation with prospective buyers and sellers to understand their needs.

Ask about:
1. Their budget range for a property purchase
2. Their desired timeline to buy or move
3. Which neighborhoods or areas in Southwest Florida interest them

Keep the conversation warm and conversational — not like an interrogation.
Ask one question at a time. When you've gathered budget, timeline, and location data,
call the finalize_qualification tool to wrap up the conversation.
Use the recording tools (record_budget, record_timeline, record_location) as soon as
the prospect provides that information — even mid-conversation."""

    def __init__(self):
        self.client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.conversation_history = []
        self.collected_data = {
            "budget": None,
            "timeline": None,
            "location": None,
            "summary": None
        }
        self.qualification_complete = False

    def _process_tool_call(self, tool_name: str, tool_input: dict) -> str:
        if tool_name == "record_budget":
            self.collected_data["budget"] = tool_input
            return f"Budget recorded: ${tool_input['min_budget']:,} - ${tool_input['max_budget']:,}"
        elif tool_name == "record_timeline":
            self.collected_data["timeline"] = tool_input
            return f"Timeline recorded: {tool_input['timeline_months']} months"
        elif tool_name == "record_location":
            self.collected_data["location"] = tool_input
            return f"Location recorded: {', '.join(tool_input['preferred_areas'])}"
        elif tool_name == "finalize_qualification":
            self.collected_data["summary"] = tool_input["summary"]
            self.qualification_complete = tool_input["ready_to_qualify"]
            return "Qualification finalized."
        return "Tool executed."

    def chat(self, user_message: str) -> str:
        self.conversation_history.append({"role": "user", "content": user_message})

        while True:
            response = self.client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1024,
                system=self.SYSTEM_PROMPT,
                tools=QUALIFICATION_TOOLS,
                messages=self.conversation_history
            )

            if response.stop_reason == "end_turn":
                assistant_text = next(
                    (block.text for block in response.content if hasattr(block, "text")), ""
                )
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })
                return assistant_text

            elif 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
                        })

                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })

                if self.qualification_complete:
                    final_response = self.client.messages.create(
                        model="claude-sonnet-4-6",
                        max_tokens=256,
                        system=self.SYSTEM_PROMPT,
                        tools=QUALIFICATION_TOOLS,
                        messages=self.conversation_history
                    )
                    return next(
                        (block.text for block in final_response.content if hasattr(block, "text")),
                        "Thank you! An agent will follow up shortly."
                    )
                continue

            else:
                return "Something went wrong. Please try again."

    def score_lead(self) -> dict:
        """
        Score the lead on a 0-100 scale based on collected qualification data.
        Returns a dict with score, classification, and reasoning.
        """
        score = 0
        reasons = []

        # Budget scoring (max 40 points)
        budget = self.collected_data.get("budget")
        if budget:
            max_b = budget.get("max_budget", 0)
            confidence = budget.get("budget_confidence", "uncertain")

            if max_b >= 500000:
                score += 30
                reasons.append("High budget ($500K+)")
            elif max_b >= 300000:
                score += 20
                reasons.append("Mid-