← Back to Blog

What You'll Build

If you've searched "build a chatbot with Claude," you probably want something more useful than a simple question-and-answer wrapper. This tutorial walks you through building a production-ready multi-agent chatbot using the Claude API and Python — one that can actually call functions, handle business logic, and manage a conversation loop end to end.

By the end, you'll have a working multi-agent system where Claude decides which tools to call, executes them, and returns intelligent responses — all inside a clean agentic run loop. I'll use a real estate inquiry scenario as the example, since that's one of the most common deployments we build at Naples AI.

📦 Full Source Code
The complete, working code for this tutorial is broken into steps below. Each snippet builds on the last. By Step 4 you'll have a fully functional multi-agent chatbot you can run locally and extend for your own use case.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic SDK installed: pip install anthropic
  • Optional but helpful: some experience with REST APIs or chatbot concepts

Step 1: Set Up Your Claude API Environment and Authentication

First thing — get your environment wired up correctly. A lot of people skip this and spend an hour debugging an auth error that's just a missing environment variable.

Create a .env file in your project root and add your key there. Never hardcode it in the source file.

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

Now install the dependencies you need for this project.

terminal
pip install anthropic python-dotenv

Next, create your main project file and confirm the client initializes without errors.

test_auth.py
import os
from dotenv import load_dotenv
import anthropic

load_dotenv()

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Quick sanity check — send one message to confirm auth works
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say hello in one sentence."}]
)

print(response.content[0].text)

Expected output:

Hello! It's great to meet you — how can I help you today?

If that runs clean, you're ready. If you get an AuthenticationError, check that your .env file is in the same directory and that you're calling load_dotenv() before initializing the client.

Step 2: Define Tool Functions for Business Logic

This is where the multi-agent part actually starts. Tools are regular Python functions that Claude can decide to call when it needs real data. Think of them as the hands of the agent — Claude is the brain, your functions are how it reaches into the world.

I'm defining three tools here: one to look up property listings, one to check agent availability, and one to calculate mortgage estimates. These mirror what we'd build for a real estate client in Southwest Florida.

tools.py
import json
from typing import Any

def get_property_listings(location: str, max_price: float, bedrooms: int) -> dict:
    """
    Simulates a database lookup for property listings.
    In production, replace this with your actual MLS or CRM API call.
    """
    listings = [
        {
            "id": "NP-1042",
            "address": "4821 Gulf Shore Blvd, Naples, FL",
            "price": 875000,
            "bedrooms": 3,
            "bathrooms": 2,
            "sqft": 1950,
            "status": "Active"
        },
        {
            "id": "NP-1078",
            "address": "192 Moorings Park Dr, Naples, FL",
            "price": 1150000,
            "bedrooms": 4,
            "bathrooms": 3,
            "sqft": 2800,
            "status": "Active"
        },
        {
            "id": "NP-1031",
            "address": "311 Pelican Bay Blvd, Naples, FL",
            "price": 650000,
            "bedrooms": 3,
            "bathrooms": 2,
            "sqft": 1740,
            "status": "Active"
        }
    ]

    # Filter by price ceiling and bedroom count
    matches = [
        l for l in listings
        if l["price"] <= max_price and l["bedrooms"] >= bedrooms
    ]

    return {"listings": matches, "total_found": len(matches)}


def check_agent_availability(date: str, time_slot: str) -> dict:
    """
    Checks whether a real estate agent is available for a showing.
    In production, connect this to your calendar API (Google Calendar, Calendly, etc.).
    """
    # Simulated availability — in real use this hits a calendar API
    available_slots = {
        "2026-08-05": ["10:00 AM", "2:00 PM", "4:00 PM"],
        "2026-08-06": ["9:00 AM", "11:00 AM", "3:00 PM"],
        "2026-08-07": ["10:00 AM", "1:00 PM"]
    }

    day_slots = available_slots.get(date, [])
    is_available = time_slot in day_slots

    return {
        "date": date,
        "time_slot": time_slot,
        "available": is_available,
        "alternative_slots": day_slots if not is_available else []
    }


def calculate_mortgage_estimate(
    home_price: float,
    down_payment_percent: float,
    annual_interest_rate: float,
    loan_term_years: int
) -> dict:
    """
    Calculates a monthly mortgage estimate using standard amortization formula.
    """
    principal = home_price * (1 - down_payment_percent / 100)
    monthly_rate = annual_interest_rate / 100 / 12
    n_payments = loan_term_years * 12

    if monthly_rate == 0:
        monthly_payment = principal / n_payments
    else:
        # Standard amortization formula
        monthly_payment = principal * (
            monthly_rate * (1 + monthly_rate) ** n_payments
        ) / ((1 + monthly_rate) ** n_payments - 1)

    return {
        "home_price": home_price,
        "down_payment": home_price * down_payment_percent / 100,
        "loan_amount": principal,
        "monthly_payment": round(monthly_payment, 2),
        "total_paid": round(monthly_payment * n_payments, 2)
    }


# Maps tool names to actual Python functions — used by the agent run loop
TOOL_REGISTRY: dict[str, Any] = {
    "get_property_listings": get_property_listings,
    "check_agent_availability": check_agent_availability,
    "calculate_mortgage_estimate": calculate_mortgage_estimate
}


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Dispatches a tool call by name and returns JSON string result."""
    if tool_name not in TOOL_REGISTRY:
        return json.dumps({"error": f"Unknown tool: {tool_name}"})

    func = TOOL_REGISTRY[tool_name]
    result = func(**tool_input)
    return json.dumps(result)
💡 Tip: Keep your tool functions thin. They should fetch or calculate one thing and return structured data. Claude handles the interpretation — your functions just supply the facts.

Step 3: Create the Main Agent Class with Tool Use

Now we wire everything together in an agent class. This class holds the conversation history, defines the tools in the format Claude expects, and handles routing tool calls back to your Python functions.

The tool schema is the part most tutorials get wrong. Claude needs the exact JSON Schema format — if your parameter types are off, tool calls will fail silently or return unexpected results.

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

load_dotenv()


class RealEstateAgent:
    """
    Multi-agent chatbot class for real estate inquiries.
    Manages conversation history and Claude tool use end to end.
    """

    MODEL = "claude-sonnet-4-6"
    MAX_TOKENS = 4096

    # Tool definitions in Anthropic's required schema format
    TOOLS = [
        {
            "name": "get_property_listings",
            "description": (
                "Search available property listings by location, maximum price, "
                "and minimum number of bedrooms. Use this when a user asks about "
                "available homes or properties for sale."
            ),
            "input_schema": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "City or neighborhood to search in, e.g. 'Naples, FL'"
                    },
                    "max_price": {
                        "type": "number",
                        "description": "Maximum purchase price in USD"
                    },
                    "bedrooms": {
                        "type": "integer",
                        "description": "Minimum number of bedrooms required"
                    }
                },
                "required": ["location", "max_price", "bedrooms"]
            }
        },
        {
            "name": "check_agent_availability",
            "description": (
                "Check whether a real estate agent is available for a property "
                "showing on a specific date and time. Use this when a user wants "
                "to schedule or confirm a viewing appointment."
            ),
            "input_schema": {
                "type": "object",
                "properties": {
                    "date": {
                        "type": "string",
                        "description": "Date in YYYY-MM-DD format, e.g. '2026-08-05'"
                    },
                    "time_slot": {
                        "type": "string",
                        "description": "Desired time slot, e.g. '2:00 PM'"
                    }
                },
                "required": ["date", "time_slot"]
            }
        },
        {
            "name": "calculate_mortgage_estimate",
            "description": (
                "Calculate an estimated monthly mortgage payment given home price, "
                "down payment percentage, interest rate, and loan term. Use this "
                "when a user asks about affordability or financing."
            ),
            "input_schema": {
                "type": "object",
                "properties": {
                    "home_price": {
                        "type": "number",
                        "description": "Total purchase price of the home in USD"
                    },
                    "down_payment_percent": {
                        "type": "number",
                        "description": "Down payment as a percentage of home price, e.g. 20 for 20%"
                    },
                    "annual_interest_rate": {
                        "type": "number",
                        "description": "Annual interest rate as a percentage, e.g. 6.75"
                    },
                    "loan_term_years": {
                        "type": "integer",
                        "description": "Loan term in years, typically 15 or 30"
                    }
                },
                "required": [
                    "home_price",
                    "down_payment_percent",
                    "annual_interest_rate",
                    "loan_term_years"
                ]
            }
        }
    ]

    SYSTEM_PROMPT = (
        "You are a helpful real estate assistant for a Naples, Florida agency. "
        "You help clients find properties, schedule showings, and understand financing. "
        "When a user asks about listings, availability, or mortgage costs, use the "
        "appropriate tools to get real data before responding. Always be concise, "
        "friendly, and accurate. If you don't have enough information to call a tool, "
        "ask the user for the missing details."
    )

    def __init__(self):
        self.client = anthropic.Anthropic(
            api_key=os.environ.get("ANTHROPIC_API_KEY")
        )
        self.conversation_history: list[dict] = []

    def add_user_message(self, content: str) -> None:
        """Appends a user message to the conversation history."""
        self.conversation_history.append({
            "role": "user",
            "content": content
        })

    def add_assistant_message(self, content: list) -> None:
        """Appends an assistant message (with content blocks) to history."""
        self.conversation_history.append({
            "role": "assistant",
            "content": content
        })

    def add_tool_result(self, tool_use_id: str, result: str) -> None:
        """
        Appends tool results back into the conversation as a user message.
        This is required by the Anthropic API — tool results go in user turns.
        """
        self.conversation_history.append({
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                }
            ]
        })

    def call_claude(self) -> anthropic.types.Message:
        """Sends the current conversation history to Claude and returns the response."""
        return self.client.messages.create(
            model=self.MODEL,
            max_tokens=self.MAX_TOKENS,
            system=self.SYSTEM_PROMPT,
            tools=self.TOOLS,
            messages=self.conversation_history
        )

Step 4: Build the Run Loop and Message Handling

The agentic run loop is what makes this a real agent instead of just a chatbot. After Claude responds, we check if it wants to call a tool. If it does, we run the tool, feed the result back, and call Claude again — repeating until it stops requesting tools and gives a final answer.

This loop is the heart of any multi-agent Claude API system. Get this right and you can extend it to handle dozens of tools.

agent.py (continued — add these methods to the RealEstateAgent class)
    def process_tool_calls(self, response_content: list) -> bool:
        """
        Iterates over response content blocks, executes any tool calls found,
        and stores results back in conversation history.
        Returns True if at least one tool was called.
        """
        tool_was_called = False

        for block in response_content:
            if block.type == "tool_use":
                tool_was_called = True
                tool_name = block.name
                tool_input = block.input
                tool_use_id = block.id

                print(f"\n[Agent] Calling tool: {tool_name}")
                print(f"[Agent] Input: {json.dumps(tool_input, indent=2)}")

                # Execute the tool and get back a JSON string
                result = execute_tool(tool_name, tool_input)

                print(f"[Agent] Result: {result}")

                # Feed the tool result back into the conversation
                self.add_tool_result(tool_use_id, result)

        return tool_was_called

    def run(self, user_input: str) -> str:
        """
        Main agentic run loop. Takes user input, runs through tool calls
        as needed, and returns Claude's final natural language response.
        """
        self.add_user_message(user_input)

        while True:
            response = self.call_claude()

            # Save assistant message (may include tool_use blocks)
            self.add_assistant_message(response.content)

            # 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

            # If Claude wants to call tools, process them and loop again
            if response.stop_reason == "tool_use":
                self.process_tool_calls(response.content)
                # Loop continues — Claude will receive tool results on next call
                continue

            # Catch any other stop reason to avoid infinite loops
            break

        return "I wasn't able to complete that request. Please try again."

Now add the entry point so you can run this from the command line.

main.py
from agent import RealEstateAgent


def main():
    agent = RealEstateAgent()
    print("Naples Real Estate Assistant — type 'quit' to exit\n")

    while True:
        user_input = input("You: ").strip()

        if not user_input:
            continue
        if user_input.lower() in ("quit", "exit"):
            print("Goodbye!")
            break

        response = agent.run(user_input)
        print(f"\nAssistant: {response}\n")


if __name__ == "__main__":
    main()

Step 5: Test with Real Business Scenarios

Let's run a realistic conversation to see the full multi-agent Claude API system in action. This tests the tool call loop, parameter extraction, and final response generation all at once.

Run python main.py and try this conversation:

example session output
Naples Real Estate Assistant — type 'quit' to exit

You: I'm looking for a 3 bedroom home in Naples under $900,000. What do you have?

[Agent] Calling tool: get_property_listings
[Agent] Input: {
  "location": "Naples, FL",
  "max_price": 900000,
  "bedrooms": 3
}
[Agent] Result: {"listings": [{"id": "NP-1042", "address": "4821 Gulf Shore Blvd, Naples, FL",
"price": 875000, "bedrooms": 3, "bathrooms": 2, "sqft": 1950, "status": "Active"},
{"id": "NP-1031", "address": "311 Pelican Bay Blvd, Naples, FL", "price": 650000,
"bedrooms": 3, "bathrooms": 2, "sqft": 1740, "status": "Active"}], "total_found": 2}