← Back to Blog

What You'll Build

If you're tired of manually sorting through leads and trying to figure out which ones are actually worth your time, this tutorial is for you. You're going to build a real estate lead qualifier agent using the Claude API that automatically scores incoming leads, flags the best ones, and decides what follow-up action to take — all in under 200 lines of Python.

By the end, you'll have a working agentic loop that takes raw lead data, runs it through a scoring model, saves qualified leads to a file, and triggers follow-up logic. No LangChain, no bloated frameworks — just the Anthropic SDK and clean Python.

📦 Full Source Code
The complete, working code for this agent is built up step by step in the sections below. Every snippet connects — by the end you'll have one cohesive script you can run immediately. Copy as you go or assemble it all at the end.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one here)
  • anthropic Python SDK installed: pip install anthropic
  • Basic familiarity with Python functions and dictionaries
  • A text editor or IDE (VS Code works great)

Step 1: Set Up Claude API and Authentication

First things first — let's get your environment connected to the Anthropic API. Create a new file called lead_qualifier.py and start with your imports and client setup.

The cleanest way to handle your API key is through an environment variable. Never hardcode it in your script.

lead_qualifier.py
import os
import json
import datetime
import anthropic

# Load your API key from the environment
# Run: export ANTHROPIC_API_KEY="sk-ant-..."
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

MODEL = "claude-sonnet-4-6"

That's it for setup. The anthropic.Anthropic() client will automatically pick up ANTHROPIC_API_KEY from your environment if you don't pass it explicitly — but being explicit is better practice in production.

Step 2: Define Lead Qualification Criteria and Scoring Rules

Before we touch the agent, we need to define what a "qualified" lead actually looks like. In real estate, that usually comes down to budget, timeline, motivation, and pre-approval status. We'll encode those as scoring criteria and pass them to Claude as part of the system prompt.

We also define the lead data structure here so it's consistent throughout the script.

lead_qualifier.py (continued)
QUALIFICATION_CRITERIA = """
You are a real estate lead qualifier for a luxury property agency in Southwest Florida.
Score each lead from 0 to 100 based on the following weighted criteria:

- Budget alignment (30 pts): Does their budget match available inventory ($400K+)?
- Timeline urgency (25 pts): Are they looking to buy within 90 days?
- Pre-approval status (20 pts): Are they pre-approved or paying cash?
- Motivation strength (15 pts): Do they have a clear reason to move (relocation, upgrade, investment)?
- Contact quality (10 pts): Did they provide a phone number and respond to initial outreach?

Scoring thresholds:
- 75-100: HOT lead — immediate follow-up required
- 50-74:  WARM lead — nurture sequence
- 0-49:   COLD lead — add to drip campaign only

Always return structured data. Be direct and decisive.
"""

# Sample lead schema
def create_lead(name, budget, timeline_days, pre_approved, motivation, phone_provided, email_responded):
    return {
        "name": name,
        "budget": budget,
        "timeline_days": timeline_days,
        "pre_approved": pre_approved,
        "motivation": motivation,
        "phone_provided": phone_provided,
        "email_responded": email_responded,
        "submitted_at": datetime.datetime.now().isoformat()
    }

Step 3: Build the Lead Qualifier Agent with Tool Use

Here's where it gets interesting. Claude's tool use (also called function calling) lets the agent take real actions — not just generate text. We're defining three tools: score_lead, save_qualified_lead, and send_followup.

Claude will decide which tools to call and in what order based on the lead data. You're not hardcoding a decision tree — the agent reasons through it.

lead_qualifier.py (continued)
# Tool definitions — Claude will choose when and how to call these
TOOLS = [
    {
        "name": "score_lead",
        "description": (
            "Analyzes a real estate lead and returns a qualification score from 0-100 "
            "along with a tier label (HOT, WARM, COLD) and brief reasoning."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_name": {"type": "string", "description": "Full name of the lead"},
                "score": {"type": "integer", "description": "Qualification score 0-100"},
                "tier": {
                    "type": "string",
                    "enum": ["HOT", "WARM", "COLD"],
                    "description": "Lead tier based on score"
                },
                "reasoning": {"type": "string", "description": "One-sentence explanation of the score"},
                "score_breakdown": {
                    "type": "object",
                    "description": "Points awarded per category",
                    "properties": {
                        "budget": {"type": "integer"},
                        "timeline": {"type": "integer"},
                        "pre_approval": {"type": "integer"},
                        "motivation": {"type": "integer"},
                        "contact_quality": {"type": "integer"}
                    }
                }
            },
            "required": ["lead_name", "score", "tier", "reasoning", "score_breakdown"]
        }
    },
    {
        "name": "save_qualified_lead",
        "description": "Saves a HOT or WARM lead record to the qualified leads file for CRM import.",
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_name": {"type": "string"},
                "score": {"type": "integer"},
                "tier": {"type": "string"},
                "follow_up_priority": {
                    "type": "string",
                    "enum": ["immediate", "within_24h", "this_week"],
                    "description": "When an agent should follow up"
                },
                "notes": {"type": "string", "description": "Key talking points for the follow-up call"}
            },
            "required": ["lead_name", "score", "tier", "follow_up_priority", "notes"]
        }
    },
    {
        "name": "send_followup",
        "description": "Triggers the appropriate follow-up action based on lead tier.",
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_name": {"type": "string"},
                "tier": {"type": "string", "enum": ["HOT", "WARM", "COLD"]},
                "action": {
                    "type": "string",
                    "enum": ["call_now", "send_listing_email", "add_to_drip"],
                    "description": "Specific follow-up action to trigger"
                },
                "message_preview": {
                    "type": "string",
                    "description": "Preview of the outreach message or call script opening"
                }
            },
            "required": ["lead_name", "tier", "action", "message_preview"]
        }
    }
]

Now let's write the Python functions that actually execute when Claude calls each tool. These are your real-world integrations — in production, save_qualified_lead would hit your CRM API and send_followup would trigger an email or SMS.

lead_qualifier.py (continued)
def execute_score_lead(tool_input: dict) -> str:
    """Process and display the lead score from Claude's analysis."""
    result = {
        "status": "scored",
        "lead": tool_input["lead_name"],
        "score": tool_input["score"],
        "tier": tool_input["tier"],
        "reasoning": tool_input["reasoning"],
        "breakdown": tool_input["score_breakdown"]
    }
    print(f"\n  📊 SCORE: {tool_input['score']}/100  |  TIER: {tool_input['tier']}")
    print(f"  💬 {tool_input['reasoning']}")
    print(f"  📋 Breakdown: {tool_input['score_breakdown']}")
    return json.dumps(result)


def execute_save_qualified_lead(tool_input: dict) -> str:
    """Append qualified lead to a local JSON file (swap for CRM API in production)."""
    output_file = "qualified_leads.json"

    # Load existing records or start fresh
    try:
        with open(output_file, "r") as f:
            leads = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        leads = []

    lead_record = {
        **tool_input,
        "saved_at": datetime.datetime.now().isoformat()
    }
    leads.append(lead_record)

    with open(output_file, "w") as f:
        json.dump(leads, f, indent=2)

    print(f"\n  💾 Saved to {output_file} — Priority: {tool_input['follow_up_priority']}")
    print(f"  📝 Notes: {tool_input['notes']}")
    return json.dumps({"status": "saved", "file": output_file})


def execute_send_followup(tool_input: dict) -> str:
    """Simulate triggering a follow-up action (replace with real email/SMS/CRM call)."""
    action_labels = {
        "call_now": "📞 Calling agent queue NOW",
        "send_listing_email": "📧 Sending curated listing email",
        "add_to_drip": "🔄 Adding to 30-day drip campaign"
    }
    label = action_labels.get(tool_input["action"], "Action triggered")
    print(f"\n  {label} for {tool_input['lead_name']}")
    print(f"  💬 Message preview: \"{tool_input['message_preview']}\"")

    return json.dumps({
        "status": "follow_up_triggered",
        "action": tool_input["action"],
        "lead": tool_input["lead_name"]
    })


# Route tool calls to the right function
TOOL_HANDLERS = {
    "score_lead": execute_score_lead,
    "save_qualified_lead": execute_save_qualified_lead,
    "send_followup": execute_send_followup
}

Step 4: Implement the Agentic Loop

The agentic loop is what makes this an agent instead of a one-shot prompt. Claude responds, we check if it wants to use a tool, we execute that tool and feed the result back, and we repeat until Claude says it's done. This is the core pattern behind most production AI agents.

The loop runs until Claude returns a final text response with no more tool calls — that's your signal that the agent finished its work.

lead_qualifier.py (continued)
class RealEstateLeadQualifierAgent:
    def __init__(self):
        self.client = client
        self.model = MODEL
        self.tools = TOOLS
        self.system_prompt = QUALIFICATION_CRITERIA

    def qualify_lead(self, lead: dict) -> str:
        """Run the full qualification workflow for a single lead."""
        print(f"\n{'='*55}")
        print(f"  🏠 Qualifying lead: {lead['name']}")
        print(f"{'='*55}")

        # Format lead data as a clear prompt
        lead_prompt = f"""Please qualify this real estate lead:

Name: {lead['name']}
Budget: ${lead['budget']:,}
Timeline: Looking to buy within {lead['timeline_days']} days
Pre-approved: {'Yes' if lead['pre_approved'] else 'No'}
Motivation: {lead['motivation']}
Phone provided: {'Yes' if lead['phone_provided'] else 'No'}
Responded to email: {'Yes' if lead['email_responded'] else 'No'}
Submitted: {lead['submitted_at']}

Score this lead, save them if they are HOT or WARM, and trigger the appropriate follow-up action."""

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

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

            # Append Claude's response to the message history
            messages.append({"role": "assistant", "content": response.content})

            # Check if Claude wants to call a tool
            if response.stop_reason == "tool_use":
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        print(f"\n  🔧 Tool called: {block.name}")
                        handler = TOOL_HANDLERS.get(block.name)

                        if handler:
                            result = handler(block.input)
                        else:
                            result = json.dumps({"error": f"Unknown tool: {block.name}"})

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

                # Feed all tool results back into the conversation
                messages.append({"role": "user", "content": tool_results})

            else:
                # Claude is done — extract the final text summary
                final_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    "Qualification complete."
                )
                print(f"\n  ✅ Agent summary: {final_text}")
                return final_text

Step 5: Test with Sample Lead Data

Let's wire everything together and run the agent against three sample leads — one hot, one warm, one cold. This is exactly how you'd test before pointing this at a live lead source like a Typeform webhook or a CRM integration.

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

    # Three test leads with varying qualification levels
    test_leads = [
        create_lead(
            name="Sandra Kowalski",
            budget=875000,
            timeline_days=45,
            pre_approved=True,
            motivation="Relocating from Chicago for work, needs to close before school year",
            phone_provided=True,
            email_responded=True
        ),
        create_lead(
            name="Marcus Webb",
            budget=520000,
            timeline_days=120,
            pre_approved=False,
            motivation="Looking to upgrade from current condo, not in a rush",
            phone_provided=True,
            email_responded=False
        ),
        create_lead(
            name="Tyler Bruns",
            budget=180000,
            timeline_days=365,
            pre_approved=False,
            motivation="Just browsing, thinking about it eventually",
            phone_provided=False,
            email_responded=False
        )
    ]

    results = []
    for lead in test_leads:
        summary = agent.qualify_lead(lead)
        results.append({"lead": lead["name"], "summary": summary})

    print(f"\n{'='*55}")
    print(f"  🎯 Processed {len(test_leads)} leads. Check qualified_leads.json for saved records.")
    print(f"{'='*55}\n")


if __name__ == "__main__":
    main()

Run it with python lead_qualifier.py and you should see output like this:

Sample Output
=======================================================
  🏠 Qualifying lead: Sandra Kowalski
=======================================================

  🔧 Tool called: score_lead

  📊 SCORE: 92/100  |  TIER: HOT
  💬 Pre-approved buyer with urgent relocation timeline and strong budget alignment.
  📋 Breakdown: {'budget': 30, 'timeline': 25, 'pre_approval': 20, 'motivation': 12, 'contact_quality': 5}

  🔧 Tool called: save_qualified_lead

  💾 Saved to qualified_leads.json — Priority: immediate
  📝 Notes: Mention school-year deadline. Lead has full pre-approval. Send Port Royal and Pelican Bay listings first.

  🔧 Tool called: send_followup

  📞 Calling agent queue NOW for Sandra Kowalski
  💬 Message preview: "Hi Sandra, I saw you're relocating from Chicago — I have two properties in top school districts that just came available..."

  ✅ Agent summary: Sandra Kowalski is a HOT lead (92/100). She is pre-approved, has a hard deadline, and a budget well above inventory minimums. I've saved her record with immediate priority and queued a phone call.

=======================================================
  🏠 Qualifying lead: Marcus Webb
=======================================================

  🔧 Tool called: score_lead

  📊 SCORE: 58/100  |  TIER: WARM
  💬 Solid budget but no pre-approval and a loose timeline — worth nurturing.
  📋 Breakdown: {'budget': 28, 'timeline': 10, 'pre_approval': 0, 'motivation': 12, 'contact_quality': 8}

  🔧 Tool called: save_qualified_lead

  💾 Saved to qualified_leads.json — Priority: within_24h
  📝 Notes: Send condo-to-single-family upgrade content. Recommend pre-approval checklist. Follow up in 24 hours.

  🔧 Tool called: send_followup

  📧 Sending curated listing email for Marcus Webb
  💬 Message preview: "Hi Marcus, based on what you shared, here are 3 condos in Naples that could be a great upgrade from your current place..."

  ✅ Agent summary: Marcus Webb is a WARM lead (58/100). Budget qualifies but no pre-approval and no urgency. Saved with 24-hour follow-up priority and triggered a listing email.

=======================================================
  🏠 Qualifying lead: Tyler Bruns
=======================================================

  🔧 Tool called: score_lead

  📊 SCORE: 14/100  |  TIER: COLD
  💬 Budget is well below inventory minimums and timeline is indefinite with no contact commitment.
  📋 Breakdown: {'budget': 0, 'timeline': 2, 'pre_approval': 0, 'motivation': 5, 'contact_quality': 7}

  🔧 Tool called: send_followup

  🔄 Adding to 30-day drip campaign for Tyler Bruns
  💬 Message preview: "Welcome! We'll send you monthly market updates for Naples real estate so you're ready when the time is right..."

  ✅ Agent summary: Tyler Bruns is a COLD lead (14/100). Budget doesn't align with current inventory and there's no urgency or commitment. Added to drip campaign only — no agent time allocated.

=======================================================
  🎯 Processed 3 leads. Check qualified_leads.json for saved records.
=======================================================

How It Works

Claude doesn't just answer a question here — it reasons through a workflow. When you send the lead data and the available tools, Claude decides what to do next based on the scoring criteria in the system prompt.

For a HOT lead, it calls all three tools in sequence: score, save, and send a follow-up. For a COLD lead, it skips saving entirely and goes straight to the drip campaign action. That's the agent making a real decision — not you writing an if-else tree.

The agentic loop keeps the conversation going by feeding tool results back into the message history. Claude sees the result of each tool call and uses that context to decide what to do next. When it has nothing left to do, it returns a plain text summary and the loop ends.

Common Errors and Fixes

Error 1: AuthenticationError — API key not found

anthropic.AuthenticationError: 401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}

Fix: Your API key isn't making it into the script. Run export ANTHROPIC_API_KEY="sk-ant-your-key-here" in the same terminal session before running the script. If you're on Windows, use set ANTHROPIC_API_KEY=sk-ant-your-key-here. Double-check there are no extra spaces or quotes in the value.

Error 2: ValidationError — tool input doesn't match schema

anthropic.BadRequestError: 400