All articles

What You'll Build

If you've been trying to figure out how to build AI agents with the Claude API, this is the tutorial that actually shows you a real use case. We're going to build a production-ready lead qualification agent in Python that takes raw lead data, extracts structured information using Claude's tool use feature, and returns a scored, categorized result you can plug directly into your CRM.

By the end of this walkthrough, you'll have a working Claude API lead qualification chatbot that scores leads from 0 to 100, assigns a tier (Hot, Warm, or Cold), and explains its reasoning in plain English. The whole thing runs in about 300 lines of Python and uses the official Anthropic SDK.

Full Source Code Note: The complete, working source code is broken into steps below so you understand what each piece does. If you want to copy the whole thing at once, all the snippets below fit together into one file called lead_qualifier.py. Every snippet uses claude-sonnet-4-6 and the official Anthropic Python SDK.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and dictionaries
  • anthropic SDK installed: pip install anthropic
  • A .env file or environment variable set for ANTHROPIC_API_KEY

Step 1: Set Up Your Claude API Client and Authentication

First things first, let's get your API client wired up correctly. The Anthropic SDK makes this pretty straightforward, but I want to show you exactly how to handle authentication cleanly so your key never ends up hardcoded in your source file.

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

lead_qualifier.py
import os
import json
import anthropic
from dataclasses import dataclass, field
from typing import Optional

# Load your API key from the environment
# Set it with: export ANTHROPIC_API_KEY="sk-ant-..."
# or put it in a .env file and load it with python-dotenv

ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")

if not ANTHROPIC_API_KEY:
    raise ValueError(
        "ANTHROPIC_API_KEY environment variable is not set. "
        "Export it before running: export ANTHROPIC_API_KEY='your-key-here'"
    )

# Initialize the Anthropic client once at module level
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)

MODEL = "claude-sonnet-4-6"


@dataclass
class LeadData:
    """Structured container for a raw lead submission."""
    raw_text: str
    source: str = "web_form"
    metadata: dict = field(default_factory=dict)


@dataclass
class QualificationResult:
    """The final output of the lead qualification agent."""
    score: int = 0
    tier: str = "Cold"
    budget_range: str = "Unknown"
    timeline: str = "Unknown"
    pain_points: list = field(default_factory=list)
    contact_name: str = "Unknown"
    company: str = "Unknown"
    reasoning: str = ""
    recommended_action: str = ""
      

The LeadData dataclass holds whatever raw text comes in from your form, email, or CRM webhook. The QualificationResult is what the agent fills in after it processes the lead. Having clean data structures like this makes the rest of the code much easier to reason about.

Step 2: Define Tool Schemas for Lead Data Extraction

This is where the agentic part really starts. Claude's tool use feature lets you define structured functions that Claude can call when it needs to take action or return structured data. We're going to define two tools: one to extract raw lead information and one to actually score and qualify the lead.

Think of tools as a contract between you and the model. You describe what the function does and what parameters it needs, and Claude decides when and how to call it.

lead_qualifier.py (continued)
# Tool definitions tell Claude what structured actions it can take.
# Claude will decide when to call these based on the conversation.

LEAD_EXTRACTION_TOOL = {
    "name": "extract_lead_info",
    "description": (
        "Extract structured information from a raw lead message or form submission. "
        "Call this first to pull out all identifiable details before scoring."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "contact_name": {
                "type": "string",
                "description": "Full name of the person who submitted the lead."
            },
            "company": {
                "type": "string",
                "description": "Company or business name, if mentioned."
            },
            "email": {
                "type": "string",
                "description": "Email address if present in the lead text."
            },
            "phone": {
                "type": "string",
                "description": "Phone number if present in the lead text."
            },
            "budget_range": {
                "type": "string",
                "description": (
                    "Estimated budget range mentioned. Use 'Unknown' if not stated. "
                    "Examples: 'Under $5k', '$5k-$20k', '$20k-$50k', '$50k+'"
                )
            },
            "timeline": {
                "type": "string",
                "description": (
                    "Project timeline or urgency. Use 'Unknown' if not stated. "
                    "Examples: 'Immediate', 'Within 30 days', '3-6 months', 'No rush'"
                )
            },
            "pain_points": {
                "type": "array",
                "items": {"type": "string"},
                "description": "List of specific problems or needs the lead mentioned."
            },
            "industry": {
                "type": "string",
                "description": "Industry or business type of the lead."
            },
            "decision_maker": {
                "type": "boolean",
                "description": "True if the person appears to be a decision-maker or owner."
            }
        },
        "required": ["contact_name", "pain_points", "decision_maker"]
    }
}

LEAD_SCORING_TOOL = {
    "name": "score_lead",
    "description": (
        "Score and qualify a lead based on extracted information. "
        "Call this after extract_lead_info with the extracted details."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "score": {
                "type": "integer",
                "description": (
                    "Lead quality score from 0 to 100. "
                    "80-100: Hot (ready to buy, clear budget, decision maker). "
                    "50-79: Warm (interested but needs nurturing). "
                    "0-49: Cold (early stage, unclear fit, or missing key info)."
                )
            },
            "tier": {
                "type": "string",
                "enum": ["Hot", "Warm", "Cold"],
                "description": "Qualification tier based on the score."
            },
            "reasoning": {
                "type": "string",
                "description": "Plain-English explanation of why this score was assigned."
            },
            "recommended_action": {
                "type": "string",
                "description": (
                    "What the sales team should do next. "
                    "Be specific: call within X hours, send case study, add to drip, etc."
                )
            }
        },
        "required": ["score", "tier", "reasoning", "recommended_action"]
    }
}

TOOLS = [LEAD_EXTRACTION_TOOL, LEAD_SCORING_TOOL]
      

The two-tool approach is intentional. Extracting first and scoring second gives Claude a natural thinking process, which produces much better results than asking it to do everything in one shot. You'll see this play out in the agentic loop.

Step 3: Create the Lead Qualification Agent Logic

Now we build the agent class itself. This is the core of the system. It holds the conversation state, calls the Claude API, and processes tool calls as they come back from the model.

The agent keeps a running list of messages so Claude has full context about what has already been extracted before it moves on to scoring.

lead_qualifier.py (continued)
class LeadQualificationAgent:
    """
    An agentic lead qualifier that uses Claude's tool use to extract
    and score leads from raw text input.
    """

    def __init__(self):
        self.client = client
        self.model = MODEL
        self.extracted_info = {}
        self.messages = []

    def _build_system_prompt(self) -> str:
        return (
            "You are a lead qualification specialist for a custom AI solutions agency "
            "in Southwest Florida. Your job is to analyze inbound lead messages and "
            "determine how well they fit our services, which include custom AI development, "
            "process automation, AI chatbots, real estate listing automation, and "
            "predictive analytics.\n\n"
            "Always start by calling extract_lead_info to pull structured data from "
            "the raw lead message. Then call score_lead using what you extracted. "
            "Be direct and practical in your reasoning. A Hot lead has a clear budget, "
            "timeline, decision-maker authority, and a pain point we can solve. "
            "A Cold lead is missing most of those signals."
        )

    def _process_tool_call(
        self,
        tool_name: str,
        tool_input: dict,
        result: QualificationResult
    ) -> str:
        """
        Handle each tool call Claude makes and store the results.
        Returns a string acknowledgment that gets fed back into the conversation.
        """
        if tool_name == "extract_lead_info":
            # Store extracted data so we can use it in the final result
            self.extracted_info = tool_input
            result.contact_name = tool_input.get("contact_name", "Unknown")
            result.company = tool_input.get("company", "Unknown")
            result.budget_range = tool_input.get("budget_range", "Unknown")
            result.timeline = tool_input.get("timeline", "Unknown")
            result.pain_points = tool_input.get("pain_points", [])

            return json.dumps({
                "status": "extracted",
                "message": "Lead information extracted successfully.",
                "extracted_fields": list(tool_input.keys())
            })

        elif tool_name == "score_lead":
            result.score = tool_input.get("score", 0)
            result.tier = tool_input.get("tier", "Cold")
            result.reasoning = tool_input.get("reasoning", "")
            result.recommended_action = tool_input.get("recommended_action", "")

            return json.dumps({
                "status": "scored",
                "message": "Lead has been scored and qualified.",
                "score": result.score,
                "tier": result.tier
            })

        # Fallback for any unexpected tool name
        return json.dumps({"status": "error", "message": f"Unknown tool: {tool_name}"})

    def qualify(self, lead: LeadData) -> QualificationResult:
        """
        Main entry point. Pass in a LeadData object and get back a
        fully populated QualificationResult.
        """
        result = QualificationResult()

        # Reset state for each new lead
        self.messages = []
        self.extracted_info = {}

        # Initial user message with the raw lead content
        self.messages.append({
            "role": "user",
            "content": (
                f"Please qualify this inbound lead.\n\n"
                f"Source: {lead.source}\n\n"
                f"Lead message:\n{lead.raw_text}"
            )
        })

        # Kick off the agentic loop (defined in Step 4)
        self._run_agentic_loop(result)

        return result
      

Step 4: Implement the Agentic Loop with Tool Use

This is the part that makes it actually agentic. The loop keeps calling Claude until it's done, handling tool calls along the way. The key signal you're watching for is stop_reason in the API response.

When stop_reason is "tool_use", Claude wants to call a function. When it's "end_turn", the agent is finished. You need to handle both of these correctly or the loop will break in confusing ways.

lead_qualifier.py (continued)
    def _run_agentic_loop(self, result: QualificationResult) -> None:
        """
        The core agentic loop. Keeps sending messages to Claude and
        processing tool calls until stop_reason is 'end_turn'.
        """
        max_iterations = 10  # Safety limit to avoid runaway loops
        iteration = 0

        while iteration < max_iterations:
            iteration += 1

            # Call the Claude API with the current message history
            response = self.client.messages.create(
                model=self.model,
                max_tokens=4096,
                system=self._build_system_prompt(),
                tools=TOOLS,
                messages=self.messages
            )

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

            # Check why the model stopped generating
            if response.stop_reason == "end_turn":
                # Claude is done. No more tool calls needed.
                break

            elif 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

                        # Process the tool call and get the result string
                        tool_result_content = self._process_tool_call(
                            tool_name, tool_input, result
                        )

                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": tool_use_id,
                            "content": tool_result_content
                        })

                # Send all tool results back to Claude in a single user message
                if tool_results:
                    self.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

        if iteration >= max_iterations:
            print("Warning: Agentic loop hit max iteration limit.")
      
Why the max_iterations limit matters: Without a ceiling on your loop, a misbehaving prompt or unexpected API response can spin your code indefinitely and rack up API costs. Ten iterations is generous for a two-tool agent. In production you might drop this to five.

Step 5: Test with Real Lead Scenarios

Now let's put it all together and run the agent against three realistic lead scenarios. These are the kinds of messages that actually come in through contact forms, and they test all three qualification tiers.

lead_qualifier.py (continued)
def print_result(lead_text: str, result: QualificationResult) -> None:
    """Pretty-print a qualification result to the console."""
    print("\n" + "=" * 60)
    print(f"LEAD: {lead_text[:80]}...")
    print("=" * 60)
    print(f"Contact:    {result.contact_name}")
    print(f"Company:    {result.company}")
    print(f"Budget:     {result.budget_range}")
    print(f"Timeline:   {result.timeline}")
    print(f"Score:      {result.score}/100")
    print(f"Tier:       {result.tier}")
    print(f"\nPain Points:")
    for point in result.pain_points:
        print(f"  - {point}")
    print(f"\nReasoning:\n  {result.reasoning}")
    print(f"\nRecommended Action:\n  {result.recommended_action}")
    print("=" * 60)


if __name__ == "__main__":
    agent = LeadQualificationAgent()

    # Scenario 1: Hot lead with clear signals
    hot_lead = LeadData(
        raw_text=(
            "Hi, my name is Marco Delgado and I run a 12-location real estate brokerage "
            "in Naples, FL called SunCoast Premier Realty. We're manually entering "
            "property listings across 4 different platforms every day, which takes my "
            "team about 3 hours per listing. I need this automated ASAP. We've budgeted "
            "around $30,000-$50,000 for a custom solution and want to be live within "
            "60 days. I'm the owner and I make all technology decisions. "
            "You can reach me at [email protected] or 239-555-0192."
        ),
        source="website_contact_form"
    )

    # Scenario 2: Warm lead that needs nurturing
    warm_lead = LeadData(
        raw_text=(
            "Hey, I manage operations at a mid-size restaurant group here in Bonita Springs. "
            "We've been thinking about using AI to help with scheduling and maybe customer "
            "service, but we're still in the research phase. Not sure what budget looks like "
            "yet, probably have to bring it to ownership. Would love to get more info about "
            "what you do. My name is Priya and my email is [email protected]."
        ),
        source="website_contact_form"
    )

    # Scenario 3: Cold lead with minimal information
    cold_lead = LeadData(
        raw_text=(
            "Interested in AI. Can you send me pricing?"
        ),
        source="website_contact_form"
    )

    # Run all three through the agent
    for lead in [hot_lead, warm_lead, cold_lead]:
        result = agent.qualify(lead)
        print_result(lead.raw_text, result)
      

Example Output

Here's what the agent actually produces when you run it. I trimmed the reasoning slightly for readability, but these are real outputs from this code.

Terminal output
============================================================
LEAD: Hi, my name is Marco Delgado and I run a 12-location real estate brokerage...
============================================================
Contact:    Marco Delgado
Company:    SunCoast Premier Realty
Budget:     $30k-$50k
Timeline:   Within 60 days
Score:      92/100
Tier:       Hot

Pain Points:
  - Manual property listing entry across 4 platforms
  - 3 hours per listing in manual labor
  - No automation for multi-platform publishing

Reasoning:
  Marco is the owner and sole decision-maker for a 12-location brokerage with a
  specific, quantified pain point (3 hours/listing x multiple platforms). He has a
  defined budget of $30k-$50k which aligns with our real estate automation services,
  a 60-day deadline creating urgency, and direct contact info. This is an ideal fit.

Recommended Action:
  Call within 2 business hours. Lead directly with the real estate listing
  automation case study. Propose a 30-minute discovery call to scope the
  multi-platform sync workflow.
============================================================

============================================================
LEAD: Hey, I manage operations at a mid-size restaurant group here in Bonita Springs...
============================================================
Contact:    Priya
Company:    Unknown
Budget:     Unknown
Timeline:   No rush
Score:      54/100
Tier:       Warm

Pain Points:
  - Manual employee scheduling
  - Potential customer service gaps
  - No current AI tooling in place

Reasoning:
  Priya has real pain points that map to our services, but she is not the
  decision-maker and has no defined budget. The restaurant industry is a good
  fit, but we need to reach ownership to advance. Early research stage with
  genuine interest.

Recommended Action:
  Add to email nurture sequence. Send the restaurant automation one-pager.
  Ask Priya for an intro to the ownership team within 72 hours.
============================================================

============================================================
LEAD: Interested in AI. Can you send me pricing?...
============================================================
Contact:    Unknown
Company:    Unknown
Budget:     Unknown
Timeline:   Unknown
Score:      12/100
Tier:       Cold

Pain Points:
  - None identified

Reasoning:
  Virtually no information was provided. No name, company, budget, timeline,
  or specific pain point. Cannot determine fit or intent. May be a price shopper
  or very early-stage curious prospect.

Recommended Action:
  Add to cold email sequence. Send a general capabilities overview and a
  short qualification form asking about industry, team size, and primary goals.
  Do not invest sales time until more information is gathered.
============================================================
      

How It Works

The agent sends your raw lead text to Claude along with the two tool definitions. Claude reads the lead message and decides to call extract_lead_info first, filling in the structured fields like name, budget, and pain points.

Your code receives that tool call, stores the extracted data in the QualificationResult object, and sends back a confirmation. Claude then calls score_lead with that context in mind, applying scoring logic based on the signals it found. The loop exits when Claude returns stop_reason: end_turn, meaning it's satisfied with what it did.