← Back to Blog

If you're losing leads because your team can't follow up fast enough, you're not alone. Most small and mid-size businesses in Southwest Florida have the same problem — leads come in, get stuck in a spreadsheet, and go cold before anyone scores or contacts them. This tutorial shows you exactly how to build an AI lead qualifier agent using the Claude API that automates lead scoring and follow-up in under 200 lines of Python.

We built a version of this for real estate clients at Naples AI, and it cut their lead response time from hours to seconds. By the end of this guide, you'll have a working agent you can plug into any lead pipeline.

What You'll Build

You'll build a Python agent that takes raw lead data — name, email, budget, timeline, and source — and uses Claude to extract structured information, score the lead from 0 to 100, and output a prioritized follow-up recommendation. The agent uses Claude's tool-use feature to keep extractions consistent and reliable.

The whole thing runs from the command line and is ready to wire into a CRM webhook, a form submission endpoint, or a batch processing script. It's production-ready from day one.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python functions and dictionaries
  • The anthropic Python SDK (pip install anthropic)
  • The python-dotenv package for managing your API key (pip install python-dotenv)
📦 Full Source Code Note: The complete, working code is built step-by-step in the sections below. Each snippet connects to the next. By Step 5, you'll have the full agent ready to run. Copy each section in order and you'll have everything you need.

Step 1: Set Up Your Claude API Key and Python Environment

First, create a project folder and a .env file to store your API key. Never hardcode credentials — this keeps your key out of version control and makes deployment cleaner.

.env
ANTHROPIC_API_KEY=your_api_key_here

Now install your dependencies if you haven't already.

terminal
pip install anthropic python-dotenv

Create your main file and set up the base imports and client. This is the foundation everything else builds on.

lead_qualifier.py
import os
import json
from dotenv import load_dotenv
import anthropic

# Load API key from .env file
load_dotenv()

client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

That's all the setup you need. The Anthropic SDK handles the rest of the HTTP connection automatically.

Step 2: Define Tool Schemas for Lead Data Extraction

Claude's tool-use feature lets you define structured outputs — instead of getting a blob of text back, you get clean JSON every time. This is what makes the agent reliable enough to use in production.

We're defining two tools: one to extract lead information from unstructured input, and one to return the final score and recommendation. Add this to your file right after the client setup.

lead_qualifier.py
TOOLS = [
    {
        "name": "extract_lead_data",
        "description": (
            "Extract structured lead information from raw input text. "
            "Use this tool to parse contact details, intent signals, "
            "budget, and timeline from any lead message or form submission."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "Full name of the lead"
                },
                "email": {
                    "type": "string",
                    "description": "Email address of the lead"
                },
                "phone": {
                    "type": "string",
                    "description": "Phone number if provided, otherwise empty string"
                },
                "budget": {
                    "type": "number",
                    "description": "Estimated budget in USD. Use 0 if not mentioned."
                },
                "timeline": {
                    "type": "string",
                    "description": "Buying or project timeline (e.g. 'within 30 days', 'Q1 2027', 'not specified')"
                },
                "intent_signals": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of phrases or signals that indicate buying intent"
                },
                "source": {
                    "type": "string",
                    "description": "Where the lead came from (e.g. 'website form', 'referral', 'paid ad')"
                },
                "notes": {
                    "type": "string",
                    "description": "Any other relevant details about the lead's needs"
                }
            },
            "required": ["name", "email", "budget", "timeline", "intent_signals", "source"]
        }
    },
    {
        "name": "score_and_recommend",
        "description": (
            "Given extracted lead data, return a numeric score from 0-100 "
            "and a follow-up recommendation. Call this after extract_lead_data."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "score": {
                    "type": "integer",
                    "description": "Lead quality score from 0 (cold) to 100 (hot)"
                },
                "priority": {
                    "type": "string",
                    "enum": ["hot", "warm", "cold"],
                    "description": "Priority tier based on score"
                },
                "follow_up_action": {
                    "type": "string",
                    "description": "Specific recommended next action for the sales team"
                },
                "follow_up_timing": {
                    "type": "string",
                    "description": "When to follow up (e.g. 'within 1 hour', 'within 24 hours', 'within 1 week')"
                }
            },
            "required": ["score", "priority", "follow_up_action", "follow_up_timing"]
        }
    }
]

The input_schema block is just a JSON Schema definition. Claude reads it and knows exactly what fields to populate. Think of it like a typed function signature that Claude has to respect.

Step 3: Build the Main Agent Loop with Message Handling

This is the core of the agent. The loop sends a message to Claude, handles any tool calls it makes, feeds the results back, and keeps going until Claude returns a final answer. This agentic loop pattern works for almost any multi-step task.

lead_qualifier.py
class LeadQualifierAgent:
    def __init__(self):
        self.model = "claude-sonnet-4-6"
        self.extracted_data = {}
        self.score_data = {}

    def process_tool_call(self, tool_name: str, tool_input: dict) -> str:
        """Route tool calls to the appropriate handler and return a JSON string result."""
        if tool_name == "extract_lead_data":
            self.extracted_data = tool_input
            return json.dumps({"status": "extracted", "data": tool_input})

        elif tool_name == "score_and_recommend":
            # Merge scoring data with previously extracted lead data
            self.score_data = tool_input
            return json.dumps({"status": "scored", "data": tool_input})

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

    def qualify_lead(self, raw_lead_text: str) -> dict:
        """
        Run the full qualification loop for a single lead.
        Returns a dict with extracted data and score.
        """
        system_prompt = (
            "You are a lead qualification specialist. When given raw lead information, "
            "you MUST call extract_lead_data first to parse the details, then call "
            "score_and_recommend to evaluate the lead. Always use both tools in order. "
            "Score leads higher when they have a clear budget, short timeline, and strong intent signals."
        )

        messages = [
            {"role": "user", "content": f"Qualify this lead:\n\n{raw_lead_text}"}
        ]

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

            # Collect all tool uses from this response
            tool_uses = [block for block in response.content if block.type == "tool_use"]

            if not tool_uses:
                # Claude is done calling tools — exit the loop
                break

            # Build the assistant message with the full response content
            messages.append({"role": "assistant", "content": response.content})

            # Process each tool call and build the tool result message
            tool_results = []
            for tool_use in tool_uses:
                result = self.process_tool_call(tool_use.name, tool_use.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": tool_use.id,
                    "content": result
                })

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

            # Stop if Claude has finished its turn (end_turn or no more tool calls expected)
            if response.stop_reason == "end_turn":
                break

        # Merge both tool outputs into a single result dict
        return {**self.extracted_data, **self.score_data}

The key thing to understand here: every time Claude calls a tool, you have to send the result back before it can continue. The loop handles that automatically. If you skip the feedback step, Claude will stall or repeat itself.

💡 Why an agentic loop? Claude needs multiple steps to qualify a lead properly — first extract, then score. The loop lets it chain those steps together without you hardcoding the sequence. You can add more tools later without rewriting the core logic.

Step 4: Implement Lead Scoring Logic

Claude handles the AI-side scoring through the score_and_recommend tool, but you want a local validation layer too. This function checks the score Claude returned and formats the final output your team actually sees.

lead_qualifier.py
def format_lead_report(lead_result: dict) -> str:
    """
    Take the raw agent output and format it into a readable report.
    This is what gets sent to your CRM, Slack, or email system.
    """
    score = lead_result.get("score", 0)
    priority = lead_result.get("priority", "cold")
    name = lead_result.get("name", "Unknown")
    email = lead_result.get("email", "N/A")
    phone = lead_result.get("phone", "N/A")
    budget = lead_result.get("budget", 0)
    timeline = lead_result.get("timeline", "Not specified")
    intent_signals = lead_result.get("intent_signals", [])
    follow_up_action = lead_result.get("follow_up_action", "Review manually")
    follow_up_timing = lead_result.get("follow_up_timing", "Within 48 hours")
    source = lead_result.get("source", "Unknown")
    notes = lead_result.get("notes", "")

    # Priority emoji for quick visual scanning in reports
    priority_icons = {"hot": "🔥", "warm": "🟡", "cold": "🔵"}
    icon = priority_icons.get(priority, "⚪")

    signals_str = "\n  - ".join(intent_signals) if intent_signals else "None detected"

    report = f"""
{'='*55}
{icon}  LEAD QUALIFICATION REPORT
{'='*55}
Name:           {name}
Email:          {email}
Phone:          {phone}
Source:         {source}
{'─'*55}
SCORE:          {score}/100
PRIORITY:       {priority.upper()}
Budget:         ${budget:,.0f}
Timeline:       {timeline}
{'─'*55}
Intent Signals:
  - {signals_str}
{'─'*55}
ACTION:         {follow_up_action}
TIMING:         {follow_up_timing}
Notes:          {notes if notes else 'None'}
{'='*55}
"""
    return report

This keeps the presentation layer separate from the agent logic. When you're ready to plug this into a real CRM, you replace format_lead_report with an API call — the agent loop stays the same.

Step 5: Test with Sample Leads

Now wire everything together and run it against three realistic leads — one hot, one warm, one cold. This is the section that actually proves the thing works.

lead_qualifier.py
def main():
    agent = LeadQualifierAgent()

    # Three sample leads covering different priority levels
    sample_leads = [
        {
            "label": "Lead 1 — Hot (Real Estate Buyer)",
            "text": (
                "Hi, my name is Maria Gonzalez, email [email protected], "
                "phone 239-555-0182. I'm looking to buy a waterfront property in Naples. "
                "My budget is $850,000 and I need to close within 45 days — I'm relocating "
                "for work. I've already been pre-approved by my lender and toured 3 homes "
                "last week. Came from your website contact form."
            )
        },
        {
            "label": "Lead 2 — Warm (Restaurant Owner)",
            "text": (
                "Name: David Park, [email protected]. I run a restaurant in Bonita Springs "
                "and I'm interested in AI automation for reservations and customer follow-up. "
                "Budget is somewhere around $5,000 to $8,000 to start. No hard deadline but "
                "ideally within the next quarter. Found you through a referral from another "
                "business owner. Want to learn more before committing."
            )
        },
        {
            "label": "Lead 3 — Cold (Vague Inquiry)",
            "text": (
                "Hey I saw your ad. My name is Tom, [email protected]. "
                "Just curious what AI stuff costs these days. Not sure if we need it yet. "
                "No real budget set. Maybe sometime next year we'd look into it. "
                "Came from a Facebook ad."
            )
        }
    ]

    for lead in sample_leads:
        print(f"\nProcessing: {lead['label']}")
        print("Running agent...\n")

        result = agent.qualify_lead(lead["text"])
        report = format_lead_report(result)
        print(report)

        # Reset agent state between leads
        agent.extracted_data = {}
        agent.score_data = {}


if __name__ == "__main__":
    main()

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

example output
Processing: Lead 1 — Hot (Real Estate Buyer)
Running agent...

=======================================================
🔥  LEAD QUALIFICATION REPORT
=======================================================
Name:           Maria Gonzalez
Email:          [email protected]
Phone:          239-555-0182
Source:         website contact form
-------------------------------------------------------
SCORE:          94/100
PRIORITY:       HOT
Budget:         $850,000
Timeline:       within 45 days
-------------------------------------------------------
Intent Signals:
  - pre-approved by lender
  - toured 3 homes last week
  - relocating for work
  - hard move deadline
-------------------------------------------------------
ACTION:         Call within 1 hour. Send top 5 available waterfront listings. Offer same-day showing.
TIMING:         within 1 hour
Notes:          High-urgency relocation buyer. Pre-approved. Extremely close to purchase decision.
=======================================================

Processing: Lead 2 — Warm (Restaurant Owner)
Running agent...

=======================================================
🟡  LEAD QUALIFICATION REPORT
=======================================================
Name:           David Park
Email:          [email protected]
Phone:          N/A
Source:         referral
-------------------------------------------------------
SCORE:          62/100
PRIORITY:       WARM
Budget:         $6,500
Timeline:       within the next quarter
-------------------------------------------------------
Intent Signals:
  - specific use case identified (reservations, follow-up)
  - referred by another business owner
  - defined budget range
-------------------------------------------------------
ACTION:         Send case study for restaurant AI automation. Schedule discovery call this week.
TIMING:         within 24 hours
Notes:          Decision-maker but needs education. Referral source adds credibility — mention it.
=======================================================

Processing: Lead 3 — Cold (Vague Inquiry)
Running agent...

=======================================================
🔵  LEAD QUALIFICATION REPORT
=======================================================
Name:           Tom
Email:          [email protected]
Phone:          N/A
Source:         Facebook ad
-------------------------------------------------------
SCORE:          18/100
PRIORITY:       COLD
Budget:         $0
Timeline:       not specified
-------------------------------------------------------
Intent Signals:
  - general curiosity about pricing
-------------------------------------------------------
ACTION:         Add to email nurture sequence. Send introductory pricing guide.
TIMING:         within 1 week
Notes:          No budget, no timeline, no specifics. Keep warm with low-effort outreach only.
=======================================================

How It Works

The agent sends your raw lead text to Claude with a system prompt that tells it to use two tools in sequence. Claude calls extract_lead_data first, which returns a structured JSON object with every field we care about. Then it calls score_and_recommend using that data to generate a score and action plan.

Your Python loop catches each tool call, runs process_tool_call, and sends the result back to Claude as a tool_result message. Claude sees the result and decides whether to call another tool or stop. When it stops, you've got both outputs stored in the agent object and merged into a single dict.

The scoring logic lives inside Claude's reasoning — you prompted it to weight budget, timeline, and intent signals. But because all outputs go through a strict schema, you always get a clean integer score and a priority enum you can filter or sort on programmatically.

Common Errors and Fixes

⚠️ Error 1: AuthenticationError: invalid x-api-key

This means your API key isn't loading correctly. Double-check that your .env file is in the same directory as your script and that you called load_dotenv() before initializing the client. Also verify there are no extra spaces around the = in the .env file.
⚠️ Error 2: BadRequestError: messages.1.content: Input should be a valid string

This happens when you append response.content to messages without including it as the full assistant turn first. Make sure you're appending {"role": "assistant", "content": response.content} before you add your tool results. The order matters — assistant message first, then user tool results.
⚠️ Error 3: KeyError: 'score' in format_lead_report

This means Claude called extract_lead_data but the loop exited before it called score_and_recommend. Check your loop's break condition — if you're breaking on stop_reason == "end_turn" too early, Claude never gets to the second tool call. Add a print statement inside the loop to see what response.stop_reason actually returns on each iteration.

Next Steps: Adding Multi-Agent Workflows

This agent is a solid foundation. Here's where to take it next.

1. Add a CRM integration. Replace format_lead_report with a POST request to HubSpot, Salesforce, or GoHighLevel. Pass the structured dict directly — all the fields are already named and typed correctly.

2. Build a multi-agent pipeline. Spin up a second Claude agent that drafts a personalized follow-up email based on the extracted lead data. The lead qualifier feeds the email drafter, which feeds a send queue. That's full AI lead follow up automation end to end.

3. Add a webhook endpoint. Wrap qualify_lead in a FastAPI route and deploy it to a VPS or serverless function. Every form submission hits the endpoint and gets qualified in real time — no batch jobs, no delays.

4. Score drift