All articles

What You'll Build

By the end of this tutorial, you'll have a working AI lead scorer agent in Python that takes raw lead data, reasons through it using Claude's tool use API, and returns a numeric score plus a plain-English recommendation. The agent handles multi-turn context, calls custom evaluation tools, and runs an autonomous agentic loop without you touching it in between. Under 200 lines of clean Python, production-ready from the start.

Full Source Code Note: The complete working code is broken across the steps below, each building on the last. By Step 5 you'll have everything assembled. If you want to copy the full script in one shot, each snippet is designed to slot together without modification.

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: pip install anthropic
  • A .env file or exported environment variable for ANTHROPIC_API_KEY

Step 1: Set Up Your Claude API Client and Environment

Start by creating a new file called lead_scorer.py. The first thing you need is the client initialization and a clean class structure to hold everything together.

I like wrapping agents in a class from the beginning. It makes it much easier to add state, logging, and configuration later without refactoring everything.

lead_scorer.py
import os
import json
from anthropic import Anthropic

class LeadScorerAgent:
    """
    An autonomous agent that scores and qualifies sales leads
    using Claude's tool use API.
    """

    def __init__(self):
        # Initialize the Anthropic client using ANTHROPIC_API_KEY from environment
        self.client = Anthropic()
        self.model = "claude-sonnet-4-6"
        self.conversation_history = []
        self.system_prompt = """
You are an expert B2B sales analyst specializing in lead qualification.
Your job is to evaluate incoming leads using the tools available to you.
Always call the appropriate tools to gather a complete picture before
assigning a final score. Be objective, data-driven, and concise in your
reasoning. Return scores as integers between 0 and 100.
"""

    def reset_conversation(self):
        """Clear conversation history between unrelated leads."""
        self.conversation_history = []

The conversation_history list is what enables multi-turn context later in Step 5. Resetting it between unrelated leads keeps the agent focused and avoids score contamination between separate prospects.

Tip: Never hardcode your API key. Use os.environ.get("ANTHROPIC_API_KEY") or a .env file loaded with python-dotenv. The Anthropic SDK reads the key automatically from the environment if it's set.

Step 2: Define Lead Scoring Tools and JSON Schema

Tools are how you give Claude the ability to take real actions, not just talk about them. Each tool definition tells Claude what the tool does, what inputs it expects, and which fields are required.

For lead scoring, I'm defining three tools: one to evaluate budget fit, one to assess company size and role authority, and one to assign the final composite score. This separation keeps reasoning modular and easy to debug.

lead_scorer.py (continued)
    def get_tool_definitions(self):
        """
        Returns the list of tools Claude can call during lead evaluation.
        Each tool maps to a local method on this class.
        """
        return [
            {
                "name": "evaluate_budget_fit",
                "description": (
                    "Evaluates whether the lead's stated or implied budget "
                    "aligns with our service pricing. Returns a budget fit "
                    "score from 0 to 100 and a short explanation."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "annual_revenue": {
                            "type": "number",
                            "description": "Lead's estimated or stated annual revenue in USD"
                        },
                        "stated_budget": {
                            "type": "number",
                            "description": "Lead's stated project budget in USD, if provided"
                        },
                        "industry": {
                            "type": "string",
                            "description": "Lead's industry vertical"
                        }
                    },
                    "required": ["annual_revenue", "industry"]
                }
            },
            {
                "name": "evaluate_authority_and_fit",
                "description": (
                    "Evaluates the lead's decision-making authority and "
                    "whether their company size and role suggest they can "
                    "approve a purchase. Returns an authority score 0-100."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "job_title": {
                            "type": "string",
                            "description": "Lead's job title or role"
                        },
                        "company_size": {
                            "type": "string",
                            "description": "Number of employees or company size tier"
                        },
                        "is_decision_maker": {
                            "type": "boolean",
                            "description": "Whether the lead self-identified as a decision maker"
                        }
                    },
                    "required": ["job_title", "company_size"]
                }
            },
            {
                "name": "assign_final_score",
                "description": (
                    "Combines all evaluation results into a final lead score "
                    "and qualification tier. Call this last, after running "
                    "the other evaluation tools."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "budget_score": {
                            "type": "number",
                            "description": "Score from evaluate_budget_fit"
                        },
                        "authority_score": {
                            "type": "number",
                            "description": "Score from evaluate_authority_and_fit"
                        },
                        "urgency_notes": {
                            "type": "string",
                            "description": "Any urgency signals from the lead's message"
                        }
                    },
                    "required": ["budget_score", "authority_score"]
                }
            }
        ]

Notice that each tool has a clear description written for Claude to read, not for you. Claude decides which tool to call based on that natural-language description, so make it specific about when and why the tool should be used.

Step 3: Create the Core Agent Loop with Tool Use

This is the heart of the agent. The agentic loop runs until Claude either finishes its reasoning or hits a maximum iteration limit you set. Without a limit, a misbehaving agent can burn API credits in a runaway loop.

Here's how it works: you send a message, Claude responds with either a final answer or a tool call request, you execute the tool and send the result back, and you repeat until Claude is done.

lead_scorer.py (continued)
    def execute_tool(self, tool_name: str, tool_input: dict) -> str:
        """
        Routes tool calls to the appropriate local method and
        returns results as a JSON string for Claude to read.
        """
        if tool_name == "evaluate_budget_fit":
            return json.dumps(self._evaluate_budget_fit(**tool_input))
        elif tool_name == "evaluate_authority_and_fit":
            return json.dumps(self._evaluate_authority_and_fit(**tool_input))
        elif tool_name == "assign_final_score":
            return json.dumps(self._assign_final_score(**tool_input))
        else:
            return json.dumps({"error": f"Unknown tool: {tool_name}"})

    def run_agent_loop(self, user_message: str, max_iterations: int = 10) -> str:
        """
        Runs the agentic loop. Sends the user message, handles tool calls,
        and returns Claude's final text response.
        """
        # Append the new user message to conversation history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        iteration = 0

        while iteration < max_iterations:
            iteration += 1

            response = self.client.messages.create(
                model=self.model,
                max_tokens=4096,
                system=self.system_prompt,
                tools=self.get_tool_definitions(),
                messages=self.conversation_history
            )

            # If Claude is done reasoning, 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
                # Store assistant response in history for multi-turn support
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })
                return final_text

            # If Claude wants to use tools, process each tool call
            if response.stop_reason == "tool_use":
                # Add Claude's response (including tool use blocks) to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })

                # Build the tool results to send back
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        tool_output = self.execute_tool(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": tool_output
                        })

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

        return "Agent reached maximum iterations without completing."

The key pattern here is that tool results go back in as a user role message with type tool_result. This is the Anthropic SDK's required format. Get this wrong and you'll hit a validation error immediately.

Important: Always append Claude's full response content block (not just the text) to conversation history when handling tool calls. Claude needs to see its own tool use blocks to correctly reference the results you send back.

Step 4: Process and Score Sample Leads

Now you need the actual tool logic and a way to run the agent against real lead data. These tool implementations are intentionally simple scoring functions. In a real deployment, they'd hit your CRM or pull from a database.

lead_scorer.py (continued)
    def _evaluate_budget_fit(
        self,
        annual_revenue: float,
        industry: str,
        stated_budget: float = None
    ) -> dict:
        """Scores budget alignment against typical service pricing."""
        score = 0
        explanation = ""

        # Use stated budget if available, otherwise estimate from revenue
        budget_signal = stated_budget if stated_budget else annual_revenue * 0.02

        if budget_signal >= 50000:
            score = 90
            explanation = "High budget signal, strong fit for enterprise packages."
        elif budget_signal >= 15000:
            score = 70
            explanation = "Mid-range budget, fits standard service tiers well."
        elif budget_signal >= 5000:
            score = 45
            explanation = "Lower budget range, may fit starter packages."
        else:
            score = 15
            explanation = "Budget signal too low for current service minimums."

        # Bonus for high-value industries we specialize in
        high_value_industries = ["real estate", "healthcare", "manufacturing", "automotive"]
        if any(ind in industry.lower() for ind in high_value_industries):
            score = min(score + 10, 100)
            explanation += f" Industry ({industry}) is a priority vertical."

        return {"budget_score": score, "explanation": explanation}

    def _evaluate_authority_and_fit(
        self,
        job_title: str,
        company_size: str,
        is_decision_maker: bool = False
    ) -> dict:
        """Scores decision-making authority based on title and company size."""
        score = 50  # baseline

        title_lower = job_title.lower()

        # C-suite and VP roles score highest
        if any(t in title_lower for t in ["ceo", "coo", "cto", "president", "owner", "founder"]):
            score = 95
        elif any(t in title_lower for t in ["vp", "vice president", "director", "head of"]):
            score = 80
        elif any(t in title_lower for t in ["manager", "lead", "supervisor"]):
            score = 60
        else:
            score = 35

        # Self-identified decision makers get a small boost
        if is_decision_maker:
            score = min(score + 10, 100)

        return {
            "authority_score": score,
            "job_title_assessed": job_title,
            "company_size": company_size
        }

    def _assign_final_score(
        self,
        budget_score: float,
        authority_score: float,
        urgency_notes: str = ""
    ) -> dict:
        """Combines sub-scores into a final lead score and tier label."""
        # Weighted average: budget 50%, authority 50%
        composite = (budget_score * 0.5) + (authority_score * 0.5)

        # Urgency signals bump the score slightly
        if urgency_notes and any(
            word in urgency_notes.lower()
            for word in ["asap", "urgent", "immediately", "this quarter", "this month"]
        ):
            composite = min(composite + 8, 100)

        composite = round(composite)

        if composite >= 75:
            tier = "Hot"
            action = "Assign to senior sales rep. Follow up within 24 hours."
        elif composite >= 50:
            tier = "Warm"
            action = "Add to nurture sequence. Follow up within 3 business days."
        elif composite >= 30:
            tier = "Cool"
            action = "Add to long-term email list. Re-evaluate in 90 days."
        else:
            tier = "Cold"
            action = "Do not pursue at this time. Archive lead."

        return {
            "final_score": composite,
            "tier": tier,
            "recommended_action": action
        }


def score_lead(lead_data: dict) -> str:
    """
    Convenience function to instantiate the agent and score a single lead.
    """
    agent = LeadScorerAgent()
    prompt = f"""
Please evaluate this incoming sales lead and provide a final score with your recommendation.

Lead Information:
- Name: {lead_data.get('name', 'Unknown')}
- Job Title: {lead_data.get('job_title', 'Unknown')}
- Company: {lead_data.get('company', 'Unknown')}
- Industry: {lead_data.get('industry', 'Unknown')}
- Company Size: {lead_data.get('company_size', 'Unknown')}
- Annual Revenue (estimated): ${lead_data.get('annual_revenue', 0):,}
- Stated Budget: ${lead_data.get('stated_budget', 0):,}
- Is Decision Maker: {lead_data.get('is_decision_maker', False)}
- Lead Message: {lead_data.get('message', 'No message provided')}

Use your evaluation tools to assess this lead fully before giving a final recommendation.
"""
    return agent.run_agent_loop(prompt)


if __name__ == "__main__":
    # Sample leads to test the agent
    sample_leads = [
        {
            "name": "Maria Gonzalez",
            "job_title": "Owner",
            "company": "Coastal Realty Group",
            "industry": "Real Estate",
            "company_size": "25 employees",
            "annual_revenue": 4200000,
            "stated_budget": 18000,
            "is_decision_maker": True,
            "message": "We need AI listing automation ASAP before the spring season hits."
        },
        {
            "name": "Derek Holt",
            "job_title": "Marketing Coordinator",
            "company": "Gulf Coast Auto",
            "industry": "Automotive",
            "company_size": "12 employees",
            "annual_revenue": 800000,
            "stated_budget": 2500,
            "is_decision_maker": False,
            "message": "Curious about chatbot pricing, just gathering info right now."
        }
    ]

    for lead in sample_leads:
        print(f"\n{'='*60}")
        print(f"Scoring lead: {lead['name']} at {lead['company']}")
        print("="*60)
        result = score_lead(lead)
        print(result)

Run this with python lead_scorer.py and you'll see Claude work through both leads, calling tools in sequence before landing on a final score. Here's what the output looks like:

Example Output
============================================================
Scoring lead: Maria Gonzalez at Coastal Realty Group
============================================================
After evaluating Maria Gonzalez using our scoring tools, here is the full assessment:

Budget Fit Score: 80/100
Maria's stated budget of $18,000 aligns well with our standard service tiers,
and real estate is one of our priority verticals. Strong budget signal overall.

Authority Score: 95/100
As the Owner of the company, Maria has full decision-making authority.
No internal approval chain to navigate.

Final Lead Score: 88/100
Tier: HOT
Urgency detected: "ASAP" language indicates high purchase intent.

Recommended Action: Assign to senior sales rep. Follow up within 24 hours.

============================================================
Scoring lead: Derek Holt at Gulf Coast Auto
============================================================
After evaluating Derek Holt using our scoring tools, here is the full assessment:

Budget Fit Score: 35/100
The stated budget of $2,500 falls below our typical service minimums.
Automotive is a priority vertical, which adds a small bonus, but budget
remains the primary limiting factor.

Authority Score: 35/100
Marketing Coordinator is not a decision-making role. This contact would
need to escalate any purchase to a manager or owner.

Final Lead Score: 35/100
Tier: COOL

Recommended Action: Add to long-term email list. Re-evaluate in 90 days.

Step 5: Handle Multi-Turn Conversations for Lead Context

Sometimes you need to ask follow-up questions before scoring. The agent already stores conversation history, so adding follow-up context is straightforward. Here's how to run a multi-turn session with the same lead.

multi_turn_example.py
import os
from lead_scorer import LeadScorerAgent

def run_multi_turn_session():
    """
    Demonstrates multi-turn lead qualification where additional context
    is gathered before producing a final score.
    """
    agent = LeadScorerAgent()

    # First turn: provide initial lead info
    first_message = """
I have a new lead to evaluate:
- Name: James Thornton
- Job Title: Operations Director
- Company: Suncoast Medical Group
- Industry: Healthcare
- Company Size: 200 employees
- Annual Revenue: $12,000,000
- Message: "We're exploring AI options for our patient intake process."

Can you start an initial evaluation?
"""

    print("TURN 1:")
    print("-" * 40)
    response_1 = agent.run_agent_loop(first_message)
    print(response_1)

    # Second turn: provide additional context without resetting history
    followup_message = """
I just got more information from the lead:
- They confirmed James is the final decision maker for technology purchases.
- Their stated budget is $45,000 for this project.
- They said they want to launch before Q1 next year, which is 8 weeks away.

Please update your evaluation and give a final score with this new context.
"""

    print("\nTURN 2 (with updated context):")
    print("-" * 40)
    # No reset_conversation() call here — history carries forward
    response_2 = agent.run_agent_loop(followup_message)
    print(response_2)


if __name__ == "__main__":
    run_multi_turn_session()

Because conversation_history persists between calls to run_agent_loop, Claude remembers everything from the first turn. You don't need to resend the original lead data in the follow-up message.

How It Works

Here's the plain-English version of what's happening under the hood. You send Claude a lead profile in natural language, and Claude reads your tool definitions to figure out which tools to call first. It calls them in a logical order, evaluating budget before assigning a final composite score.

Each tool call comes back to your Python code, which runs the actual scoring logic and returns a JSON result. Claude reads that result and decides whether it has enough information to finish, or whether to call another tool. This loop continues until Claude produces a final text response with stop_reason: "end_turn".

The conversation history list is what connects all the turns together. Every message, every tool call, every tool result gets appended in order. Claude sees the entire thread on each API call, which is how it maintains context across turns without you doing anything special.

Common Errors and Fixes

Error 1: