← Back to Blog

What You'll Build

If you've been searching for a way to build a multi-agent lead qualifier using the Claude API, you're in the right place. Most lead qualification systems are brittle rule trees that break the moment a lead doesn't fit a clean template. What we're building instead is an autonomous Python agent that scores incoming leads, enriches their data, and routes them to the right pipeline — all driven by Claude's reasoning.

By the end of this tutorial you'll have a working Python script that accepts a raw lead (name, company, message), runs it through three specialized tools, and returns a routing decision with a score and reasoning. No LangChain, no LlamaIndex — just the Anthropic SDK and plain Python.

📦 Full Source Code
The complete, working code is split across the steps below so you can follow each piece in context. If you want to jump straight to the final file, scroll to Step 4 — that's where everything comes together into the full agent loop. Every snippet shown is production-ready and syntactically correct.

Prerequisites

  • Python 3.10 or higher
  • An Anthropic API key (console.anthropic.com)
  • anthropic SDK installed (pip install anthropic)
  • python-dotenv for environment variable management (pip install python-dotenv)
  • Basic familiarity with Python functions and dictionaries
  • ~20 minutes and a code editor

Step 1: Set Up the Anthropic SDK and Environment

First, get your environment wired up correctly. Create a project folder and add a .env file so your API key never touches source control. This is the part most tutorials skip and then you wonder why you get auth errors.

.env
ANTHROPIC_API_KEY=sk-ant-your-key-here

Now install the dependencies and confirm everything loads cleanly before writing a single line of agent logic.

terminal
pip install anthropic python-dotenv
setup_check.py
import os
from dotenv import load_dotenv
import anthropic

load_dotenv()

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

# Quick smoke test — should print the model name without errors
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say 'SDK connected' and nothing else."}]
)

print(response.content[0].text)
# Expected output: SDK connected

If that prints SDK connected, you're good. If it throws an AuthenticationError, double-check that your .env file is in the same directory you're running Python from.

Step 2: Create the Lead Qualifier Agent Class

Now we build the agent class that holds state, owns the Anthropic client, and manages the conversation loop. Think of this class as the "brain" that decides which tools to call and when to stop.

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

load_dotenv()

class LeadQualifierAgent:
    """
    A multi-agent lead qualifier powered by Claude claude-sonnet-4-6.
    Uses tool calls to score, enrich, and route incoming leads autonomously.
    """

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-6"
        self.max_tokens = 1024

        # System prompt defines the agent's role and decision boundaries
        self.system_prompt = """You are an expert lead qualification agent for a B2B software company.

Your job is to evaluate incoming leads by:
1. Calling the score_lead tool to assess lead quality (0-100)
2. Calling the enrich_lead tool to gather additional context
3. Calling the route_lead tool to assign the lead to the correct pipeline

Always call all three tools in order before giving a final summary.
Be decisive. Give clear reasoning for every score and routing decision."""

        # Register the tools the agent can call
        self.tools = self._define_tools()

    def _define_tools(self):
        """Returns the tool schema list that Claude uses to understand available actions."""
        return [
            {
                "name": "score_lead",
                "description": "Scores a lead on a 0-100 scale based on company size, intent signals, budget indicators, and message quality. Returns a numeric score and a short rationale.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "lead_name": {
                            "type": "string",
                            "description": "Full name of the lead"
                        },
                        "company": {
                            "type": "string",
                            "description": "Company or organization name"
                        },
                        "message": {
                            "type": "string",
                            "description": "The raw message or inquiry from the lead"
                        },
                        "score": {
                            "type": "integer",
                            "description": "Lead quality score from 0 (unqualified) to 100 (highly qualified)"
                        },
                        "rationale": {
                            "type": "string",
                            "description": "Brief explanation of why this score was assigned"
                        }
                    },
                    "required": ["lead_name", "company", "message", "score", "rationale"]
                }
            },
            {
                "name": "enrich_lead",
                "description": "Enriches a lead record with inferred industry, estimated company size, likely decision-maker role, and urgency level based on available information.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "company": {
                            "type": "string",
                            "description": "Company name to enrich"
                        },
                        "industry": {
                            "type": "string",
                            "description": "Inferred industry vertical"
                        },
                        "estimated_size": {
                            "type": "string",
                            "enum": ["1-10", "11-50", "51-200", "201-1000", "1000+"],
                            "description": "Estimated employee count range"
                        },
                        "decision_maker_role": {
                            "type": "string",
                            "description": "Most likely role of the person who would approve this purchase"
                        },
                        "urgency": {
                            "type": "string",
                            "enum": ["low", "medium", "high"],
                            "description": "Estimated urgency of the lead's need"
                        }
                    },
                    "required": ["company", "industry", "estimated_size", "decision_maker_role", "urgency"]
                }
            },
            {
                "name": "route_lead",
                "description": "Routes a qualified lead to the appropriate sales pipeline based on score and enrichment data.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "lead_name": {
                            "type": "string",
                            "description": "Full name of the lead"
                        },
                        "pipeline": {
                            "type": "string",
                            "enum": ["enterprise", "mid_market", "smb", "nurture", "disqualified"],
                            "description": "Target pipeline for this lead"
                        },
                        "priority": {
                            "type": "string",
                            "enum": ["urgent", "normal", "low"],
                            "description": "Follow-up priority level"
                        },
                        "assigned_to": {
                            "type": "string",
                            "description": "Sales rep or team name this lead should be assigned to"
                        },
                        "next_action": {
                            "type": "string",
                            "description": "Recommended immediate next action (e.g., 'Schedule discovery call within 24 hours')"
                        }
                    },
                    "required": ["lead_name", "pipeline", "priority", "assigned_to", "next_action"]
                }
            }
        ]

Notice that the tool schemas are explicit about what Claude should return — including the score and rationale fields inside the score_lead tool itself. This is intentional. When Claude calls a tool, it fills those fields with its own reasoning, so you capture the AI's judgment in a structured format you can store in a database.

Step 3: Define the Tool Execution Functions

Claude decides which tool to call and with what arguments, but your Python code actually executes the tool and returns a result. In a real system these functions would hit your CRM, a data enrichment API like Clearbit, or your routing logic. For this tutorial, they process the arguments Claude provides and return confirmation objects.

lead_qualifier.py (continued)
    def _execute_tool(self, tool_name: str, tool_input: dict) -> dict:
        """
        Routes tool calls to their handler functions and returns structured results.
        In production, these handlers would call external APIs or your CRM.
        """
        if tool_name == "score_lead":
            return self._handle_score_lead(tool_input)
        elif tool_name == "enrich_lead":
            return self._handle_enrich_lead(tool_input)
        elif tool_name == "route_lead":
            return self._handle_route_lead(tool_input)
        else:
            return {"error": f"Unknown tool: {tool_name}"}

    def _handle_score_lead(self, inputs: dict) -> dict:
        """
        Processes the score_lead tool call.
        Claude provides the score and rationale — we log and confirm them.
        """
        score = inputs.get("score", 0)
        rationale = inputs.get("rationale", "")

        # In production: write this score to your CRM or database here
        print(f"\n  [TOOL] score_lead called")
        print(f"  → Score: {score}/100")
        print(f"  → Rationale: {rationale}")

        return {
            "status": "success",
            "score_recorded": score,
            "rationale_recorded": rationale,
            "message": f"Lead scored at {score}/100. Score saved to CRM."
        }

    def _handle_enrich_lead(self, inputs: dict) -> dict:
        """
        Processes the enrich_lead tool call.
        Returns the enriched profile back to Claude for use in routing.
        """
        print(f"\n  [TOOL] enrich_lead called")
        print(f"  → Industry: {inputs.get('industry')}")
        print(f"  → Size: {inputs.get('estimated_size')} employees")
        print(f"  → Decision maker: {inputs.get('decision_maker_role')}")
        print(f"  → Urgency: {inputs.get('urgency')}")

        # Return enrichment data so Claude can use it in the routing decision
        return {
            "status": "success",
            "enriched_profile": {
                "company": inputs.get("company"),
                "industry": inputs.get("industry"),
                "estimated_size": inputs.get("estimated_size"),
                "decision_maker_role": inputs.get("decision_maker_role"),
                "urgency": inputs.get("urgency")
            },
            "message": "Enrichment complete. Profile ready for routing."
        }

    def _handle_route_lead(self, inputs: dict) -> dict:
        """
        Processes the route_lead tool call.
        In production: create a deal in your CRM, trigger a Slack alert, send email.
        """
        print(f"\n  [TOOL] route_lead called")
        print(f"  → Pipeline: {inputs.get('pipeline')}")
        print(f"  → Priority: {inputs.get('priority')}")
        print(f"  → Assigned to: {inputs.get('assigned_to')}")
        print(f"  → Next action: {inputs.get('next_action')}")

        return {
            "status": "success",
            "routing_confirmed": {
                "pipeline": inputs.get("pipeline"),
                "priority": inputs.get("priority"),
                "assigned_to": inputs.get("assigned_to"),
                "next_action": inputs.get("next_action")
            },
            "message": f"Lead routed to {inputs.get('pipeline')} pipeline and assigned to {inputs.get('assigned_to')}."
        }

Step 4: Implement the Agent Loop with Tool Use

This is where the real magic happens. The agent loop keeps running until Claude stops requesting tools and gives a final answer. Each iteration processes any tool calls Claude made, sends the results back, and checks if Claude is done. This is the Claude API multi-agent automation pattern you'll reuse across dozens of projects.

lead_qualifier.py (continued — full agent loop)
    def qualify_lead(self, lead_name: str, company: str, message: str) -> str:
        """
        Main entry point. Runs the full qualification loop for a single lead.
        Returns Claude's final summary after all tools have been called.
        """
        print(f"\n{'='*60}")
        print(f"QUALIFYING LEAD: {lead_name} from {company}")
        print(f"{'='*60}")

        # Build the initial user message with all lead data
        messages = [
            {
                "role": "user",
                "content": f"""Please qualify this incoming lead:

Name: {lead_name}
Company: {company}
Message: {message}

Run all three qualification steps (score, enrich, route) and give me a final summary."""
            }
        ]

        # Agent loop — runs until Claude stops calling tools
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=self.system_prompt,
                tools=self.tools,
                messages=messages
            )

            # If Claude is done (no more tool calls), return the final text
            if response.stop_reason == "end_turn":
                final_text = ""
                for block in response.content:
                    if hasattr(block, "text"):
                        final_text = block.text
                return final_text

            # Process any tool calls Claude made in this turn
            tool_results = []
            has_tool_use = False

            for block in response.content:
                if block.type == "tool_use":
                    has_tool_use = True
                    tool_result = self._execute_tool(block.name, block.input)

                    # Package the result in the format the API expects
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(tool_result)
                    })

            # If no tool calls were found but stop_reason isn't end_turn,
            # extract any text and return it to avoid an infinite loop
            if not has_tool_use:
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text
                return "Qualification complete."

            # Add 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})
⚠️ Important: The messages.append({"role": "assistant", "content": response.content}) line appends the raw content blocks, not just text. This is required by the Anthropic API when there are tool use blocks in the response. Sending only the text will cause a 400 error on the next turn.

Step 5: Test With Sample Leads

Now we wire up the agent and run it against three different leads — a strong enterprise lead, a mid-market maybe, and a low-quality inquiry. This shows you how the agent reasons differently based on the actual content of each lead's message.

main.py
import os
from dotenv import load_dotenv
from lead_qualifier import LeadQualifierAgent

load_dotenv()

def main():
    agent = LeadQualifierAgent()

    # Sample leads — realistic variety to show different routing outcomes
    sample_leads = [
        {
            "name": "Jennifer Torres",
            "company": "Coastal Realty Group",
            "message": "Hi, we're a 200-agent brokerage in Southwest Florida and we need to automate our listing process. We're losing deals because our agents spend 4 hours per listing on descriptions and SEO. Budget isn't an issue — we need this solved in Q3. Who do I talk to?"
        },
        {
            "name": "Mike Patel",
            "company": "Patel Family Dental",
            "message": "We have 2 locations and I've been reading about AI chatbots for patient scheduling. Not sure if it makes sense for us but wanted to learn more. Maybe a call sometime?"
        },
        {
            "name": "Anonymous",
            "company": "Unknown",
            "message": "can you make me an ai for free"
        }
    ]

    results = []

    for lead in sample_leads:
        summary = agent.qualify_lead(
            lead_name=lead["name"],
            company=lead["company"],
            message=lead["message"]
        )

        print(f"\n{'─'*60}")
        print("FINAL AGENT SUMMARY:")
        print(summary)
        print(f"{'─'*60}\n")

        results.append({
            "lead": lead["name"],
            "company": lead["company"],
            "summary": summary
        })

    return results

if __name__ == "__main__":
    main()

Here's what the actual terminal output looks like when you run this against the first lead:

sample output (terminal)
============================================================
QUALIFYING LEAD: Jennifer Torres from Coastal Realty Group
============================================================

  [TOOL] score_lead called
  → Score: 91/100
  → Rationale: High-volume brokerage with 200 agents, specific pain point
    (4 hours per listing), explicit Q3 deadline, budget pre-approved.
    Strong buying signals throughout the message.

  [TOOL] enrich_lead called
  → Industry: Real Estate
  → Size: 201-1000 employees
  → Decision maker: VP of Operations / Broker-Owner
  → Urgency: high

  [TOOL] route_lead called
  → Pipeline: enterprise
  → Priority: urgent
  → Assigned to: Senior Account Executive
  → Next action: Schedule discovery call within 24 hours

────────────────────────────────────────────────────────────
FINAL AGENT SUMMARY:
Jennifer Torres from Coastal Realty Group is a high-priority enterprise lead
(score: 91/100). She leads a 200-agent brokerage with a clear, quantified
pain point — 4 hours per listing lost to manual work. She has an explicit Q3
deadline and has pre-approved budget. I've routed her to the enterprise
pipeline with urgent priority and assigned her to a Senior Account Executive.
Recommended next action: schedule a discovery call within 24 hours before
she evaluates competitors.
────────────────────────────────────────────────────────────

How It Works: Agent Reasoning and Tool Orchestration

Here's what's actually happening under the hood when you call qualify_lead(). Claude receives the lead data and your system prompt, then decides on its own to call score_lead first — it doesn't need to be told the order explicitly, just that all three tools should be used. The tool call arrives as a structured block in the API response with a unique tool_use_id.

Your Python code executes the tool function, gets the result, and sends it back to Claude tagged with that same ID. Claude processes the result as part of its context and decides what to call next. This continues until Claude has enough information to stop calling tools and write its final summary — that's when stop_reason becomes end_turn.

The key insight is that Claude is doing the orchestration. Your code just provides the tools and handles the message loop. This is fundamentally different from a hard-coded workflow — the agent adapts its reasoning based on what each tool returns.

Common Errors and Fixes

Error 1: anthropic.BadRequestError: messages.2.content.0 is missing

anthropic.BadRequestError: Error code: 400 - {'type': 'error', 'error':
{'type': 'invalid_request_error', 'message': 'messages: roles must alternate
between "user" and "assistant"'}}

What causes it: You appended only text to the messages list when Claude's response contained tool use blocks. The API expects the full content list, not just extracted text.

Fix: Always append response.content (the full list of blocks) as the assistant turn, not response.content[0].text.