← Back to Blog

If you've ever watched a salesperson spend three hours chasing a lead that was never going to close, you already understand the problem. Qualifying leads manually is slow, inconsistent, and expensive — and it's exactly the kind of work an AI agent can take off your plate. In this tutorial, I'll show you how to build a production-ready AI lead qualifier agent in Python using the Claude API, complete with tool use, scoring logic, and multi-turn conversations.

This is a real, working system — not a toy demo. By the end, you'll have an agent that asks the right questions, scores leads automatically, and returns a structured qualification report you can pipe into any CRM or webhook.

What You'll Build

You're building a Python-based AI lead qualifier agent that talks to prospects, asks discovery questions, and scores each lead across multiple dimensions like budget, timeline, authority, and fit. The agent uses Claude's tool_use feature to call scoring and research functions in real time, just like a human sales rep working through a checklist. At the end of each conversation, it outputs a structured JSON score with a qualification verdict.

This is the same architecture we use at Naples AI when building lead automation systems for real estate agencies, car dealerships, and healthcare practices across Southwest Florida. It works in production and it scales.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python functions and dictionaries
  • anthropic SDK installed: pip install anthropic
  • A .env file or environment variable for your API key
  • Optional but recommended: python-dotenvpip install python-dotenv
📦 Full Source Code Note: The complete, working source code is built up step by step in the sections below. Each snippet is self-contained and labeled with its filename. If you want to jump straight to the finished file, all the pieces fit together into a single lead_qualifier.py file by the end of Step 5. Every code block here is syntactically correct and tested against claude-sonnet-4-6.

Step 1: Set Up Your Claude API Client and Environment

First things first — let's get the client initialized and confirm your environment is wired up correctly. I keep API keys in a .env file so they never end up in version control.

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

# Load environment variables from .env file
load_dotenv()

# Initialize the Anthropic client — it reads ANTHROPIC_API_KEY automatically
client = Anthropic()

# We'll use claude-sonnet-4-6 throughout this tutorial
MODEL = "claude-sonnet-4-6"

# System prompt that tells Claude it's acting as a lead qualification agent
SYSTEM_PROMPT = """You are a professional B2B lead qualification agent for a software agency.
Your job is to have a natural, friendly conversation with a prospect and gather the information
needed to score them using the BANT framework: Budget, Authority, Need, and Timeline.

Ask one question at a time. Be conversational, not robotic. When you have enough information
to score the lead across all four dimensions, call the score_lead tool. If you need to look up
company information, call the research_company tool first.

Always end the conversation by calling qualify_lead with the final verdict."""

def main():
    """Entry point — we'll build this out in the steps below."""
    print("Lead Qualifier Agent initialized.")
    print(f"Using model: {MODEL}")

if __name__ == "__main__":
    main()

Run this now to confirm there are no import errors. You should see both print statements with no exceptions. If you get an AuthenticationError, double-check that your .env file contains ANTHROPIC_API_KEY=sk-ant-... on its own line.

💡 Tip: Never hardcode your API key directly in the source file. Use environment variables or a secrets manager from day one. This habit saves you from accidental key exposure when you push to GitHub.

Step 2: Define Lead Qualification Tools (Scoring, Research, Qualification)

This is where the real power of agentic patterns shows up. Claude can call Python functions — we define what those functions look like using a JSON schema, then write the actual Python logic that runs when Claude decides to call them. Think of it like giving Claude a toolkit.

We're defining three tools: research_company to look up company details, score_lead to assign BANT scores, and qualify_lead to produce the final verdict. In a real deployment, research_company would call an API like Clearbit or Apollo — here we'll simulate it so you can see the full pattern without needing extra credentials.

lead_qualifier.py (continued)
# Tool definitions — these are passed to Claude so it knows what functions are available
TOOLS = [
    {
        "name": "research_company",
        "description": (
            "Look up publicly available information about a company given its name or domain. "
            "Returns company size, industry, estimated revenue, and location."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "company_name": {
                    "type": "string",
                    "description": "The name of the company to research"
                },
                "domain": {
                    "type": "string",
                    "description": "Optional company website domain, e.g. acme.com"
                }
            },
            "required": ["company_name"]
        }
    },
    {
        "name": "score_lead",
        "description": (
            "Score a lead using the BANT framework. Call this when you have gathered enough "
            "information about Budget, Authority, Need, and Timeline."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "budget": {
                    "type": "integer",
                    "description": "Budget score from 1-10. 10 means they have confirmed budget allocated.",
                    "minimum": 1,
                    "maximum": 10
                },
                "authority": {
                    "type": "integer",
                    "description": "Authority score 1-10. 10 means you are speaking directly with the decision maker.",
                    "minimum": 1,
                    "maximum": 10
                },
                "need": {
                    "type": "integer",
                    "description": "Need score 1-10. 10 means they have an urgent, clearly defined problem.",
                    "minimum": 1,
                    "maximum": 10
                },
                "timeline": {
                    "type": "integer",
                    "description": "Timeline score 1-10. 10 means they want to start within 30 days.",
                    "minimum": 1,
                    "maximum": 10
                },
                "notes": {
                    "type": "string",
                    "description": "Brief notes explaining the scores"
                }
            },
            "required": ["budget", "authority", "need", "timeline", "notes"]
        }
    },
    {
        "name": "qualify_lead",
        "description": (
            "Issue the final lead qualification verdict. Call this after score_lead "
            "to close out the conversation with a structured result."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "prospect_name": {
                    "type": "string",
                    "description": "Full name of the prospect"
                },
                "company": {
                    "type": "string",
                    "description": "Company name"
                },
                "verdict": {
                    "type": "string",
                    "enum": ["hot", "warm", "cold", "disqualified"],
                    "description": "hot=score>=35, warm=score 25-34, cold=score 15-24, disqualified=<15"
                },
                "composite_score": {
                    "type": "integer",
                    "description": "Sum of all four BANT scores (max 40)"
                },
                "recommended_action": {
                    "type": "string",
                    "description": "Specific next step for the sales team"
                }
            },
            "required": ["prospect_name", "company", "verdict", "composite_score", "recommended_action"]
        }
    }
]

The input_schema field is critical — Claude uses it to know exactly what arguments to pass when it calls a tool. Keep your descriptions clear and specific. Vague descriptions lead to Claude calling tools with wrong or missing arguments, which is the number one source of bugs in agentic systems.

Step 3: Create the Agent Loop with tool_use Handling

This is the heart of the whole system. An agentic loop means: send a message to Claude, check if it wants to call a tool, execute that tool with real Python code, send the result back to Claude, and repeat until Claude is done. It's a loop because Claude might call several tools before it finishes a task.

The key thing to understand is that tool_use blocks appear inside Claude's response alongside text blocks. You have to check for them, run your Python functions, and return the results wrapped in tool_result messages. If you don't do this, Claude will stall waiting for results that never come.

lead_qualifier.py (continued)
import time

def research_company(company_name: str, domain: str = None) -> dict:
    """
    Simulated company research — in production, replace this with a Clearbit,
    Apollo, or Hunter.io API call.
    """
    # Simulate a brief API delay
    time.sleep(0.5)
    return {
        "company_name": company_name,
        "domain": domain or f"{company_name.lower().replace(' ', '')}.com",
        "industry": "Professional Services",
        "employee_count": 45,
        "estimated_annual_revenue": "$3M - $8M",
        "location": "Naples, FL",
        "founded": 2018
    }


def score_lead(budget: int, authority: int, need: int, timeline: int, notes: str) -> dict:
    """Calculate composite BANT score and return scoring breakdown."""
    composite = budget + authority + need + timeline
    return {
        "bant_scores": {
            "budget": budget,
            "authority": authority,
            "need": need,
            "timeline": timeline
        },
        "composite_score": composite,
        "max_possible": 40,
        "percentage": round((composite / 40) * 100, 1),
        "notes": notes
    }


def qualify_lead(
    prospect_name: str,
    company: str,
    verdict: str,
    composite_score: int,
    recommended_action: str
) -> dict:
    """Package the final qualification result."""
    return {
        "status": "qualified",
        "prospect": prospect_name,
        "company": company,
        "verdict": verdict.upper(),
        "composite_score": composite_score,
        "recommended_action": recommended_action,
        "qualified_at": "2026-07-13T10:30:00Z"
    }


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """
    Route tool calls to the correct Python function and return results as a string.
    Claude expects tool results as strings (or structured content).
    """
    if tool_name == "research_company":
        result = research_company(**tool_input)
    elif tool_name == "score_lead":
        result = score_lead(**tool_input)
    elif tool_name == "qualify_lead":
        result = qualify_lead(**tool_input)
    else:
        result = {"error": f"Unknown tool: {tool_name}"}

    return json.dumps(result)


def run_agent_loop(user_message: str, conversation_history: list) -> tuple[list, dict | None]:
    """
    Core agentic loop. Sends a message, handles tool calls, and returns
    the updated conversation history plus any final qualification result.
    """
    # Append the new user message to history
    conversation_history.append({
        "role": "user",
        "content": user_message
    })

    final_result = None

    # Keep looping until Claude stops calling tools
    while True:
        response = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            system=SYSTEM_PROMPT,
            tools=TOOLS,
            messages=conversation_history
        )

        # Extract any text Claude wants to say to the user
        assistant_text = ""
        tool_calls = []

        for block in response.content:
            if block.type == "text":
                assistant_text = block.text
            elif block.type == "tool_use":
                tool_calls.append(block)

        # Add Claude's full response to history (preserves tool_use blocks)
        conversation_history.append({
            "role": "assistant",
            "content": response.content
        })

        # If there are no tool calls, Claude is done — return the conversation
        if not tool_calls:
            if assistant_text:
                print(f"\nAgent: {assistant_text}")
            break

        # Print any text Claude said before calling tools
        if assistant_text:
            print(f"\nAgent: {assistant_text}")

        # Execute each tool and collect results
        tool_results = []
        for tool_call in tool_calls:
            print(f"\n[Calling tool: {tool_call.name} with {tool_call.input}]")
            result_str = execute_tool(tool_call.name, tool_call.input)
            result_data = json.loads(result_str)

            # Capture the final qualification result when qualify_lead is called
            if tool_call.name == "qualify_lead":
                final_result = result_data

            tool_results.append({
                "type": "tool_result",
                "tool_use_id": tool_call.id,
                "content": result_str
            })

        # Send tool results back to Claude as a user message
        conversation_history.append({
            "role": "user",
            "content": tool_results
        })

        # Check if Claude is done after receiving tool results
        if response.stop_reason == "end_turn" and not tool_calls:
            break

    return conversation_history, final_result

Notice how we pass the full response.content back into history — not just the text. This is important because Claude needs to see its own tool_use blocks to maintain context in the conversation. Stripping them out causes confusing behavior where Claude repeats tool calls it already made.

Step 4: Implement Multi-Turn Conversation for Lead Questions

A single-shot prompt isn't enough for real lead qualification. You need back-and-forth — Claude asks a question, the prospect answers, Claude follows up. This is where multi-turn conversation management comes in. We maintain a conversation_history list that grows with every exchange, giving Claude full context at all times.

lead_qualifier.py (continued)
def run_qualification_session(prospect_intro: str) -> dict | None:
    """
    Run a full lead qualification session starting from a prospect's intro message.
    In a real app, you'd replace the input() calls with a web form, chatbot widget,
    or CRM webhook payload.
    """
    conversation_history = []
    final_result = None

    print("\n" + "="*60)
    print("LEAD QUALIFIER AGENT — Naples AI")
    print("="*60)
    print(f"\nProspect: {prospect_intro}")

    # Start the conversation with the prospect's opening message
    conversation_history, final_result = run_agent_loop(
        prospect_intro,
        conversation_history
    )

    # Continue the multi-turn conversation until we have a qualification result
    max_turns = 10  # Safety limit to prevent infinite loops in edge cases
    turn = 0

    while final_result is None and turn < max_turns:
        # In a real system this would come from a chat widget or API endpoint
        user_input = input("\nProspect: ").strip()

        if not user_input:
            continue

        if user_input.lower() in ["quit", "exit", "bye"]:
            print("\nAgent: Thanks for your time! We'll be in touch.")
            break

        conversation_history, final_result = run_agent_loop(
            user_input,
            conversation_history
        )
        turn += 1

    return final_result

The max_turns guard is something I always add to agentic systems. Without it, a bug in your logic or an unexpected Claude response can spin the loop forever and burn through API credits fast. Ten turns is generous for lead qualification — most conversations wrap up in four or five exchanges.

Step 5: Parse and Return Qualified Lead Scores

The final piece is formatting and displaying the qualification result in a way that's actually useful — structured JSON that can be sent to a CRM, logged to a database, or fired off as a webhook. Let's also wire up the complete main() function so the whole script runs end to end.

lead_qualifier.py (continued — final main function)
def format_qualification_report(result: dict) -> str:
    """
    Format the final qualification result as a readable report.
    In production, you'd also POST this to your CRM or a webhook.
    """
    if not result:
        return "No qualification result generated."

    verdict_emoji = {
        "HOT": "🔥",
        "WARM": "♨️",
        "COLD": "❄️",
        "DISQUALIFIED": "⛔"
    }

    emoji = verdict_emoji.get(result.get("verdict", ""), "📋")

    report = f"""
{'='*60}
LEAD QUALIFICATION REPORT
{'='*60}
Prospect:         {result.get('prospect', 'Unknown')}
Company:          {result.get('company', 'Unknown')}
Verdict:          {emoji} {result.get('verdict', 'N/A')}
Composite Score:  {result.get('composite_score', 0)} / 40
Next Action:      {result.get('recommended_action', 'N/A')}
Qualified At:     {result.get('qualified_at', 'N/A')}
{'='*60}

Raw JSON Output:
{json.dumps(result, indent=2)}
"""
    return report


def main():
    """
    Entry point. Runs a sample lead qualification session.
    Replace the prospect_intro with input from your actual lead source.
    """
    # Example prospect opening message — swap this with real input in production
    prospect_intro = (
        "Hi, I'm Sarah Chen, VP of Operations at Gulf Coast Medical Group. "
        "We have about 12 locations across Collier and Lee County and we're "
        "struggling with patient intake — it's still mostly manual and paper-based. "
        "A colleague mentioned you might be able to help us automate some of this."
    )

    result = run_qualification_session(prospect_intro)

    if result:
        print(format_qualification_report(result))
    else:
        print("\nSession ended without a full qualification score.")


if __name__ == "__main__":
    main()

Here's what a real session looks like when you run this. Claude will ask discovery questions, call research_company in the background, then score and qualify the lead:

Sample Terminal Output
============================================================
LEAD QUALIFIER AGENT — Naples AI
============================================================

Prospect: Hi, I'm Sarah Chen, VP of Operations at Gulf Coast Medical Group...

[Calling tool: research_company with {'company_name': 'Gulf Coast Medical Group'}]

Agent: Hi Sarah! Thanks for reaching out — patient intake automation is definitely
something we can help with. Before I connect you with our team, I'd love to ask
a few quick questions to make sure we're the right fit. First: do you have a
specific budget range in mind for this project, or are you still in the
exploratory phase?

Prospect: We have budget approved — somewhere in the $80,000 to $120,000 range
for this fiscal year.

Agent: That's great to hear — that's a solid budget for what you're describing.
Quick follow-up: as VP of Operations, would you be the final decision-maker
on a project like this, or would it need to go through someone else for sign-off?

Prospect: I have final sign-off on anything under $150k. Our CFO would need to
be looped in above that.

Agent: Perfect, that's really helpful. One more question: are you looking to
get something implemented this quarter, or is this more of a longer-term
planning conversation?

Prospect: We want to be live before the end of Q3 — so roughly 10 weeks from now.

[Calling tool: score_lead with {'budget': 9, 'authority': 8, 'need': 9,
'timeline': 8, 'notes': 'Confirmed budget $80-120k, VP has sign-off under $150k,
urgent need with manual intake across 12 locations, 10-week timeline to Q3 go-live'}]

[Calling tool: qualify_lead with {'prospect_name': 'Sarah Chen',
'company': 'Gulf Coast Medical Group', 'verdict': 'hot', 'composite_score': 34,
'recommended_action': 'Schedule a 60-minute discovery call within 48 hours.
Send intake automation case study before the call.'}]

Agent: Sarah, based on our conversation I think there's a really strong fit here.
I'm going to flag you as a priority for our team and someone will reach out
within 48