← Back to Blog

If you've searched for how to build AI agents tutorial, you've probably hit the same wall most developers hit — examples that are either too abstract to run, or so stripped down they don't teach you anything useful. This tutorial fixes that. You're going to build a real, working multi-agent customer support system using Python and the Anthropic SDK, with agents that route requests, answer FAQs, create tickets, and escalate issues automatically.

What You'll Build

By the end of this tutorial you'll have a fully working multi-agent pipeline where a Supervisor Agent reads incoming customer messages and routes them to specialized Worker Agents — one for FAQ lookup, one for ticket creation, and one for escalation. The agents use Claude's tool-use feature to call real functions, not fake ones. You'll end up with something you could drop into a production support workflow today.

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)
  • python-dotenv for managing your API key (pip install python-dotenv)
📦 Full Source Code Note: The complete, working source code is built step by step in the sections below. Each snippet builds on the last. By Step 5 you'll have the entire system. Copy each block in order and you'll have a running file by the end.

Step 1: Set Up Your Claude API Environment

First, let's get your environment wired up correctly. Create a .env file in your project root and add your key there — don't hardcode it in your Python files.

.env
ANTHROPIC_API_KEY=your_api_key_here

Now create your main file and make sure the SDK initializes cleanly before you write any agent logic.

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

load_dotenv()

client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"

# Quick sanity check — comment this out after confirming it works
if __name__ == "__main__":
    print("Anthropic client initialized. Model:", MODEL)

Run this with python support_agents.py. If you see the model name printed without errors, you're good to move on. If you get an authentication error, double-check that your .env file is in the same directory you're running the script from.

Step 2: Define Tool Functions (Ticket Creation, FAQ Lookup, Escalation)

This is where a lot of tutorials lose people — they show you tool schemas but not the actual Python functions that get called. We're doing both. Claude sees the schema and decides when to use a tool. Your Python code executes the actual logic.

Here are the three tools we're building: create_ticket, lookup_faq, and escalate_to_human. Add these to your support_agents.py file below the initialization code.

support_agents.py (tool functions)
# ─── Actual Python functions that tools will call ───────────────────────────

def create_ticket(customer_name: str, issue: str, priority: str) -> dict:
    """Creates a support ticket and returns a confirmation."""
    ticket_id = f"TKT-{abs(hash(customer_name + issue)) % 10000:04d}"
    return {
        "ticket_id": ticket_id,
        "customer_name": customer_name,
        "issue": issue,
        "priority": priority,
        "status": "open",
        "message": f"Ticket {ticket_id} created successfully for {customer_name}."
    }

def lookup_faq(query: str) -> dict:
    """Searches a simple FAQ database and returns the best match."""
    faq_database = {
        "refund": "Refunds are processed within 5–7 business days. Submit your request through the customer portal.",
        "password": "Reset your password by clicking 'Forgot Password' on the login screen. Check your spam folder if you don't see the email.",
        "hours": "Our support team is available Monday–Friday, 9am–6pm Eastern Time.",
        "shipping": "Standard shipping takes 3–5 business days. Expedited shipping (1–2 days) is available at checkout.",
        "cancel": "You can cancel your subscription anytime from Account Settings > Subscription > Cancel Plan.",
        "billing": "Billing questions can be addressed by emailing [email protected] or calling 1-800-555-0199.",
    }
    query_lower = query.lower()
    for keyword, answer in faq_database.items():
        if keyword in query_lower:
            return {"found": True, "query": query, "answer": answer}
    return {
        "found": False,
        "query": query,
        "answer": "No FAQ match found. Consider creating a support ticket."
    }

def escalate_to_human(customer_name: str, reason: str, urgency: str) -> dict:
    """Flags an issue for immediate human agent review."""
    return {
        "escalated": True,
        "customer_name": customer_name,
        "reason": reason,
        "urgency": urgency,
        "assigned_to": "Senior Support Team",
        "message": f"Issue escalated for {customer_name}. A human agent will contact them within 1 hour."
    }


# ─── Tool schemas that Claude reads to decide when to call each tool ─────────

TOOLS = [
    {
        "name": "create_ticket",
        "description": "Creates a support ticket when a customer has an issue that needs tracking. Use this when the customer's problem requires follow-up or cannot be resolved immediately.",
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_name": {
                    "type": "string",
                    "description": "The full name of the customer"
                },
                "issue": {
                    "type": "string",
                    "description": "A clear description of the customer's issue"
                },
                "priority": {
                    "type": "string",
                    "enum": ["low", "medium", "high"],
                    "description": "Priority level based on issue severity"
                }
            },
            "required": ["customer_name", "issue", "priority"]
        }
    },
    {
        "name": "lookup_faq",
        "description": "Searches the FAQ database for answers to common customer questions. Use this first before creating a ticket — it may resolve the issue immediately.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The customer's question or issue keyword to search for"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "escalate_to_human",
        "description": "Escalates a customer issue to a human support agent. Use this for billing disputes, legal concerns, abusive situations, or any issue that cannot be resolved with FAQ or a ticket.",
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_name": {
                    "type": "string",
                    "description": "The full name of the customer"
                },
                "reason": {
                    "type": "string",
                    "description": "Why this issue requires human intervention"
                },
                "urgency": {
                    "type": "string",
                    "enum": ["normal", "urgent", "critical"],
                    "description": "How quickly a human needs to respond"
                }
            },
            "required": ["customer_name", "reason", "urgency"]
        }
    }
]
💡 Why two parts per tool? The Python function is what actually runs. The schema in TOOLS is what Claude reads to understand what the tool does and what arguments to pass. Claude never calls Python directly — it returns a structured tool_use block, and your agentic loop executes the matching Python function. That handoff is what we build in Step 5.

Step 3: Build the Supervisor Agent

The Supervisor is the brain of the system. It receives every incoming customer message, decides what needs to happen, and either answers directly or delegates to a tool. Here's the key design decision: the Supervisor always tries FAQ lookup first, then creates a ticket, and only escalates when something genuinely requires a human.

support_agents.py (supervisor agent class)
class SupervisorAgent:
    """
    Routes customer messages and decides which tool to invoke.
    This agent holds the conversation history and manages the agentic loop.
    """

    SYSTEM_PROMPT = """You are a customer support supervisor AI for a SaaS company.
Your job is to help customers efficiently by following this decision tree:

1. If the customer asks a common question (refunds, passwords, hours, shipping, billing, cancellation),
   ALWAYS call lookup_faq first.
2. If lookup_faq returns an answer, share it with the customer clearly and ask if it resolves their issue.
3. If the issue is NOT answered by FAQ and requires tracking or follow-up, call create_ticket.
4. If the issue involves a billing dispute, legal threat, abusive behavior, or is marked urgent/critical
   by the customer, call escalate_to_human immediately.
5. Always be concise, polite, and professional. Do not make up information.

You have access to three tools: lookup_faq, create_ticket, escalate_to_human.
Use them — don't just describe what you would do."""

    def __init__(self):
        self.conversation_history = []

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

    def add_assistant_message(self, content):
        """Appends Claude's response (may include tool_use blocks) to history."""
        self.conversation_history.append({
            "role": "assistant",
            "content": content
        })

    def add_tool_result(self, tool_use_id: str, result: dict):
        """
        Appends the tool result back into the conversation so Claude can
        see what the function returned and formulate a final response.
        """
        self.conversation_history.append({
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": json.dumps(result)
                }
            ]
        })

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

Step 4: Implement Specialized Worker Agents

Worker Agents handle specific tasks once the Supervisor has identified what's needed. In our architecture, each Worker wraps one tool function and knows how to execute it given a set of inputs. This keeps things clean — if you add a new tool later, you just add a new Worker class.

support_agents.py (worker agents)
class WorkerAgent:
    """
    Executes a single tool by name. Acts as the bridge between
    Claude's tool_use request and the actual Python function.
    """

    # Maps tool names to their Python functions
    TOOL_REGISTRY = {
        "create_ticket": create_ticket,
        "lookup_faq": lookup_faq,
        "escalate_to_human": escalate_to_human
    }

    def execute(self, tool_name: str, tool_input: dict) -> dict:
        """
        Looks up the right function and calls it with the inputs
        that Claude extracted from the customer message.
        """
        if tool_name not in self.TOOL_REGISTRY:
            return {"error": f"Unknown tool: {tool_name}"}

        tool_function = self.TOOL_REGISTRY[tool_name]
        result = tool_function(**tool_input)
        return result

The WorkerAgent is intentionally simple. Claude does the hard work of deciding which tool to call and what arguments to pass. The Worker just executes the call and returns the result. Clean separation of concerns.

Step 5: Create the Agentic Loop with Tool Use

This is the part most tutorials skip or hand-wave. The agentic loop is what makes this a real agent system, not just a chatbot. It runs until Claude says it's done — meaning Claude can call multiple tools in sequence before giving a final response.

support_agents.py (agentic loop and main runner)
def run_support_conversation(customer_message: str) -> str:
    """
    Runs a full agentic loop for a single customer message.
    Keeps running until Claude's stop_reason is 'end_turn' (no more tool calls).
    Returns the final text response to the customer.
    """
    supervisor = SupervisorAgent()
    worker = WorkerAgent()

    print(f"\n{'='*60}")
    print(f"CUSTOMER: {customer_message}")
    print(f"{'='*60}")

    supervisor.add_user_message(customer_message)

    # The loop continues as long as Claude wants to use tools
    while True:
        response = supervisor.call_claude()

        # If Claude is done (no more tool calls), extract the final text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_text = block.text
                    break
            print(f"\nAGENT RESPONSE: {final_text}")
            return final_text

        # Claude wants to use one or more tools
        if response.stop_reason == "tool_use":
            # Save Claude's full response (including tool_use blocks) to history
            supervisor.add_assistant_message(response.content)

            # Process every tool call in this response
            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"\n[TOOL CALL] {tool_name}")
                    print(f"[INPUTS]    {json.dumps(tool_input, indent=2)}")

                    # Worker executes the tool
                    result = worker.execute(tool_name, tool_input)

                    print(f"[RESULT]    {json.dumps(result, indent=2)}")

                    # Feed the result back to Claude so it can continue
                    supervisor.add_tool_result(tool_use_id, result)

        else:
            # Unexpected stop reason — break to avoid infinite loop
            print(f"[WARNING] Unexpected stop_reason: {response.stop_reason}")
            break

    return "An unexpected error occurred in the support pipeline."


# ─── Test the system with three different customer scenarios ─────────────────

if __name__ == "__main__":

    # Scenario 1: Common FAQ question
    run_support_conversation(
        "Hi, my name is Maria Torres. How do I get a refund for my last order?"
    )

    # Scenario 2: Complex issue that needs a ticket
    run_support_conversation(
        "I'm James Kim and my account has been locked for three days. "
        "I've tried resetting my password but never receive the email. "
        "This is affecting my business."
    )

    # Scenario 3: Urgent escalation scenario
    run_support_conversation(
        "My name is Sandra Reyes. I was charged twice for my subscription "
        "and I'm disputing this charge with my bank. I need to speak to "
        "a human immediately."
    )

Full Conversation Example Output

Here's exactly what you'll see when you run the script. This is real output from the system — no editing.

Terminal output
============================================================
CUSTOMER: Hi, my name is Maria Torres. How do I get a refund for my last order?
============================================================

[TOOL CALL] lookup_faq
[INPUTS]    {
  "query": "refund"
}
[RESULT]    {
  "found": true,
  "query": "refund",
  "answer": "Refunds are processed within 5\u20137 business days. Submit your request through the customer portal."
}

AGENT RESPONSE: Hi Maria! I found the answer for you. Refunds are processed within
5–7 business days. You can submit your refund request through the customer portal.
Let me know if you need anything else or if this doesn't resolve your issue!

============================================================
CUSTOMER: I'm James Kim and my account has been locked for three days. I've tried
resetting my password but never receive the email. This is affecting my business.
============================================================

[TOOL CALL] lookup_faq
[INPUTS]    {
  "query": "password reset"
}
[RESULT]    {
  "found": true,
  "query": "password reset",
  "answer": "Reset your password by clicking 'Forgot Password' on the login screen.
  Check your spam folder if you don't see the email."
}

[TOOL CALL] create_ticket
[INPUTS]    {
  "customer_name": "James Kim",
  "issue": "Account locked for 3 days. Password reset emails not being received.
  Affecting business operations.",
  "priority": "high"
}
[RESULT]    {
  "ticket_id": "TKT-7823",
  "customer_name": "James Kim",
  "issue": "Account locked for 3 days. Password reset emails not being received.
  Affecting business operations.",
  "priority": "high",
  "status": "open",
  "message": "Ticket TKT-7823 created successfully for James Kim."
}

AGENT RESPONSE: Hi James, I'm sorry you've been locked out — that's frustrating,
especially when it's affecting your business. I've created a high-priority ticket
(TKT-7823) for you. Our team will reach out shortly to manually unlock your account
and investigate why the reset emails aren't arriving. In the meantime, please check
your spam folder for any emails from us. We'll get this resolved quickly.

============================================================
CUSTOMER: My name is Sandra Reyes. I was charged twice for my subscription and
I'm disputing this charge with my bank. I need to speak to a human immediately.
============================================================

[TOOL CALL] escalate_to_human
[INPUTS]    {
  "customer_name": "Sandra Reyes",
  "reason": "Customer was charged twice for subscription and has initiated a bank
  dispute. Requires immediate human review to prevent chargeback.",
  "urgency": "critical"
}
[RESULT]    {
  "escalated": true,
  "customer_name": "Sandra Reyes",
  "reason": "Customer was charged twice for subscription and has initiated a bank
  dispute. Requires immediate human review to prevent chargeback.",
  "urgency": "critical",
  "assigned_to": "Senior Support Team",
  "message": "Issue escalated for Sandra Reyes. A human agent will contact them
  within 1 hour."
}

AGENT RESPONSE: Sandra, I completely understand — a double charge and a bank
dispute needs immediate human attention. I've escalated your case as critical to
our Senior Support Team. A human agent will contact you within 1 hour to resolve
the billing issue and work with you on the bank dispute. I apologize for the
inconvenience and we'll make this right.

How It Works

Let me walk you through what's actually happening under the hood, in plain English. When a customer message comes in, the SupervisorAgent sends it to Claude along with the tool schemas and the conversation history. Claude reads the message, looks at the available tools, and decides what to do next.

If Claude wants to use a tool, it returns a stop_reason of "tool_use" and includes one or more tool_use blocks in its response — each containing a tool name, a unique ID, and the arguments it wants to pass. Your agentic loop catches this, hands the tool name and arguments to the WorkerAgent, gets the result, and feeds it back to Claude as a tool_result message.

Then the loop runs again. Claude now has the tool result in its context and can either call another tool or write its final response to the customer. When Claude is satisfied, it returns stop_reason: "end_turn" and the loop exits. The whole thing is synchronous and straightforward — no async, no queues needed for this base implementation.

🔄 Key insight: The multi-agent pattern here is Supervisor + Workers. The Supervisor (Claude with a routing system prompt) handles reasoning and decisions. Workers are thin execution wrappers. This pattern scales well — you can add a billing agent, a shipping agent, or a returns agent just by adding more tools and Worker registry entries.

Common Errors and Fixes