← Back to Blog

What You'll Build

If you're manually reading through form submissions and deciding which leads are worth a call, you're burning time that could go toward closing deals. In this tutorial, you'll build a fully autonomous lead qualification agent using the Claude API that scores incoming prospects, reasons through your criteria, and tells you exactly who to call first.

The finished agent runs in under 200 lines of Python, uses tool calling to simulate CRM lookups and scoring logic, and outputs a structured qualification decision with a confidence score and reasoning. You can wire it directly into your existing intake workflow.

📦 Full Source Code
The complete, working code for this agent is spread across the steps below in order. Copy each snippet in sequence and you'll have a fully functional lead qualification agent by the end. Every block is syntactically correct and tested against claude-sonnet-4-6.

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 SDK installed via pip (pip install anthropic)
  • A .env file or environment variable set for ANTHROPIC_API_KEY

Step 1: Set Up the Anthropic SDK and Environment

Start by installing the SDK and verifying your API key is loading correctly. This is the foundation everything else runs on, so get this right before moving forward.

setup_check.py
import os
import anthropic
from dotenv import load_dotenv

# Load your API key from a .env file in the project root
load_dotenv()

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Quick sanity check — send a minimal message to confirm the key works
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Reply with: API connection successful."}]
)

print(response.content[0].text)
# Expected output: API connection successful.

If you see API connection successful. printed in your terminal, you're good to go. If you get an authentication error, double-check that your .env file has ANTHROPIC_API_KEY=sk-ant-... with no extra spaces.

💡 Tip
Install python-dotenv with pip install python-dotenv if you don't have it. Never hardcode your API key directly in source files — especially if you're pushing to GitHub.

Step 2: Define Lead Qualification Criteria and Tools

This is where you encode your actual sales logic. The agent needs two tools: one to look up enriched lead data (simulating a CRM call) and one to score the lead against your criteria. In a real deployment, the CRM lookup tool would hit your HubSpot or Salesforce API instead of returning mock data.

Tool definitions in the Anthropic SDK follow a JSON schema format. Claude reads these definitions, decides when to call them, and passes back structured arguments you can act on.

tools.py
import anthropic

# These are the tool definitions Claude will use to reason about leads
LEAD_TOOLS = [
    {
        "name": "lookup_lead_data",
        "description": (
            "Retrieves enriched information about a lead from the CRM database. "
            "Returns company size, industry, annual revenue estimate, job title, "
            "and previous interactions."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_id": {
                    "type": "string",
                    "description": "The unique identifier for the lead record."
                }
            },
            "required": ["lead_id"]
        }
    },
    {
        "name": "score_lead",
        "description": (
            "Scores a lead on a 0-100 scale based on qualification criteria: "
            "budget fit, authority (decision-maker status), need (pain point match), "
            "and timeline (purchase urgency). Returns a BANT score breakdown."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "lead_id": {
                    "type": "string",
                    "description": "The unique identifier for the lead."
                },
                "budget_fit": {
                    "type": "integer",
                    "description": "Budget alignment score from 0 to 25."
                },
                "authority": {
                    "type": "integer",
                    "description": "Decision-maker authority score from 0 to 25."
                },
                "need": {
                    "type": "integer",
                    "description": "Pain point relevance score from 0 to 25."
                },
                "timeline": {
                    "type": "integer",
                    "description": "Purchase urgency score from 0 to 25."
                }
            },
            "required": ["lead_id", "budget_fit", "authority", "need", "timeline"]
        }
    }
]


def lookup_lead_data(lead_id: str) -> dict:
    """
    Simulates a CRM lookup. Replace this with a real API call in production.
    """
    mock_crm_data = {
        "lead_001": {
            "name": "Sandra Kowalski",
            "company": "Gulf Coast Auto Group",
            "job_title": "General Manager",
            "company_size": 85,
            "industry": "Automotive Dealership",
            "annual_revenue_estimate": 12000000,
            "pain_points": ["slow lead follow-up", "manual inventory reporting"],
            "timeline_to_purchase": "30-60 days",
            "previous_interactions": 3,
            "budget_discussed": True,
            "budget_range": "$8,000 - $15,000/year"
        },
        "lead_002": {
            "name": "Marcus Delray",
            "company": "Beachside Bites LLC",
            "job_title": "Owner",
            "company_size": 12,
            "industry": "Restaurant",
            "annual_revenue_estimate": 480000,
            "pain_points": ["no online ordering system"],
            "timeline_to_purchase": "6+ months",
            "previous_interactions": 1,
            "budget_discussed": False,
            "budget_range": "Unknown"
        }
    }
    # Return empty dict if lead not found rather than crashing
    return mock_crm_data.get(lead_id, {"error": f"Lead {lead_id} not found in CRM."})


def score_lead(lead_id: str, budget_fit: int, authority: int, need: int, timeline: int) -> dict:
    """
    Aggregates the BANT scores and returns a qualification decision.
    """
    total_score = budget_fit + authority + need + timeline
    qualified = total_score >= 60  # Our threshold for a sales-ready lead

    return {
        "lead_id": lead_id,
        "total_score": total_score,
        "breakdown": {
            "budget_fit": budget_fit,
            "authority": authority,
            "need": need,
            "timeline": timeline
        },
        "qualified": qualified,
        "disposition": "QUALIFIED — Schedule discovery call" if qualified else "DISQUALIFIED — Move to nurture sequence"
    }


# Router that maps tool names to their Python functions
TOOL_FUNCTIONS = {
    "lookup_lead_data": lookup_lead_data,
    "score_lead": score_lead
}

Step 3: Create the Agent Agentic Loop

The agentic loop is the core of any autonomous AI agent. Claude looks at the lead data, decides which tools to call, processes the results, and keeps going until it has enough information to give you a final answer. This is what separates a simple chatbot from a real AI agent framework.

The pattern here is straightforward: send a message, check if Claude wants to use a tool, execute that tool, feed the result back, and repeat until you get a plain text response.

agent.py
import os
import json
import anthropic
from dotenv import load_dotenv
from tools import LEAD_TOOLS, TOOL_FUNCTIONS

load_dotenv()

SYSTEM_PROMPT = """You are a lead qualification specialist for a Southwest Florida AI agency.
Your job is to evaluate incoming leads using the BANT framework (Budget, Authority, Need, Timeline).

For every lead:
1. Call lookup_lead_data to retrieve their CRM profile.
2. Analyze the profile and assign BANT sub-scores (each 0-25, total out of 100).
3. Call score_lead with your scores and the lead_id.
4. Summarize your qualification decision in 3-4 sentences, explaining your reasoning.

Be direct. Sales reps will act on your output immediately."""


class LeadQualificationAgent:
    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-6"

    def qualify_lead(self, lead_id: str) -> str:
        """
        Runs the full agentic loop for a single lead and returns the qualification summary.
        """
        messages = [
            {
                "role": "user",
                "content": f"Please qualify lead ID: {lead_id}"
            }
        ]

        print(f"\n{'='*60}")
        print(f"Starting qualification for lead: {lead_id}")
        print(f"{'='*60}")

        # Agentic loop — continues until Claude stops calling tools
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=SYSTEM_PROMPT,
                tools=LEAD_TOOLS,
                messages=messages
            )

            # Check why Claude stopped generating
            if response.stop_reason == "end_turn":
                # No more tool calls — extract the final text response
                final_text = next(
                    block.text for block in response.content
                    if hasattr(block, "text")
                )
                print(f"\n📋 QUALIFICATION RESULT:\n{final_text}")
                return final_text

            if response.stop_reason == "tool_use":
                # Claude wants to call one or more tools
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        tool_name = block.name
                        tool_input = block.input
                        tool_use_id = block.id

                        print(f"\n🔧 Tool called: {tool_name}")
                        print(f"   Input: {json.dumps(tool_input, indent=2)}")

                        # Execute the actual tool function
                        tool_fn = TOOL_FUNCTIONS.get(tool_name)
                        if tool_fn:
                            result = tool_fn(**tool_input)
                        else:
                            result = {"error": f"Unknown tool: {tool_name}"}

                        print(f"   Result: {json.dumps(result, indent=2)}")

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

                # Append Claude's response and all tool results to the message history
                messages.append({"role": "assistant", "content": response.content})
                messages.append({"role": "user", "content": tool_results})

            else:
                # Unexpected stop reason — break to avoid infinite loop
                print(f"Unexpected stop reason: {response.stop_reason}")
                break

        return "Qualification could not be completed."
⚠️ Important
Always append both the assistant's tool-use message AND your tool results back into the messages list. If you skip either one, the API will throw a validation error about mismatched message roles. This is the most common mistake when building LLM agents for the first time.

Step 4: Implement Tool Calling for Lead Scoring

Now let's wire everything together with a runner script that processes multiple leads in a batch. This is what you'd actually call from a webhook handler or a scheduled cron job when new leads come in from your website or CRM.

run_qualification.py
import json
from agent import LeadQualificationAgent


def run_batch_qualification(lead_ids: list[str]) -> list[dict]:
    """
    Qualifies a list of leads and returns structured results.
    """
    agent = LeadQualificationAgent()
    results = []

    for lead_id in lead_ids:
        summary = agent.qualify_lead(lead_id)
        results.append({
            "lead_id": lead_id,
            "summary": summary
        })

    return results


if __name__ == "__main__":
    # Test with two leads — one that should qualify, one that shouldn't
    leads_to_process = ["lead_001", "lead_002"]

    batch_results = run_batch_qualification(leads_to_process)

    print("\n" + "="*60)
    print("BATCH QUALIFICATION COMPLETE")
    print("="*60)
    for result in batch_results:
        print(f"\nLead ID: {result['lead_id']}")
        print(f"Summary: {result['summary'][:200]}...")

Step 5: Test with Real Lead Data

Run the script with python run_qualification.py and you should see the agent working through each lead in real time. Watch the terminal output — you can see exactly which tools Claude calls and what data it's reasoning over.

Here's what the actual output looks like for both leads:

sample_output.txt
============================================================
Starting qualification for lead: lead_001
============================================================

🔧 Tool called: lookup_lead_data
   Input: {
     "lead_id": "lead_001"
   }
   Result: {
     "name": "Sandra Kowalski",
     "company": "Gulf Coast Auto Group",
     "job_title": "General Manager",
     "company_size": 85,
     "industry": "Automotive Dealership",
     "annual_revenue_estimate": 12000000,
     "pain_points": ["slow lead follow-up", "manual inventory reporting"],
     "timeline_to_purchase": "30-60 days",
     "previous_interactions": 3,
     "budget_discussed": true,
     "budget_range": "$8,000 - $15,000/year"
   }

🔧 Tool called: score_lead
   Input: {
     "lead_id": "lead_001",
     "budget_fit": 22,
     "authority": 24,
     "need": 23,
     "timeline": 22
   }
   Result: {
     "lead_id": "lead_001",
     "total_score": 91,
     "breakdown": {
       "budget_fit": 22,
       "authority": 24,
       "need": 23,
       "timeline": 22
     },
     "qualified": true,
     "disposition": "QUALIFIED — Schedule discovery call"
   }

📋 QUALIFICATION RESULT:
Sandra Kowalski from Gulf Coast Auto Group is a strong qualified lead
with a BANT score of 91/100. As General Manager, she has full purchasing
authority and a confirmed budget range of $8,000-$15,000/year that aligns
well with our AI automation packages. Her pain points around slow lead
follow-up and manual inventory reporting are problems we solve directly,
and her 30-60 day purchase timeline indicates genuine urgency.
Recommendation: Schedule a discovery call within 48 hours.

============================================================
Starting qualification for lead: lead_002
============================================================

🔧 Tool called: lookup_lead_data
   Input: {
     "lead_id": "lead_002"
   }
   Result: {
     "name": "Marcus Delray",
     "company": "Beachside Bites LLC",
     "job_title": "Owner",
     "company_size": 12,
     "industry": "Restaurant",
     "annual_revenue_estimate": 480000,
     "pain_points": ["no online ordering system"],
     "timeline_to_purchase": "6+ months",
     "previous_interactions": 1,
     "budget_discussed": false,
     "budget_range": "Unknown"
   }

🔧 Tool called: score_lead
   Input: {
     "lead_id": "lead_002",
     "budget_fit": 8,
     "authority": 25,
     "need": 12,
     "timeline": 6
   }
   Result: {
     "lead_id": "lead_002",
     "total_score": 51,
     "breakdown": {
       "budget_fit": 8,
       "authority": 25,
       "need": 12,
       "timeline": 6
     },
     "qualified": false,
     "disposition": "DISQUALIFIED — Move to nurture sequence"
   }

📋 QUALIFICATION RESULT:
Marcus Delray from Beachside Bites LLC scores 51/100 and does not meet
the qualification threshold at this time. While he is the owner and has
full authority, budget has not been discussed and the $480K revenue
estimate suggests limited capacity for our service tier. His 6+ month
timeline and single prior interaction indicate he is still in early
research mode. Recommendation: Enroll in 90-day email nurture sequence
and re-evaluate in Q4.

How It Works

The agent isn't magic — it's a loop with a decision point. Claude reads your system prompt, which tells it exactly how to evaluate a lead. Then it calls tools in the order that makes sense to gather the data it needs before making a judgment call.

The stop_reason field is the key to the whole thing. When it's "tool_use", Claude is asking you to run something and report back. When it's "end_turn", it's done reasoning and is giving you the final answer. Everything in between is just passing data back and forth through the messages array.

The BANT scoring tool forces the model to commit to explicit numerical scores rather than vague language. That's intentional — you want defensible, consistent decisions you can audit, not qualitative hunches that change with every run.

Common Errors and Fixes

Error 1: Invalid API Key

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

Fix: Your .env file isn't loading or the key is malformed. Make sure you're calling load_dotenv() before initializing the client, and that your key starts with sk-ant-. Print os.environ.get("ANTHROPIC_API_KEY") to confirm it's actually being read.

Error 2: Mismatched Tool Result Message

anthropic.BadRequestError: messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row

Fix: You're appending tool results incorrectly. Tool results must be wrapped in a user message with "type": "tool_result" immediately after the assistant message containing the tool_use block. Make sure you're appending the assistant response first, then the tool results — never skip either one.

Error 3: Tool Input Schema Mismatch

anthropic.BadRequestError: tools.1.input_schema.properties.budget_fit: Input should be a valid integer

Fix: Claude occasionally returns a score as a string like "22" instead of an integer 22 when your schema isn't strict enough. Add input coercion in your tool function: budget_fit = int(budget_fit) at the top of score_lead(). You can also add "type": "integer" more explicitly in the schema to guide the model.

Next Steps

Once this is working locally, here are a few ways to extend it into something production-ready:

  • Connect a real CRM: Replace the mock lookup_lead_data function with an actual HubSpot or Salesforce API call using their Python SDKs. The tool interface stays exactly the same.
  • Add a webhook endpoint: Wrap the agent in a FastAPI route so it fires automatically when a new lead submits your contact form. You can have qualification results in under 10 seconds of form submission.
  • Persist results to a database: Write the final score and disposition to a Postgres or Supabase table so sales reps can see a live dashboard of ranked leads without opening their CRM.
  • Build a multi-agent pipeline: Feed qualified leads from this agent into a second agent that drafts a personalized outreach email based on the lead's specific pain points and industry. That's a full