All articles

If you've searched for how to build AI agents and landed on a pile of vague theory with no working code, I get it. That's exactly what I ran into when I started building autonomous agents for clients here in Southwest Florida. This tutorial skips the fluff and gives you a fully working AI agent in Python using the Claude API, from scratch, in under 30 minutes.

By the end, you'll have an agent that can reason, choose tools, execute actions, and loop until a task is complete. That's the foundation behind every autonomous AI system we build at Naples AI.

What You'll Build

You're going to build a lead qualification agent that takes a prospect's information, scores their fit, looks up relevant data, and produces a structured qualification report. It uses Claude's tool use feature to call functions, processes the results, and loops autonomously until the job is done.

This isn't a toy demo. The same pattern powers real agents we've deployed for real estate teams and car dealerships in the Naples area. You'll walk away with code you can actually adapt for your own use case.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic Python knowledge (functions, classes, dictionaries)
  • The anthropic Python SDK installed (pip install anthropic)
  • A terminal and a code editor
Full Source Code Note: The complete working agent is built piece by piece in the steps below. Each snippet builds on the last, and Step 8 shows you the final combined file with sample output. If you want to jump straight to the finished version, scroll to Step 8.

Step 1: Set Up Your Claude API Environment and Authentication

First, install the Anthropic SDK if you haven't already. Open your terminal and run pip install anthropic. Then create a .env file in your project folder to store your API key safely.

You'll also want python-dotenv so your script can read that file. Run pip install python-dotenv and you're set.

.env
ANTHROPIC_API_KEY=your_api_key_here

Now let's write the initialization code. This sets up your client and defines the model you'll use throughout the agent.

agent_config.py
import os
import anthropic
from dotenv import load_dotenv

load_dotenv()

# Initialize the Anthropic client using the API key from your .env file
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# We use claude-sonnet-4-6 for the right balance of speed and reasoning
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 4096

# System prompt that defines the agent's role and behavior
SYSTEM_PROMPT = """You are a lead qualification agent for a real estate agency.
Your job is to evaluate incoming leads, gather relevant information using your tools,
score each lead on a scale of 1-10, and produce a structured qualification report.
Always use your tools to gather data before making a final assessment.
When you have enough information to complete the report, call the finish_report tool."""

print("Agent configuration loaded successfully.")
print(f"Using model: {MODEL}")
Security tip: Never hardcode your API key directly in your Python file. Always use environment variables. If you accidentally commit a key to GitHub, rotate it immediately in the Anthropic console.

Step 2: Create Your First Agent Class with Tool Definitions

Tools are how your agent interacts with the world. You define them as JSON schemas, and Claude decides which one to call based on the task at hand. Think of them like function signatures that Claude can read.

For our lead qualification agent, we'll define three tools: one to look up a prospect's LinkedIn data, one to check a property price range, and one to finalize the report. Here's the full agent class with those tool definitions baked in.

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

load_dotenv()

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 4096

SYSTEM_PROMPT = """You are a lead qualification agent for a real estate agency.
Your job is to evaluate incoming leads, gather relevant information using your tools,
score each lead on a scale of 1-10, and produce a structured qualification report.
Always use your tools to gather data before making a final assessment.
When you have enough information to complete the report, call the finish_report tool."""


class LeadQualificationAgent:
    def __init__(self):
        self.client = client
        self.model = MODEL
        self.conversation_history = []

        # Define the tools Claude can call during the agentic loop
        self.tools = [
            {
                "name": "lookup_prospect_profile",
                "description": "Look up professional background and financial indicators for a prospect based on their name and email. Returns job title, company, estimated income range, and years of experience.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "Full name of the prospect"
                        },
                        "email": {
                            "type": "string",
                            "description": "Email address of the prospect"
                        }
                    },
                    "required": ["name", "email"]
                }
            },
            {
                "name": "check_market_range",
                "description": "Check current market price ranges for properties in a given location and property type. Returns median price, low estimate, and high estimate.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "City or neighborhood to check, e.g. Naples FL or Bonita Springs FL"
                        },
                        "property_type": {
                            "type": "string",
                            "description": "Type of property: single_family, condo, or townhouse"
                        }
                    },
                    "required": ["location", "property_type"]
                }
            },
            {
                "name": "finish_report",
                "description": "Call this when you have gathered enough information to finalize the lead qualification report. Provide the complete assessment.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "lead_score": {
                            "type": "integer",
                            "description": "Score from 1 to 10 indicating lead quality"
                        },
                        "summary": {
                            "type": "string",
                            "description": "2-3 sentence summary of the lead's qualification status"
                        },
                        "recommended_action": {
                            "type": "string",
                            "description": "What the sales team should do next: schedule_call, send_listing, nurture, or disqualify"
                        },
                        "budget_fit": {
                            "type": "boolean",
                            "description": "Whether the prospect's estimated income aligns with market prices"
                        }
                    },
                    "required": ["lead_score", "summary", "recommended_action", "budget_fit"]
                }
            }
        ]

    def add_user_message(self, content):
        """Add a user message to the conversation history."""
        self.conversation_history.append({
            "role": "user",
            "content": content
        })

    def add_assistant_message(self, content):
        """Add an assistant message to the conversation history."""
        self.conversation_history.append({
            "role": "assistant",
            "content": content
        })


# Quick test to confirm the agent initializes correctly
if __name__ == "__main__":
    agent = LeadQualificationAgent()
    print(f"Agent initialized with {len(agent.tools)} tools:")
    for tool in agent.tools:
        print(f"  - {tool['name']}: {tool['description'][:60]}...")

When you run this file on its own, you should see the three tools listed in your terminal. That confirms the class is set up correctly before we wire in the execution logic.

Step 3: Implement Tool Execution and Error Handling

Defining tools tells Claude what it can do. But you still need actual Python functions that run when Claude decides to call a tool. In a production system, these would hit real APIs. Here, we'll use realistic mock data so you can see exactly how the flow works.

The process_tool_call function acts as a dispatcher. It takes Claude's tool call, routes it to the right function, and returns the result. Wrapping everything in try/except keeps the agent alive even when something goes wrong.

agent.py (add to LeadQualificationAgent class)
    def execute_lookup_prospect_profile(self, name, email):
        """
        In production, this would call a real enrichment API like Apollo or Clearbit.
        For this tutorial, we return realistic mock data.
        """
        mock_profiles = {
            "[email protected]": {
                "job_title": "VP of Operations",
                "company": "TechCorp Solutions",
                "estimated_income_range": "$180,000 - $220,000",
                "years_experience": 12,
                "location": "Naples, FL"
            },
            "[email protected]": {
                "job_title": "Recent Graduate",
                "company": "Currently Job Seeking",
                "estimated_income_range": "$35,000 - $45,000",
                "years_experience": 1,
                "location": "Fort Myers, FL"
            }
        }

        # Return matched profile or a generic one for unknown emails
        profile = mock_profiles.get(email, {
            "job_title": "Professional",
            "company": "Unknown Company",
            "estimated_income_range": "$75,000 - $95,000",
            "years_experience": 5,
            "location": "Southwest Florida"
        })
        profile["name"] = name
        return profile

    def execute_check_market_range(self, location, property_type):
        """
        In production, this would query a real estate data API like Zillow or MLS.
        """
        market_data = {
            ("Naples FL", "single_family"): {
                "median_price": 850000,
                "low_estimate": 650000,
                "high_estimate": 1200000,
                "market_trend": "appreciating"
            },
            ("Naples FL", "condo"): {
                "median_price": 450000,
                "low_estimate": 320000,
                "high_estimate": 700000,
                "market_trend": "stable"
            },
            ("Bonita Springs FL", "single_family"): {
                "median_price": 620000,
                "low_estimate": 480000,
                "high_estimate": 900000,
                "market_trend": "appreciating"
            }
        }

        key = (location, property_type)
        return market_data.get(key, {
            "median_price": 500000,
            "low_estimate": 350000,
            "high_estimate": 750000,
            "market_trend": "stable"
        })

    def execute_finish_report(self, lead_score, summary, recommended_action, budget_fit):
        """Store the final report and signal the agent loop to stop."""
        report = {
            "lead_score": lead_score,
            "summary": summary,
            "recommended_action": recommended_action,
            "budget_fit": budget_fit,
            "status": "complete"
        }
        # Attach the report to the instance so we can retrieve it after the loop
        self.final_report = report
        return {"message": "Report finalized successfully.", "report": report}

    def process_tool_call(self, tool_name, tool_input):
        """
        Dispatcher: routes Claude's tool call to the correct Python function.
        Returns the result as a dictionary that gets fed back into the conversation.
        """
        try:
            if tool_name == "lookup_prospect_profile":
                return self.execute_lookup_prospect_profile(
                    name=tool_input["name"],
                    email=tool_input["email"]
                )
            elif tool_name == "check_market_range":
                return self.execute_check_market_range(
                    location=tool_input["location"],
                    property_type=tool_input["property_type"]
                )
            elif tool_name == "finish_report":
                return self.execute_finish_report(
                    lead_score=tool_input["lead_score"],
                    summary=tool_input["summary"],
                    recommended_action=tool_input["recommended_action"],
                    budget_fit=tool_input["budget_fit"]
                )
            else:
                return {"error": f"Unknown tool: {tool_name}"}

        except KeyError as e:
            # Claude sent a tool call missing a required field
            return {"error": f"Missing required parameter: {str(e)}"}
        except Exception as e:
            # Catch-all so the agent loop doesn't crash on unexpected errors
            return {"error": f"Tool execution failed: {str(e)}"}

Step 4: Build the Agentic Loop (Think, Act, Observe Cycle)

This is the core of any autonomous agent. The loop runs continuously, sending messages to Claude, checking if Claude wants to call a tool, executing that tool, feeding the result back, and repeating until Claude is done. The cycle is often called think, act, observe.

The loop exits in two ways: Claude calls the finish_report tool and sets a completion flag, or we hit a maximum iteration limit as a safety net. Never build an agentic loop without a circuit breaker.

agent.py (add to LeadQualificationAgent class)
    def run(self, task):
        """
        Main agentic loop. Runs until the agent finishes the report or hits max iterations.
        """
        print(f"\n{'='*60}")
        print("AGENT STARTED")
        print(f"Task: {task}")
        print(f"{'='*60}\n")

        # Initialize conversation with the user's task
        self.add_user_message(task)
        self.final_report = None
        max_iterations = 10  # Safety limit to prevent runaway loops
        iteration = 0

        while True:
            iteration += 1

            if iteration > max_iterations:
                print(f"[WARNING] Hit max iterations ({max_iterations}). Stopping.")
                break

            print(f"[Iteration {iteration}] Calling Claude...")

            # Send the full conversation history to Claude on every iteration
            response = self.client.messages.create(
                model=self.model,
                max_tokens=MAX_TOKENS,
                system=SYSTEM_PROMPT,
                tools=self.tools,
                messages=self.conversation_history
            )

            print(f"[Iteration {iteration}] Stop reason: {response.stop_reason}")

            # Add Claude's full response to the conversation history
            self.add_assistant_message(response.content)

            # If Claude is done thinking and not calling any tools, we're finished
            if response.stop_reason == "end_turn":
                print("[Agent] Claude finished without calling finish_report.")
                break

            # If Claude wants to use a tool, process all tool calls in this response
            if response.stop_reason == "tool_use":
                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"[Tool Call] {tool_name}")
                        print(f"[Tool Input] {json.dumps(tool_input, indent=2)}")

                        # Execute the tool and get the result
                        result = self.process_tool_call(tool_name, tool_input)

                        print(f"[Tool Result] {json.dumps(result, indent=2)}\n")

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

                        # If the agent just finished the report, set a completion flag
                        if tool_name == "finish_report":
                            self.final_report = result.get("report")

                # Feed all tool results back into the conversation as a user turn
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })

                # If the report is done, we can exit the loop cleanly
                if self.final_report:
                    print("[Agent] Task complete. Exiting loop.")
                    break

        return self.final_report
Why feed tool results back as a "user" message? The Anthropic API uses a specific pattern where tool results must come in as a user role message, not assistant. This is how Claude sees the outcome of its own tool calls. Getting this wrong is one of the most common mistakes beginners make.

Step 5: Test with Real-World Use Cases (Lead Qualification Example)

Now let's put it all together. The code below is the complete, runnable version of everything we've built. Copy this into a single file called lead_agent.py, add your API key to your .env file, and run it.

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

load_dotenv()

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 4096

SYSTEM_PROMPT = """You are a lead qualification agent for a real estate agency in Naples, Florida.
Your job is to evaluate incoming leads, gather relevant information using your tools,
score each lead on a scale of 1-10, and produce a structured qualification report.
Always use your tools to gather data before making a final assessment.
When you have enough information to complete the report, call the finish_report tool."""


class LeadQualificationAgent:
    def __init__(self):
        self.client = client
        self.model = MODEL
        self.conversation_history = []
        self.final_report = None
        self.tools = [
            {
                "name": "lookup_prospect_profile",
                "description": "Look up professional background and financial indicators for a prospect based on their name and email. Returns job title, company, estimated income range, and years of experience.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string", "description": "Full name of the prospect"},
                        "email": {"type": "string", "description": "Email address of the prospect"}
                    },
                    "required": ["name", "email"]
                }
            },
            {
                "name": "check_market_range",
                "description": "Check current market price ranges for properties in a given location and property type.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "location": {"type": "string", "description": "City or neighborhood, e.g. Naples FL"},
                        "property_type": {"type": "string", "description": "single_family, condo, or townhouse"}
                    },
                    "required": ["location", "property_type"]
                }
            },
            {
                "name": "finish_report",
                "description": "Call this when you have gathered enough information to finalize the lead qualification report.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "lead_score": {"type": "integer", "description": "Score from 1 to 10"},
                        "summary": {"type": "string", "description": "2-3 sentence summary of qualification"},
                        "recommended_action": {"type": "string", "description": "schedule_call, send_listing, nurture, or disqualify"},
                        "budget_fit": {"type": "boolean", "description": "Whether income aligns with market prices"}
                    },
                    "required": ["lead_score", "summary", "recommended_action", "budget_fit"]
                }
            }
        ]

    def add_user_message(self, content):
        self.conversation_history.append({"role": "user", "content": content})

    def add_assistant_message(self, content):
        self.conversation_history.append({"role": "assistant", "content": content})

    def execute_lookup_prospect_profile(self, name, email):
        mock_profiles = {
            "[email protected]": {
                "job_title": "VP of Operations",
                "company": "TechCorp Solutions",
                "estimated_income_range": "$180,000 - $220,000",
                "years_experience": 12,
                "location": "Naples, FL"
            },
            "[email protected]": {
                "job_title": "Recent Graduate",
                "company": "Currently Job Seeking",
                "estimated_income_range": "$35,000 - $45,000",
                "years_experience": 1,
                "location": "Fort Myers, FL"
            }
        }
        profile = mock_profiles.get(email, {
            "job_title": "Professional",
            "company": "Unknown Company",
            "estimated_income_range": "$75,000 - $95,000",
            "years_experience": 5,
            "location": "Southwest Florida"
        })
        profile["name"] = name
        return profile

    def execute_check_market_range(self,