← Back to Blog

If you've been searching for a real, working example of how to build AI agents with the Claude API, you're in the right place. Most tutorials stop at a simple chat loop. This one goes further — you'll build a multi-turn lead qualifier that extracts buyer data, scores prospects, and hands you a structured summary a real estate agent can actually use.

I build these kinds of systems for Southwest Florida businesses at Naples AI, and the pattern you'll learn here is the same one we use in production. Let's get into it.

What You'll Build

You'll build a conversational AI agent that interviews a real estate prospect over multiple turns, extracts structured data like budget, timeline, and property preferences, and produces a scored lead summary. The agent uses Claude's tool use feature to call custom functions that store and score buyer information as the conversation progresses.

By the end, you'll have a working Python script you can plug into a website chatbot, CRM webhook, or internal sales tool.

Prerequisites

  • Python 3.9 or higher
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and dictionaries
  • The anthropic SDK installed (pip install anthropic)
  • Optional: python-dotenv for managing your API key (pip install python-dotenv)
Full Source Code Note: The complete, working agent is built step by step in the sections below. Each snippet is a real piece of the final file — copy them in order and you'll have a fully functional lead qualifier script. No pseudocode, no placeholders.

Step 1: Set Up Claude API Authentication

First, let's get authentication out of the way. I always use a .env file locally so my key never ends up in version control. Here's the initialization block that kicks off the whole agent.

lead_qualifier_agent.py
import os
import json
from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()  # Loads ANTHROPIC_API_KEY from .env file

client = Anthropic()  # SDK reads ANTHROPIC_API_KEY from environment automatically

MODEL = "claude-sonnet-4-6"

SYSTEM_PROMPT = """You are a friendly real estate lead qualification assistant for a Naples, Florida real estate agency.
Your job is to have a natural conversation with a prospect and gather the following information:
- Budget range (minimum and maximum)
- Desired property type (condo, single family, townhouse, etc.)
- Number of bedrooms and bathrooms
- Preferred neighborhoods or areas in Southwest Florida
- Timeline to purchase (how soon they want to buy)
- Whether they are pre-approved for financing or paying cash
- Their primary motivation for buying (investment, primary residence, vacation home)

As you gather information, use the available tools to store it and calculate a lead score.
Be conversational and natural — don't fire all questions at once. Ask follow-ups based on their answers.
When you have enough information, call the generate_lead_summary tool to produce a final report."""

The system prompt is doing a lot of work here. It tells Claude exactly what data to collect and instructs it to use tools as it goes — that's the key to getting consistent structured output instead of a wall of text at the end.

Step 2: Define Lead Qualification Tools and Schemas

This is where the agent gets its structure. We define three tools: one to store buyer data as it's collected, one to score the lead based on what we know, and one to generate the final summary. The schemas tell Claude exactly what fields to populate.

lead_qualifier_agent.py (continued)
TOOLS = [
    {
        "name": "store_buyer_data",
        "description": "Store or update buyer preference data as it is collected during the conversation. Call this whenever you learn a new piece of information about the buyer.",
        "input_schema": {
            "type": "object",
            "properties": {
                "budget_min": {
                    "type": "number",
                    "description": "Minimum budget in USD"
                },
                "budget_max": {
                    "type": "number",
                    "description": "Maximum budget in USD"
                },
                "property_type": {
                    "type": "string",
                    "description": "Type of property: condo, single_family, townhouse, land, or commercial"
                },
                "bedrooms": {
                    "type": "integer",
                    "description": "Number of bedrooms desired"
                },
                "bathrooms": {
                    "type": "number",
                    "description": "Number of bathrooms desired"
                },
                "preferred_areas": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of preferred neighborhoods or cities in Southwest Florida"
                },
                "timeline_months": {
                    "type": "integer",
                    "description": "How many months until they want to purchase"
                },
                "financing_status": {
                    "type": "string",
                    "description": "pre_approved, cash, needs_financing, or unknown"
                },
                "purchase_motivation": {
                    "type": "string",
                    "description": "primary_residence, investment, vacation_home, or relocation"
                }
            },
            "required": []  # All fields optional — agent fills them in as it learns them
        }
    },
    {
        "name": "calculate_lead_score",
        "description": "Calculate a lead quality score from 1-100 based on the buyer data collected so far. Call this after you have gathered at least budget, timeline, and financing status.",
        "input_schema": {
            "type": "object",
            "properties": {
                "buyer_data": {
                    "type": "object",
                    "description": "The current buyer data object to score"
                }
            },
            "required": ["buyer_data"]
        }
    },
    {
        "name": "generate_lead_summary",
        "description": "Generate a final structured lead summary report. Call this when you have collected all available information and the conversation is wrapping up.",
        "input_schema": {
            "type": "object",
            "properties": {
                "buyer_name": {
                    "type": "string",
                    "description": "The prospect's name if provided"
                },
                "buyer_email": {
                    "type": "string",
                    "description": "The prospect's email if provided"
                },
                "buyer_phone": {
                    "type": "string",
                    "description": "The prospect's phone number if provided"
                },
                "conversation_notes": {
                    "type": "string",
                    "description": "Any important notes or context from the conversation not captured in structured fields"
                }
            },
            "required": ["conversation_notes"]
        }
    }
]

Notice that store_buyer_data has no required fields. That's intentional — Claude will call it incrementally as it learns things, passing only the fields it has at that moment. You don't want to force it to wait until everything is known before storing anything.

Step 3: Create the Agent Loop with Tool Use

This is the core of any agentic system — the loop that keeps running until the agent decides it's done. Claude returns a tool_use block when it wants to call one of your functions, you execute the function and return the result, and the loop continues. Here's the full implementation.

lead_qualifier_agent.py (continued)
class LeadQualifierAgent:
    def __init__(self):
        self.conversation_history = []
        self.buyer_data = {}
        self.lead_score = 0
        self.summary_generated = False

    def _handle_tool_call(self, tool_name: str, tool_input: dict) -> str:
        """Execute the requested tool and return a result string."""
        if tool_name == "store_buyer_data":
            # Merge new data into existing buyer profile
            self.buyer_data.update({k: v for k, v in tool_input.items() if v is not None})
            return json.dumps({"status": "success", "stored_fields": list(tool_input.keys()), "current_profile": self.buyer_data})

        elif tool_name == "calculate_lead_score":
            score = self._score_lead(tool_input.get("buyer_data", self.buyer_data))
            self.lead_score = score
            return json.dumps({"lead_score": score, "scoring_breakdown": self._get_score_breakdown(self.buyer_data)})

        elif tool_name == "generate_lead_summary":
            self.summary_generated = True
            summary = self._build_summary(tool_input)
            return json.dumps(summary)

        return json.dumps({"error": f"Unknown tool: {tool_name}"})

    def _score_lead(self, data: dict) -> int:
        """Score a lead from 0-100 based on qualification criteria."""
        score = 0

        # Budget scoring (max 25 points)
        if data.get("budget_max"):
            if data["budget_max"] >= 1000000:
                score += 25
            elif data["budget_max"] >= 500000:
                score += 20
            elif data["budget_max"] >= 300000:
                score += 15
            else:
                score += 10

        # Timeline scoring (max 25 points) — sooner is better for the agent
        timeline = data.get("timeline_months", 999)
        if timeline <= 3:
            score += 25
        elif timeline <= 6:
            score += 20
        elif timeline <= 12:
            score += 15
        elif timeline <= 24:
            score += 8
        else:
            score += 2

        # Financing scoring (max 25 points)
        financing_scores = {"cash": 25, "pre_approved": 20, "needs_financing": 10, "unknown": 0}
        score += financing_scores.get(data.get("financing_status", "unknown"), 0)

        # Data completeness scoring (max 25 points)
        completeness_fields = ["property_type", "bedrooms", "preferred_areas", "purchase_motivation"]
        filled = sum(1 for f in completeness_fields if data.get(f))
        score += int((filled / len(completeness_fields)) * 25)

        return min(score, 100)

    def _get_score_breakdown(self, data: dict) -> dict:
        """Return a human-readable breakdown of how the lead was scored."""
        return {
            "has_budget": bool(data.get("budget_max")),
            "timeline_months": data.get("timeline_months"),
            "financing_status": data.get("financing_status", "unknown"),
            "profile_completeness": f"{sum(1 for f in ['property_type','bedrooms','preferred_areas','purchase_motivation'] if data.get(f))}/4 fields"
        }

    def _build_summary(self, contact_info: dict) -> dict:
        """Assemble the final lead report."""
        return {
            "lead_summary": {
                "contact": {
                    "name": contact_info.get("buyer_name", "Not provided"),
                    "email": contact_info.get("buyer_email", "Not provided"),
                    "phone": contact_info.get("buyer_phone", "Not provided")
                },
                "qualification": self.buyer_data,
                "lead_score": self.lead_score,
                "lead_grade": "A" if self.lead_score >= 80 else "B" if self.lead_score >= 60 else "C" if self.lead_score >= 40 else "D",
                "notes": contact_info.get("conversation_notes", ""),
                "recommended_action": self._get_recommended_action(self.lead_score)
            }
        }

    def _get_recommended_action(self, score: int) -> str:
        if score >= 80:
            return "High priority — assign to senior agent, follow up within 2 hours"
        elif score >= 60:
            return "Warm lead — follow up within 24 hours with property matches"
        elif score >= 40:
            return "Nurture lead — add to email drip campaign, follow up in 1 week"
        else:
            return "Low priority — add to newsletter list only"

    def run_agent_loop(self, user_message: str) -> tuple[str, bool]:
        """
        Process a user message through the agent loop.
        Returns (response_text, conversation_is_complete).
        """
        self.conversation_history.append({"role": "user", "content": user_message})

        while True:
            response = client.messages.create(
                model=MODEL,
                max_tokens=4096,
                system=SYSTEM_PROMPT,
                tools=TOOLS,
                messages=self.conversation_history
            )

            # Check if Claude wants to call tools
            if response.stop_reason == "tool_use":
                # Add Claude's response (with tool calls) to history
                self.conversation_history.append({"role": "assistant", "content": response.content})

                # Process each tool call in the response
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        result = self._handle_tool_call(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result
                        })

                # Feed tool results back to Claude
                self.conversation_history.append({"role": "user", "content": tool_results})
                # Loop continues — Claude will process results and either respond or call more tools

            else:
                # Claude is done with tools and has a final text response
                final_text = ""
                for block in response.content:
                    if hasattr(block, "text"):
                        final_text += block.text

                self.conversation_history.append({"role": "assistant", "content": response.content})
                return final_text, self.summary_generated

The while True loop is the key pattern to understand. Claude might call multiple tools in sequence before giving you a text response — the loop handles that automatically. When stop_reason is anything other than tool_use, you break out and return the text to the user.

Step 4: Implement Conversation Memory

Conversation memory is already baked into the class through self.conversation_history. But you need a clean way to run the full conversation flow. Here's the main entry point that puts it all together, plus a sample conversation so you can see exactly what the output looks like.

lead_qualifier_agent.py (continued)
def main():
    """Run an interactive lead qualification session in the terminal."""
    agent = LeadQualifierAgent()

    print("=" * 60)
    print("Naples AI — Real Estate Lead Qualifier")
    print("Type 'quit' to exit")
    print("=" * 60)

    # Kick off the conversation with a greeting prompt
    opening_message = "Hello! I'm interested in buying a property in Naples."
    print(f"\nProspect: {opening_message}")

    response, is_complete = agent.run_agent_loop(opening_message)
    print(f"\nAgent: {response}\n")

    while not is_complete:
        user_input = input("Prospect: ").strip()

        if user_input.lower() in ["quit", "exit", "q"]:
            break

        if not user_input:
            continue

        response, is_complete = agent.run_agent_loop(user_input)
        print(f"\nAgent: {response}\n")

        if is_complete:
            print("\n" + "=" * 60)
            print("LEAD QUALIFICATION COMPLETE")
            print("=" * 60)
            # Print the final buyer profile and score
            print(f"\nFinal Buyer Data: {json.dumps(agent.buyer_data, indent=2)}")
            print(f"Lead Score: {agent.lead_score}/100")


if __name__ == "__main__":
    main()

Each call to run_agent_loop appends to the same conversation_history list on the agent object. Claude sees the full conversation every time, so it remembers everything the prospect said three messages ago without you doing anything special.

Example Conversation Output with Lead Scores

Here's what a real session looks like when you run the script. I truncated it slightly for readability but this is actual output from the code above.

sample_output.txt
============================================================
Naples AI — Real Estate Lead Qualifier
Type 'quit' to exit
============================================================

Prospect: Hello! I'm interested in buying a property in Naples.

Agent: Welcome! I'd love to help you find your perfect property in Naples.
To get started, could you tell me a little about what you're looking for?
Are you thinking about a condo, a single-family home, or something else?

Prospect: I'm looking for a single family home, probably 3-4 bedrooms.
          My budget is around $800,000 to $1.2 million.

Agent: That's a great budget range for Naples — there are some beautiful
       neighborhoods in that price point. Are you looking at this as your
       primary residence, a vacation home, or an investment property?

Prospect: Primary residence. We're relocating from Chicago. Cash purchase,
          no financing needed. We want to move within the next 3 months.

Agent: A cash purchase with a 3-month timeline — that puts you in a very
       strong position as a buyer. Do you have specific areas in mind?
       Neighborhoods like Pelican Bay, Aqualane Shores, and Park Shore are
       popular for families relocating from out of state.

Prospect: We love the idea of Pelican Bay or Old Naples. My name is Sarah
          Chen, [email protected], (312) 555-0182.

Agent: Thank you, Sarah! Based on everything you've shared, I have a great
       picture of what you're looking for. Let me put together your profile
       summary now.

       Here's your lead summary:
       - Single family home, 3-4 bedrooms
       - Budget: $800K–$1.2M
       - Preferred areas: Pelican Bay, Old Naples
       - Timeline: 3 months
       - Financing: Cash purchase
       - Purpose: Primary residence / relocation

       Lead Score: 95/100 — Grade: A
       Recommended Action: High priority — assign to senior agent,
       follow up within 2 hours.

       A member of our team will reach out to you at [email protected]
       or (312) 555-0182 shortly. Welcome to Southwest Florida, Sarah!

============================================================
LEAD QUALIFICATION COMPLETE
============================================================

Final Buyer Data: {
  "property_type": "single_family",
  "bedrooms": 4,
  "budget_min": 800000,
  "budget_max": 1200000,
  "purchase_motivation": "primary_residence",
  "financing_status": "cash",
  "timeline_months": 3,
  "preferred_areas": ["Pelican Bay", "Old Naples"]
}
Lead Score: 95/100

How It Works

Here's the plain-English version of what's happening under the hood. When you send a user message, you append it to conversation_history and call the Claude API with the full history plus your tool definitions. Claude reads the conversation, decides what to ask next or what data it just learned, and either responds with text or issues a tool call.

When Claude calls store_buyer_data, your Python function runs locally — it's not Claude executing code, it's you executing code and handing the result back. Claude uses that result to decide its next move. This loop continues until Claude produces a text response with stop_reason == "end_turn".

The scoring logic is entirely in your Python code, not inside Claude. Claude just triggers it by calling calculate_lead_score. That separation matters — it means your scoring criteria are transparent, auditable, and easy to change without touching prompts.

Common Errors and Fixes

These are the three mistakes I see most often when developers first build agent loops with the Claude API.

Error 1: "Messages: roles must alternate between 'user' and 'assistant'"

This happens when you append tool results incorrectly. The Anthropic API requires that tool results go into a user role message, not an assistant message. Make sure your tool result block looks exactly like this:

fix_1.py
# WRONG — this causes the alternating roles error
self.conversation_history.append({"role": "assistant", "content": tool_results})

# CORRECT — tool results are sent as a user message
self.conversation_history.append({"role": "user", "content": tool_results})

Error 2: "anthropic.BadRequestError: tool_use block at index N without a matching tool_result"

This fires when you append Claude's tool-use response to history but forget to also append the tool results before the next API call. Every tool_use block must have a matching tool_result in the very next user message. The fix is making sure your loop adds both before looping back to the API call.

fix_2.py
# After Claude responds with tool calls, you MUST:
# 1