All articles

What You'll Build

If you've been searching for a real, working example of how to build AI agents in Python, this is it. By the end of this tutorial, you'll have a fully functional AI lead qualifier agent that scores inbound leads, asks follow-up questions, and outputs a structured qualification report, all powered by the Claude API.

The agent uses tool use (function calling) and an agentic loop to handle multi-turn conversations with leads. This is exactly the kind of system we build for real estate agencies, car dealerships, and service businesses here in Southwest Florida at Naples AI.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and dictionaries
  • The anthropic SDK installed (pip install anthropic)
  • A .env file or environment variable set for ANTHROPIC_API_KEY
Full Source Code Notice: The complete, working code for this agent is built step by step in the sections below. Every snippet connects to the next, so by Step 5 you'll have everything you need to run it. You can copy each block in order or assemble them into one file as you go.

Step 1: Set Up Your Claude API Environment and Imports

Start by installing the Anthropic SDK if you haven't already. Run pip install anthropic python-dotenv in your terminal. Then create a file called lead_qualifier.py and add the following imports at the top.

We're pulling in json for tool result formatting, os for environment variables, and anthropic for the Claude client. The dataclasses module keeps our lead data clean and typed.

lead_qualifier.py
import os
import json
from dataclasses import dataclass, field
from typing import Any
import anthropic
from dotenv import load_dotenv

load_dotenv()

# Initialize the Anthropic client using ANTHROPIC_API_KEY from your environment
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

MODEL = "claude-sonnet-4-6"

@dataclass
class Lead:
    name: str
    email: str
    phone: str
    source: str
    responses: dict = field(default_factory=dict)
    score: int = 0
    qualification_status: str = "unqualified"
    notes: str = ""

The Lead dataclass is the object we pass through the agent. It holds the raw contact info plus everything the agent learns during qualification, including the final score and status.

Tip: Keep your API key in a .env file and never hardcode it. If you accidentally commit it to GitHub, Anthropic will automatically revoke it.

Step 2: Define Lead Qualification Tools and Scoring Criteria

This is where the real work happens. Claude uses tools to perform structured actions during a conversation, things like scoring a lead or flagging it for human review. Think of tools as functions Claude is allowed to call when it decides they're needed.

We're defining three tools: one to score the lead based on their answers, one to flag a lead for human review, and one to finalize the qualification report. The scoring criteria here are tuned for a service business, but you can adjust the weights for any industry.

lead_qualifier.py (continued)
QUALIFICATION_TOOLS = [
    {
        "name": "score_lead",
        "description": (
            "Scores a lead based on their qualification responses. "
            "Call this after collecting answers about budget, timeline, and decision authority."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "budget_score": {
                    "type": "integer",
                    "description": "Score 0-40 based on budget fit. 40=ideal budget, 0=no budget.",
                },
                "timeline_score": {
                    "type": "integer",
                    "description": "Score 0-30 based on urgency. 30=ready now, 0=no timeline.",
                },
                "authority_score": {
                    "type": "integer",
                    "description": "Score 0-20 if they are the decision maker. 20=yes, 0=no.",
                },
                "fit_score": {
                    "type": "integer",
                    "description": "Score 0-10 based on how well their need matches our services.",
                },
                "reasoning": {
                    "type": "string",
                    "description": "A brief explanation of the scoring rationale.",
                },
            },
            "required": ["budget_score", "timeline_score", "authority_score", "fit_score", "reasoning"],
        },
    },
    {
        "name": "flag_for_human_review",
        "description": "Flags this lead for a human sales rep to review before any further action.",
        "input_schema": {
            "type": "object",
            "properties": {
                "reason": {
                    "type": "string",
                    "description": "Why this lead needs human review.",
                }
            },
            "required": ["reason"],
        },
    },
    {
        "name": "finalize_qualification",
        "description": "Finalizes the lead qualification and sets the outcome status.",
        "input_schema": {
            "type": "object",
            "properties": {
                "status": {
                    "type": "string",
                    "enum": ["hot", "warm", "cold", "disqualified"],
                    "description": "Final qualification status of the lead.",
                },
                "recommended_action": {
                    "type": "string",
                    "description": "What the sales team should do next with this lead.",
                },
                "summary": {
                    "type": "string",
                    "description": "A 2-3 sentence summary of the lead for the sales team.",
                },
            },
            "required": ["status", "recommended_action", "summary"],
        },
    },
]


def execute_tool(tool_name: str, tool_input: dict, lead: Lead) -> str:
    """Executes the tool called by Claude and updates the lead object."""

    if tool_name == "score_lead":
        lead.score = (
            tool_input["budget_score"]
            + tool_input["timeline_score"]
            + tool_input["authority_score"]
            + tool_input["fit_score"]
        )
        lead.notes += f"Scoring reasoning: {tool_input['reasoning']}\n"
        return json.dumps({"success": True, "total_score": lead.score})

    elif tool_name == "flag_for_human_review":
        lead.qualification_status = "needs_review"
        lead.notes += f"Flagged for review: {tool_input['reason']}\n"
        return json.dumps({"success": True, "flagged": True})

    elif tool_name == "finalize_qualification":
        lead.qualification_status = tool_input["status"]
        lead.notes += (
            f"Recommended action: {tool_input['recommended_action']}\n"
            f"Summary: {tool_input['summary']}\n"
        )
        return json.dumps({"success": True, "status": tool_input["status"]})

    return json.dumps({"error": f"Unknown tool: {tool_name}"})

The execute_tool function is what actually runs when Claude decides to call a tool. It takes the tool name, the arguments Claude passed, and the lead object, then updates the lead in place and returns a result string back to Claude.

Step 3: Build the AI Agent Class with Tool Use Capability

Now we wrap everything into an agent class. The agent holds the conversation history, the system prompt, and the logic for sending messages to Claude. Keeping this in a class makes it easy to run one agent per lead without state bleeding between conversations.

The system prompt is doing a lot of work here. It tells Claude exactly what role it's playing, what tools it has, and how to use them. Spend time on your system prompt; it's the difference between an agent that works and one that hallucinates actions.

lead_qualifier.py (continued)
SYSTEM_PROMPT = """You are a professional lead qualification agent for Naples AI, 
a custom AI solutions agency in Southwest Florida. Your job is to have a brief, 
friendly conversation with an inbound lead to assess their fit for our services.

Ask the following qualification questions naturally, one at a time:
1. What specific problem are they trying to solve with AI?
2. What is their approximate budget for this project?
3. What is their timeline for getting started?
4. Are they the final decision maker, or is someone else involved?

After collecting their answers, use the score_lead tool to calculate their score.
Then use finalize_qualification to set their status:
- hot: score 80-100 (strong fit, ready to move forward)
- warm: score 50-79 (good potential, needs nurturing)
- cold: score 20-49 (early stage, not ready yet)
- disqualified: score 0-19 (not a fit)

If they give inconsistent or suspicious answers, use flag_for_human_review first.
Keep your responses concise and conversational. Never be pushy."""


class LeadQualifierAgent:
    def __init__(self, lead: Lead):
        self.lead = lead
        self.messages: list[dict] = []
        self.is_complete = False

    def _send_to_claude(self, user_message: str) -> anthropic.types.Message:
        """Sends the current message history to Claude and returns the response."""
        self.messages.append({"role": "user", "content": user_message})

        response = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            system=SYSTEM_PROMPT,
            tools=QUALIFICATION_TOOLS,
            messages=self.messages,
        )
        return response

    def _handle_tool_use(self, response: anthropic.types.Message) -> str | None:
        """Processes any tool calls in the response and returns the next assistant message."""
        tool_results = []

        for block in response.content:
            if block.type == "tool_use":
                result = execute_tool(block.name, block.input, self.lead)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })

                # Mark agent as complete when finalization tool is called
                if block.name == "finalize_qualification":
                    self.is_complete = True

        if not tool_results:
            return None

        # Add the assistant's tool-calling response to history
        self.messages.append({"role": "assistant", "content": response.content})

        # Send tool results back to Claude to get its next response
        follow_up = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            system=SYSTEM_PROMPT,
            tools=QUALIFICATION_TOOLS,
            messages=self.messages + [{"role": "user", "content": tool_results}],
        )

        self.messages.append({"role": "user", "content": tool_results})
        return follow_up

    def chat(self, user_message: str) -> str:
        """Main entry point for sending a message and getting the agent's reply."""
        response = self._send_to_claude(user_message)

        # If Claude wants to use tools, handle that first
        if response.stop_reason == "tool_use":
            follow_up = self._handle_tool_use(response)
            if follow_up:
                final_text = next(
                    (block.text for block in follow_up.content if hasattr(block, "text")), ""
                )
                self.messages.append({"role": "assistant", "content": follow_up.content})
                return final_text

        # Standard text response
        reply = next(
            (block.text for block in response.content if hasattr(block, "text")), ""
        )
        self.messages.append({"role": "assistant", "content": response.content})
        return reply

The chat method is the public interface. Every time a lead sends a message, you call chat() with their input and get back Claude's response. The tool handling happens automatically inside the agent, invisible to whoever is calling it.

Step 4: Implement the Agentic Loop for Multi-Turn Conversations

The agentic loop is what makes this a real agent instead of a one-shot prompt. It keeps the conversation going, processes tool calls, and knows when to stop. The loop runs until Claude calls finalize_qualification or until we hit a max turn limit as a safety net.

In a real product, you'd replace the input() calls with your CRM webhook or chatbot frontend. The loop logic stays the same either way.

lead_qualifier.py (continued)
def run_qualification_session(lead: Lead, simulated_responses: list[str] | None = None) -> Lead:
    """
    Runs a full qualification conversation for a lead.
    If simulated_responses is provided, uses those instead of real user input
    (useful for testing and batch processing).
    """
    agent = LeadQualifierAgent(lead)
    max_turns = 12
    turn = 0

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

    # Kick off the conversation with the lead's initial inquiry
    opening_message = (
        f"Hi, I'm {lead.name}. I found you through {lead.source} "
        f"and I'm interested in learning more about your AI services."
    )

    response = agent.chat(opening_message)
    print(f"Agent: {response}\n")

    response_index = 0

    while not agent.is_complete and turn < max_turns:
        turn += 1

        if simulated_responses and response_index < len(simulated_responses):
            user_input = simulated_responses[response_index]
            response_index += 1
            print(f"Lead: {user_input}")
        else:
            user_input = input("Lead: ").strip()
            if not user_input:
                continue

        if user_input.lower() in ("quit", "exit", "stop"):
            print("Session ended by user.")
            break

        reply = agent.chat(user_input)
        print(f"\nAgent: {reply}\n")

    if agent.is_complete:
        print(f"\n{'='*60}")
        print("QUALIFICATION COMPLETE")
        print(f"{'='*60}")
        print(f"Lead:    {lead.name}")
        print(f"Score:   {lead.score}/100")
        print(f"Status:  {lead.qualification_status.upper()}")
        print(f"Notes:\n{lead.notes}")

    return lead

The simulated_responses parameter is what lets us test this without a live human on the other end. Pass in a list of strings and the loop will use them in order, which is perfect for batch processing leads from a CRM export.

Step 5: Process Sample Leads and Generate Scores

Let's run the agent against a sample lead so you can see exactly what it produces. The simulated responses below represent a strong lead, someone with budget, urgency, and decision-making authority.

lead_qualifier.py (continued)
if __name__ == "__main__":
    # Create a sample lead to qualify
    sample_lead = Lead(
        name="Maria Gonzalez",
        email="[email protected]",
        phone="239-555-0187",
        source="Google Search",
    )

    # Simulated lead responses for testing
    simulated_answers = [
        "We're a real estate brokerage and we want to automate our listing descriptions "
        "and lead follow-up emails. We're spending about 15 hours a week on this manually.",
        "We have a budget of around $8,000 to $12,000 for the initial build.",
        "We'd like to get this done within the next 30 to 45 days before the busy season.",
        "Yes, I'm the broker-owner. I make all technology decisions for the company.",
        "We have about 12 agents and we handle around 40 to 60 listings per month.",
    ]

    result = run_qualification_session(sample_lead, simulated_responses=simulated_answers)

    # Final lead object is returned with all data populated
    print("\nFinal Lead Object:")
    print(f"  Name:    {result.name}")
    print(f"  Email:   {result.email}")
    print(f"  Score:   {result.score}")
    print(f"  Status:  {result.qualification_status}")

Here's what the output looks like when you run this against a strong lead like Maria:

sample output
============================================================
Starting qualification for: Maria Gonzalez ([email protected])
============================================================

Agent: Hi Maria! Thanks for reaching out to Naples AI. I'd love to learn more
about how we might be able to help. Could you tell me a bit more about the
specific problem you're looking to solve with AI?

Lead: We're a real estate brokerage and we want to automate our listing descriptions
and lead follow-up emails. We're spending about 15 hours a week on this manually.

Agent: That's a great use case, and it's one we've helped several brokerages with.
Automating listing copy and follow-up sequences can save your team a significant
amount of time every week. What's your approximate budget for a project like this?

Lead: We have a budget of around $8,000 to $12,000 for the initial build.

Agent: Perfect, that's a realistic range for what you're describing. What's your
timeline for getting started, and is there a specific deadline driving that?

Lead: We'd like to get this done within the next 30 to 45 days before the busy season.

Agent: That's a tight but achievable timeline. One quick question: are you the
final decision maker on this, or is there someone else you'd need to loop in
before moving forward?

Lead: Yes, I'm the broker-owner. I make all technology decisions for the company.

Agent: Great to know. And just to make sure we scope this correctly — how many
agents do you have, and roughly how many listings do you handle per month?

Lead: We have about 12 agents and we handle around 40 to 60 listings per month.

Agent: Thank you, Maria. Based on everything you've shared, it sounds like you're
a strong fit for our real estate automation services. I'm going to flag your
information for our team, and someone will reach out within one business day to
set up a discovery call. Sound good?

============================================================
QUALIFICATION COMPLETE
============================================================
Lead:    Maria Gonzalez
Score:   92/100
Status:  HOT
Notes:
Scoring reasoning: Strong budget ($8-12k), immediate timeline (30-45 days),
sole decision maker (broker-owner), and clear high-volume use case (40-60 listings/month).
Recommended action: Book a discovery call within 24 hours. Send case study for
real estate listing automation before the call.
Summary: Maria is a broker-owner with a clear automation need, realistic budget,
and an urgent timeline tied to the busy season. High close probability.

Final Lead Object:
  Name:    Maria Gonzalez
  Email:   [email protected]
  Score:   92
  Status:  hot

How It Works: Agent Architecture and Decision Flow

Let me walk you through what's actually happening under the hood, because it's not magic, it's just a well-designed loop. When you call agent.chat(), the message history gets sent to Claude along with the tool definitions and system prompt.

Claude reads the conversation and decides whether to respond with text or call a tool. If it calls a tool, we execute that tool locally in Python, then send the result back to Claude. Claude reads the result and either calls another tool or responds with text.

This back-and-forth continues until Claude calls finalize_qualification, which sets agent.is_complete = True and breaks the loop. The whole thing is stateless on Claude's side; we're maintaining state in the Lead object and the messages list on our end.

Why this matters: This same agentic loop pattern works for any task-driven agent. Lead qualification, document review, customer support triage, appointment booking. The tools and system prompt change, but the loop structure stays the same. Once you understand this pattern, you can build almost anything.

Common Errors and Fixes

Error 1: AuthenticationError on API key

You'll see this one early if your environment variable isn't loading correctly.

error message
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}

The fix is to make sure your .env file is in the same directory you're running the script from, and that you're calling load_dotenv() before initializing the client. Double-check the variable name is