← Back to Blog

What You'll Build

If you've been searching for how to build AI agents that actually do something useful, this is the tutorial you want. We're going to build a production-ready AI lead qualifier in Python that takes raw real estate lead data, asks smart follow-up questions, and scores each lead on a 1–10 scale so your sales team knows exactly who to call first.

By the end, you'll have roughly 150 lines of Python using the Anthropic SDK that handles multi-turn conversations, structured tool calls, and a ranked output you can pipe straight into a CRM. I've built versions of this for actual Naples-area real estate teams — this is the real thing, not a demo.

📦 Full Source Code Note: The complete working code is broken into logical steps below. Each snippet builds on the last. By Step 5 you'll have the entire working agent — just copy the sections in order into a single file called lead_qualifier.py.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key — get one at console.anthropic.com
  • Basic comfort with Python classes and dictionaries
  • pip install anthropic run in your environment
  • No prior experience with AI agents required — I'll explain each piece

Step 1: Set Up the Claude API Client and Authentication

First thing we need is a working connection to Claude. The Anthropic SDK makes this dead simple — you pass your API key and you're done. I like to load it from an environment variable so the key never ends up in source control.

Here's the base file setup with the client initialization and the main agent class skeleton we'll fill in over the next steps.

lead_qualifier.py
import os
import json
from anthropic import Anthropic

# Load API key from environment variable (never hardcode this)
client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

MODEL = "claude-sonnet-4-6"

class LeadQualifierAgent:
    """
    AI agent that qualifies real estate leads using Claude's tool-use API.
    Handles multi-turn conversation and produces a scored lead report.
    """

    def __init__(self):
        self.client = client
        self.conversation_history = []
        self.lead_data = {}
        self.qualification_score = None

    def reset(self):
        """Clear state between leads so scores don't bleed across runs."""
        self.conversation_history = []
        self.lead_data = {}
        self.qualification_score = None

Set your key in the terminal before running: export ANTHROPIC_API_KEY="sk-ant-...". If you're on Windows, use set ANTHROPIC_API_KEY=sk-ant-... in Command Prompt. That's all the auth you need.

Step 2: Define Lead Qualification Tools and Schemas

This is where the agent gets its "hands." Claude's tool-use feature lets you define functions that the model can call when it needs to take a specific action — like recording a lead's budget or triggering a score calculation. Think of tools as the only actions the agent is allowed to take outside of talking.

We're defining three tools: one to capture lead details, one to ask a clarifying question, and one to produce the final qualification score. The schemas tell Claude exactly what fields each tool expects.

lead_qualifier.py (continued)
    def get_tools(self):
        """
        Returns the tool definitions Claude uses to structure its actions.
        Each tool maps to a method in this class via dispatch in run_agent().
        """
        return [
            {
                "name": "record_lead_details",
                "description": (
                    "Record structured information about a real estate lead. "
                    "Call this whenever you learn something concrete about the lead's "
                    "budget, timeline, property type, location, or motivation."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "budget_min": {
                            "type": "number",
                            "description": "Minimum budget in USD, e.g. 400000"
                        },
                        "budget_max": {
                            "type": "number",
                            "description": "Maximum budget in USD, e.g. 700000"
                        },
                        "timeline_months": {
                            "type": "number",
                            "description": "How many months until the lead wants to close"
                        },
                        "property_type": {
                            "type": "string",
                            "enum": ["single_family", "condo", "townhouse", "land", "commercial", "unknown"]
                        },
                        "location_preference": {
                            "type": "string",
                            "description": "Preferred neighborhood or city, e.g. 'Naples, FL downtown'"
                        },
                        "motivation": {
                            "type": "string",
                            "enum": ["primary_residence", "investment", "vacation_home", "relocation", "unknown"]
                        },
                        "pre_approved": {
                            "type": "boolean",
                            "description": "Whether the lead has mortgage pre-approval"
                        },
                        "agent_engaged": {
                            "type": "boolean",
                            "description": "Whether the lead is already working with another agent"
                        }
                    },
                    "required": []
                }
            },
            {
                "name": "ask_clarifying_question",
                "description": (
                    "Ask the lead a single focused follow-up question to gather "
                    "missing qualification data. Only ask one question at a time."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "question": {
                            "type": "string",
                            "description": "The question to ask the lead"
                        },
                        "field_targeting": {
                            "type": "string",
                            "description": "Which qualification field this question is trying to fill",
                            "enum": ["budget", "timeline", "property_type", "location", "motivation", "pre_approval", "agent_status"]
                        }
                    },
                    "required": ["question", "field_targeting"]
                }
            },
            {
                "name": "submit_qualification_score",
                "description": (
                    "Call this when you have enough information to score the lead. "
                    "Submit a score from 1-10 with a brief justification and recommended action."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "score": {
                            "type": "number",
                            "description": "Lead quality score from 1 (cold) to 10 (hot, ready to buy)"
                        },
                        "tier": {
                            "type": "string",
                            "enum": ["hot", "warm", "cold"],
                            "description": "hot=8-10, warm=5-7, cold=1-4"
                        },
                        "justification": {
                            "type": "string",
                            "description": "2-3 sentence plain-English explanation of the score"
                        },
                        "recommended_action": {
                            "type": "string",
                            "description": "What the agent should do next, e.g. 'Call within 24 hours'"
                        },
                        "missing_info": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "List of fields still unknown that would improve the score accuracy"
                        }
                    },
                    "required": ["score", "tier", "justification", "recommended_action"]
                }
            }
        ]

Three tools keeps this clean. In real deployments I sometimes add a fourth tool to push the scored lead directly into a CRM via API — that's covered in Next Steps below.

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

This is the heart of how to build AI agents — the agentic loop. The idea is simple: send a message to Claude, check if it wants to use a tool, execute that tool, send the result back, and repeat until Claude is done. The loop keeps running until Claude calls submit_qualification_score or we hit a max-turn limit.

The tool dispatch pattern here is clean and easy to extend. Each tool name maps directly to a method on the class, so adding a new tool is just adding a new method and one line in the dispatch dictionary.

lead_qualifier.py (continued)
    def record_lead_details(self, inputs: dict) -> str:
        """Store lead details — called when Claude uses the record_lead_details tool."""
        self.lead_data.update(inputs)
        saved_fields = list(inputs.keys())
        return f"Recorded: {', '.join(saved_fields)}"

    def ask_clarifying_question(self, inputs: dict) -> str:
        """Return the question text so it shows up in the conversation output."""
        return f"Question posed to lead: {inputs['question']}"

    def submit_qualification_score(self, inputs: dict) -> str:
        """Store the final score and signal the loop to stop."""
        self.qualification_score = inputs
        return "Score submitted. Qualification complete."

    def dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
        """
        Routes Claude's tool call to the correct method.
        Returns a string result that gets sent back to Claude as a tool result.
        """
        dispatch_map = {
            "record_lead_details": self.record_lead_details,
            "ask_clarifying_question": self.ask_clarifying_question,
            "submit_qualification_score": self.submit_qualification_score,
        }
        handler = dispatch_map.get(tool_name)
        if handler is None:
            return f"Error: Unknown tool '{tool_name}'"
        return handler(tool_input)

    def run_agent(self, lead_description: str, max_turns: int = 10) -> dict:
        """
        Main agentic loop. Feeds the lead description to Claude and loops
        until qualification_score is populated or max_turns is reached.
        """
        self.reset()

        system_prompt = """You are an expert real estate lead qualification agent for a Southwest Florida real estate team.

Your job is to analyze incoming lead information, ask targeted follow-up questions when needed, and produce an accurate lead score.

Follow this process:
1. First, call record_lead_details with everything you can extract from the initial description.
2. If critical information is missing (budget, timeline, motivation), call ask_clarifying_question once per missing field.
3. Once you have enough to score confidently (or after 3 clarifying questions), call submit_qualification_score.

Scoring guide:
- Score 8-10 (hot): Pre-approved, clear timeline under 3 months, specific location/property in mind, motivated buyer
- Score 5-7 (warm): Some info missing, timeline 3-6 months, or not yet pre-approved but financially capable
- Score 1-4 (cold): No clear timeline, just browsing, unrealistic expectations, or already agent-committed

Always be concise and professional."""

        # Seed the conversation with the lead description
        self.conversation_history.append({
            "role": "user",
            "content": f"Please qualify this real estate lead:\n\n{lead_description}"
        })

        for turn in range(max_turns):
            response = self.client.messages.create(
                model=MODEL,
                max_tokens=1024,
                system=system_prompt,
                tools=self.get_tools(),
                messages=self.conversation_history
            )

            # Add Claude's full response to history for multi-turn context
            self.conversation_history.append({
                "role": "assistant",
                "content": response.content
            })

            # Check if Claude is done (no tool calls = final text response)
            if response.stop_reason == "end_turn":
                break

            # Process all tool calls in this response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = self.dispatch_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result
                    })

                    # Stop looping as soon as the score is submitted
                    if block.name == "submit_qualification_score":
                        return self._build_output(lead_description)

            # Send all tool results back to Claude in one message
            if tool_results:
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })

        return self._build_output(lead_description)

    def _build_output(self, lead_description: str) -> dict:
        """Packages everything into a clean dict for the caller."""
        return {
            "original_lead": lead_description,
            "extracted_details": self.lead_data,
            "qualification_result": self.qualification_score,
            "turns_taken": len([m for m in self.conversation_history if m["role"] == "assistant"])
        }

Step 4: Implement Scoring Logic and Lead Ranking

The scoring lives inside Claude's reasoning (driven by the system prompt), but we need a thin wrapper that can process a batch of leads and sort them by score. This is what makes the tool useful in production — you feed in a list of leads and get back a ranked report.

I also add a simple batch runner here with a pretty-print output function. Nothing fancy, but it's what you'd actually use when testing with a new dataset.

lead_qualifier.py (continued)
def qualify_lead_batch(leads: list[str]) -> list[dict]:
    """
    Qualifies a list of lead descriptions and returns them sorted by score.
    Instantiates a fresh agent per lead to ensure clean state.
    """
    agent = LeadQualifierAgent()
    results = []

    for i, lead in enumerate(leads):
        print(f"\nQualifying lead {i + 1} of {len(leads)}...")
        result = agent.run_agent(lead)
        results.append(result)

    # Sort descending by score (unscored leads go to the bottom)
    results.sort(
        key=lambda r: r["qualification_result"]["score"] if r["qualification_result"] else 0,
        reverse=True
    )
    return results


def print_lead_report(results: list[dict]) -> None:
    """Prints a human-readable ranked report to the console."""
    print("\n" + "=" * 60)
    print("LEAD QUALIFICATION REPORT — Ranked by Score")
    print("=" * 60)

    for rank, result in enumerate(results, start=1):
        qr = result.get("qualification_result")
        if not qr:
            continue

        tier_emoji = {"hot": "🔥", "warm": "🌡️", "cold": "❄️"}.get(qr.get("tier", "cold"), "")
        print(f"\nRank #{rank} {tier_emoji}  Score: {qr['score']}/10  |  Tier: {qr['tier'].upper()}")
        print(f"Lead: {result['original_lead'][:80]}...")
        print(f"Justification: {qr['justification']}")
        print(f"Action: {qr['recommended_action']}")

        details = result.get("extracted_details", {})
        if details:
            print(f"Extracted data: {json.dumps(details, indent=2)}")

        missing = qr.get("missing_info", [])
        if missing:
            print(f"Missing info: {', '.join(missing)}")

        print("-" * 60)

Step 5: Test with Real Estate Lead Examples

Now let's wire it all up with three realistic lead examples that represent the range you'd actually see — a hot buyer, a warm prospect, and a cold tire-kicker. Run this and you'll see the agent reason through each one, use its tools, and produce a ranked output.

lead_qualifier.py (continued)
if __name__ == "__main__":

    # Three realistic Southwest Florida real estate leads
    sample_leads = [
        """
        Name: Jennifer Marsh
        Source: Website contact form
        Message: Hi, my husband and I are relocating from Chicago to Naples for his new job
        starting July 1st. We need to find a 3-bed, 2-bath single family home in a good school
        district. Our budget is $550,000-$650,000 and we've already been pre-approved by our
        bank. We'd like to close by June 15th if possible. We're not working with an agent yet.
        """,
        """
        Name: Robert Tanner
        Source: Zillow inquiry
        Message: Interested in condos in the Naples area, maybe around $400k range. Looking
        for something near the beach, could be a vacation place or maybe we'd move there
        eventually. Not in a huge rush, just seeing what's out there. My wife and I are
        talking about it. Haven't looked into financing yet.
        """,
        """
        Name: Dave K.
        Source: Facebook ad click
        Message: Just browsing. What's the market like in Naples? Are prices going up or down?
        Might buy something someday. Send me some listings.
        """
    ]

    results = qualify_lead_batch(sample_leads)
    print_lead_report(results)

Here's the actual output this produces when you run it:

Sample Output
Qualifying lead 1 of 3...
Qualifying lead 2 of 3...
Qualifying lead 3 of 3...

============================================================
LEAD QUALIFICATION REPORT — Ranked by Score
============================================================

Rank #1 🔥  Score: 9/10  |  Tier: HOT
Lead: Name: Jennifer Marsh  Source: Website contact form  Message: Hi, my husb...
Justification: Jennifer is pre-approved, has a hard move deadline of June 15th,
a clear budget range of $550k-$650k, and a specific property type in mind. The
only minor gap is confirming school district priorities.
Action: Call within 24 hours. Book a showing tour for next weekend. Send 3-5
listings in top Naples school zones today.
Extracted data: {
  "budget_min": 550000,
  "budget_max": 650000,
  "timeline_months": 2,
  "property_type": "single_family",
  "location_preference": "Naples, FL - good school district",
  "motivation": "relocation",
  "pre_approved": true,
  "agent_engaged": false
}
------------------------------------------------------------

Rank #2 🌡️  Score: 6/10  |  Tier: WARM
Lead: Name: Robert Tanner  Source: Zillow inquiry  Message: Interested in condos...
Justification: Robert has a rough budget and clear property type preference, but
no financing in place and an undefined timeline. The "vacation or move eventually"
framing suggests low urgency. Worth nurturing.
Action: Send a market report and 2-3 beachfront condo listings. Follow up in 2
weeks. Add to monthly email drip.
Extracted data: {
  "budget_max": 400000,
  "property_type": "condo",
  "location_preference": "Naples, FL near beach",
  "motivation": "vacation_home",
  "pre_approved": false
}
Missing info: timeline_months, budget_min, agent_engaged
------------------------------------------------------------

Rank #3 ❄️  Score: 2/10  |  Tier: COLD
Lead: Name: Dave K.  Source: Facebook ad click  Message: Just browsing. What's...
Justification: No budget, no timeline, no specific property interest, and no
stated motivation. "Might buy someday" is the clearest signal here. Not a sales
priority at this time.
Action: Add to top-of-funnel email list. Send a Naples market overview PDF.
Revisit if they re-engage within 90 days.
Missing info: budget, timeline_months, property_type, location, motivation, pre_approval
------------------------------------------------------------

How It Works

The agent loop is the key concept. Every time you call run_agent(), Claude receives the lead description and the tool definitions. Claude then decides which tool to call first — usually record_lead_details to extract what it already knows, then ask_clarifying_question for any gaps, and finally submit_qualification_score when it has enough data.

The conversation history array is what makes this multi-turn. Every message — including tool calls and their results — gets appended to the history before the next API call. Claude sees the full context each time, so it knows what it's already asked and what it still needs.

The tool dispatch pattern in dispatch_tool() is intentionally simple. The dictionary maps tool names to methods, so Claude can never call something you haven't explicitly defined. That's an important guardrail when building production agents.

💡 Why tool use instead of just prompting for JSON? You could ask Claude to respond with a JSON score directly, but tool use gives you structured, validated inputs every time. Claude can't hallucinate a field that isn't in the schema — the SDK enforces it.

Common Errors and Fixes

Error 1: AuthenticationError on startup

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