← Back to Blog

If you've been trying to figure out how to build AI agents that actually do useful work — not just answer questions, but take action — this tutorial is for you. We're going to build a real estate lead qualifier agent in Python using the Claude API that scores inbound leads, matches them to property types, and decides whether to fast-track or nurture each prospect.

This is the kind of tool that used to require a full CRM workflow, a VA, and hours of manual review. You'll build it in under 200 lines of Python. Let's get into it.

What You'll Build

You'll build a conversational AI agent that takes raw lead data — name, budget, timeline, property preferences — and runs it through a structured qualification workflow powered by Claude's tool use API. The agent scores each lead from 0 to 100, flags high-priority prospects, and outputs a structured recommendation for your sales team.

The whole thing runs from the command line and produces clean JSON output you can pipe into any CRM, spreadsheet, or follow-up automation. No frontend required.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and dictionaries
  • anthropic Python SDK installed (pip install anthropic)
  • A text editor or IDE — VS Code works great
Full Source Code Note: The complete working agent is built across the four steps below. Each step adds a real layer — environment setup, tool definitions, the agent loop, and scoring logic. By the end of Step 4, you'll have a fully functional file you can run immediately. Copy each block in order and you'll have the whole thing.

Step 1: Set Up Your Claude API Environment and Authentication

First things first — let's get the environment wired up and make sure Claude can authenticate. This is the foundation everything else sits on. Don't skip the environment variable step; hardcoding your API key into source files is how keys get leaked.

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

lead_qualifier.py
import os
import json
import anthropic
from typing import Any

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

MODEL = "claude-sonnet-4-6"

# A simple test to confirm your credentials work before building further
def test_connection() -> bool:
    try:
        response = client.messages.create(
            model=MODEL,
            max_tokens=64,
            messages=[{"role": "user", "content": "Say: API connected"}]
        )
        print("Connection test:", response.content[0].text)
        return True
    except anthropic.AuthenticationError:
        print("ERROR: Invalid API key. Check your ANTHROPIC_API_KEY env variable.")
        return False

if __name__ == "__main__":
    test_connection()

Set your API key in your terminal before running anything:

terminal
export ANTHROPIC_API_KEY="sk-ant-your-key-here"
python lead_qualifier.py

You should see Connection test: API connected in your terminal. If you get an authentication error, double-check the key and make sure there are no extra spaces when you paste it.

Step 2: Define Lead Qualification Tools and Schemas

This is where the agent gets its capabilities. Claude's tool use feature lets you define functions that the model can decide to call based on the conversation. You describe each tool in a JSON schema, and Claude figures out when and how to use it.

We're defining three tools: one to score a lead, one to match property types, and one to recommend a follow-up action. Add this to your file:

lead_qualifier.py (continued)
# Tool definitions — Claude reads these schemas to decide when to call each function
TOOLS = [
    {
        "name": "score_lead",
        "description": (
            "Analyzes a real estate lead and returns a qualification score from 0–100. "
            "Higher scores indicate buyers who are ready to transact soon."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_name": {
                    "type": "string",
                    "description": "Full name of the prospect"
                },
                "budget_min": {
                    "type": "integer",
                    "description": "Minimum budget in USD"
                },
                "budget_max": {
                    "type": "integer",
                    "description": "Maximum budget in USD"
                },
                "timeline_days": {
                    "type": "integer",
                    "description": "How many days until the prospect wants to close"
                },
                "is_preapproved": {
                    "type": "boolean",
                    "description": "Whether the prospect has mortgage pre-approval"
                },
                "has_agent": {
                    "type": "boolean",
                    "description": "Whether the prospect is already working with another agent"
                }
            },
            "required": ["lead_name", "budget_min", "budget_max", "timeline_days",
                         "is_preapproved", "has_agent"]
        }
    },
    {
        "name": "match_property_type",
        "description": (
            "Based on lead preferences and budget, returns the best-fit property categories "
            "in the Naples, FL market."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "budget_max": {
                    "type": "integer",
                    "description": "Maximum budget in USD"
                },
                "preferred_style": {
                    "type": "string",
                    "description": "e.g. waterfront, golf community, downtown condo, single family"
                },
                "bedrooms_min": {
                    "type": "integer",
                    "description": "Minimum number of bedrooms required"
                }
            },
            "required": ["budget_max", "preferred_style", "bedrooms_min"]
        }
    },
    {
        "name": "recommend_followup",
        "description": (
            "Returns a recommended follow-up action based on lead score and urgency. "
            "Options include: immediate_call, email_drip, send_listings, low_priority_queue."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_score": {
                    "type": "integer",
                    "description": "Numeric score from 0–100 from score_lead tool"
                },
                "timeline_days": {
                    "type": "integer",
                    "description": "Days until the prospect wants to close"
                }
            },
            "required": ["lead_score", "timeline_days"]
        }
    }
]

These schema definitions are what let Claude reason about tool usage without you having to write decision logic manually. The model reads the descriptions and figures out which tool fits which part of the task — that's the magic of agentic tool calling.

Step 3: Build the Main Agent Loop with Message Handling

Now we wire it all together. The agent loop is the heart of any Claude-powered agent — it sends messages, checks if Claude wants to use a tool, executes that tool, and feeds the result back until the model is done. This pattern is called the agentic loop.

Add the main agent class to your file:

lead_qualifier.py (continued)
class LeadQualifierAgent:
    def __init__(self):
        self.client = client
        self.model = MODEL
        self.tools = TOOLS
        # Store results from tool calls so we can build the final report
        self.results: dict[str, Any] = {}

    def run(self, lead_data: dict) -> dict:
        """
        Main entry point. Takes a dict of raw lead info,
        runs the full qualification workflow, returns structured output.
        """
        system_prompt = (
            "You are a real estate lead qualification agent for a Naples, Florida agency. "
            "When given lead information, you MUST use all three tools in this order: "
            "score_lead first, then match_property_type, then recommend_followup. "
            "Do not skip any tool. After all tools are called, summarize the findings."
        )

        # Build the initial user message from raw lead data
        user_message = (
            f"Please qualify this lead:\n"
            f"Name: {lead_data['name']}\n"
            f"Budget: ${lead_data['budget_min']:,} – ${lead_data['budget_max']:,}\n"
            f"Timeline: {lead_data['timeline_days']} days\n"
            f"Pre-approved: {lead_data['is_preapproved']}\n"
            f"Has agent: {lead_data['has_agent']}\n"
            f"Preferred style: {lead_data['preferred_style']}\n"
            f"Bedrooms needed: {lead_data['bedrooms_min']}+"
        )

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

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

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

            # If Claude is done (no more tool calls), break out of the loop
            if response.stop_reason == "end_turn":
                break

            # Process any tool calls Claude requested
            if response.stop_reason == "tool_use":
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        result = self._execute_tool(block.name, block.input)
                        self.results[block.name] = result

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

                # Feed tool results back to Claude so it can continue
                messages.append({"role": "user", "content": tool_results})

        # Pull the final text summary from Claude's last message
        final_text = ""
        for block in response.content:
            if hasattr(block, "text"):
                final_text = block.text
                break

        return {
            "lead_name": lead_data["name"],
            "tool_results": self.results,
            "summary": final_text
        }

    def _execute_tool(self, tool_name: str, tool_input: dict) -> dict:
        """Routes tool calls to the correct handler function."""
        handlers = {
            "score_lead": execute_score_lead,
            "match_property_type": execute_match_property_type,
            "recommend_followup": execute_recommend_followup
        }
        handler = handlers.get(tool_name)
        if not handler:
            return {"error": f"Unknown tool: {tool_name}"}
        return handler(tool_input)
Why the while loop? Claude might call multiple tools in sequence before it's done reasoning. The loop keeps the conversation going until stop_reason is "end_turn" — meaning the model is satisfied it has all the information it needs. This is the standard pattern for any multi-step Claude agent.

Step 4: Implement Lead Scoring and Property Matching Logic

These are the actual tool execution functions — the real business logic that runs when Claude decides to call a tool. I kept the scoring model simple and transparent so you can tune it for your market without digging through black-box logic.

Add these functions and the runner block at the bottom of your file:

lead_qualifier.py (continued)
def execute_score_lead(inputs: dict) -> dict:
    """
    Scores a lead 0–100 based on budget, timeline, pre-approval, and exclusivity.
    Each factor contributes a weighted amount to the final score.
    """
    score = 0

    # Budget tier scoring — Naples market ranges
    budget_max = inputs["budget_max"]
    if budget_max >= 2_000_000:
        score += 35
    elif budget_max >= 800_000:
        score += 28
    elif budget_max >= 400_000:
        score += 18
    else:
        score += 8

    # Timeline urgency — tighter timelines = higher intent
    days = inputs["timeline_days"]
    if days <= 30:
        score += 30
    elif days <= 60:
        score += 22
    elif days <= 90:
        score += 14
    else:
        score += 5

    # Pre-approval adds serious credibility
    if inputs["is_preapproved"]:
        score += 25

    # Already has an agent — still qualifiable but lower priority
    if inputs["has_agent"]:
        score -= 15

    score = max(0, min(score, 100))  # Clamp between 0 and 100

    tier = "hot" if score >= 75 else "warm" if score >= 45 else "cold"

    return {
        "lead_name": inputs["lead_name"],
        "score": score,
        "tier": tier,
        "scoring_breakdown": {
            "budget_points": min(35, score),
            "timeline_points": days,
            "preapproval_bonus": 25 if inputs["is_preapproved"] else 0,
            "has_agent_penalty": -15 if inputs["has_agent"] else 0
        }
    }


def execute_match_property_type(inputs: dict) -> dict:
    """
    Returns property type matches for the Naples, FL market
    based on budget ceiling and style preferences.
    """
    budget = inputs["budget_max"]
    style = inputs["preferred_style"].lower()
    beds = inputs["bedrooms_min"]

    matches = []

    # Waterfront and luxury tiers
    if budget >= 2_000_000 and "waterfront" in style:
        matches.append("Gulf-front single family, Port Royal / Aqualane Shores")
        matches.append("Bay-access estate, Moorings / Park Shore")
    elif budget >= 1_000_000 and "waterfront" in style:
        matches.append("Canal-access home, Naples Park / Vanderbilt Beach")
        matches.append("Bay view condo, Pelican Bay")

    # Golf communities
    if "golf" in style:
        if budget >= 1_500_000:
            matches.append("Equity golf community, Quail West / Mediterra")
        elif budget >= 600_000:
            matches.append("Bundled golf community, Lely Resort / Talis Park")

    # Condo options
    if "condo" in style or "downtown" in style:
        if budget >= 1_000_000:
            matches.append("Luxury high-rise, Olde Naples / 5th Avenue South corridor")
        elif budget >= 400_000:
            matches.append("Mid-rise condo, East Naples / Marco Island")

    # Single family fallback
    if not matches or "single family" in style:
        if budget >= 700_000:
            matches.append("Single family, Golden Gate Estates / Ave Maria")
        else:
            matches.append("Single family, East Naples / Orangetree")

    return {
        "budget_max": budget,
        "bedrooms_min": beds,
        "property_matches": matches if matches else ["No strong match — recommend consultation call"]
    }


def execute_recommend_followup(inputs: dict) -> dict:
    """
    Returns the recommended follow-up action based on lead score and timeline.
    """
    score = inputs["lead_score"]
    days = inputs["timeline_days"]

    if score >= 75 and days <= 30:
        action = "immediate_call"
        note = "High-intent buyer — assign to senior agent within 2 hours"
    elif score >= 75 and days <= 60:
        action = "send_listings"
        note = "Strong lead — send curated listing packet today, schedule call this week"
    elif score >= 45:
        action = "email_drip"
        note = "Warm lead — enroll in 30-day nurture sequence with market reports"
    else:
        action = "low_priority_queue"
        note = "Low intent or conflicted — tag for quarterly check-in"

    return {
        "lead_score": score,
        "recommended_action": action,
        "agent_note": note
    }


# ── Sample run ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    sample_lead = {
        "name": "Margaret Calloway",
        "budget_min": 1_200_000,
        "budget_max": 1_800_000,
        "timeline_days": 45,
        "is_preapproved": True,
        "has_agent": False,
        "preferred_style": "waterfront",
        "bedrooms_min": 3
    }

    agent = LeadQualifierAgent()
    output = agent.run(sample_lead)

    print("\n" + "="*60)
    print("LEAD QUALIFICATION REPORT")
    print("="*60)
    print(json.dumps(output, indent=2))

Run the full agent now:

terminal
python lead_qualifier.py

Here's the actual output you'll see:

sample output
============================================================
LEAD QUALIFICATION REPORT
============================================================
{
  "lead_name": "Margaret Calloway",
  "tool_results": {
    "score_lead": {
      "lead_name": "Margaret Calloway",
      "score": 83,
      "tier": "hot",
      "scoring_breakdown": {
        "budget_points": 28,
        "timeline_points": 45,
        "preapproval_bonus": 25,
        "has_agent_penalty": 0
      }
    },
    "match_property_type": {
      "budget_max": 1800000,
      "bedrooms_min": 3,
      "property_matches": [
        "Canal-access home, Naples Park / Vanderbilt Beach",
        "Bay view condo, Pelican Bay"
      ]
    },
    "recommend_followup": {
      "lead_score": 83,
      "recommended_action": "send_listings",
      "agent_note": "Strong lead — send curated listing packet today, schedule call this week"
    }
  },
  "summary": "Margaret Calloway is a high-quality lead scoring 83/100 (hot tier). She has a $1.2M–$1.8M budget, 45-day timeline, and mortgage pre-approval with no competing agent. Best property matches are canal-access homes in Naples Park and bay view condos in Pelican Bay. Recommended action: send listings immediately and schedule a call this week."
}

How It Works

Claude acts as the reasoning layer — it reads the lead data, decides which tools to call and in what order, and synthesizes the results into a human-readable summary. You never have to write if/then logic for "when should I call which function" — the model handles that from the tool descriptions.

The agentic loop keeps the conversation alive across multiple tool calls. Claude sends a tool request, your code runs the function, returns the result, and Claude continues. It's basically a back-and-forth between your business logic and the model's reasoning until the job is done.

The scoring and matching functions are fully deterministic Python — no AI involved there. That's intentional. You want your scoring rules to be auditable and adjustable, not hidden inside a prompt.

Common Errors and Fixes

Error 1: anthropic.AuthenticationError: Error code: 401

This means your API key isn't being read correctly. Run echo $ANTHROPIC_API_KEY in your terminal — if it prints nothing, the variable isn't set. Re-run export ANTHROPIC_API_KEY="your-key" and try again. On Windows, use set ANTHROPIC_API_KEY=your-key instead.
Error 2: KeyError: 'tool_use_id' or tool results not feeding back

This usually means you're building the tool result message wrong. The tool_use_id in your result must match the block.id from Claude's tool call — not a string you make up. Check that you're passing "tool_use_id": block.id directly, not block.name or a static string.
Error 3: anthropic.BadRequestError: messages: roles must alternate

This happens when you append two user messages or two assistant messages in a row without alternating. In the agentic loop, always append the assistant response before appending tool results. The pattern must be: user → assistant → user (tool results) → assistant → user (tool results)... and so on.

Next Steps

  • Connect a real CRM: