If you've ever watched your support team drown in repetitive tickets — password resets, order status questions, basic troubleshooting — you already know the problem this tutorial solves. By the end of this guide, you'll have a working Claude API multi-agent system that automatically routes incoming support tickets, queries a knowledge base, and resolves common issues without a human touching them. This is the same pattern we use at Naples AI when we build autonomous customer service agents for local businesses across Southwest Florida.
The complete, working code for this Claude API multi-agent system is spread across the steps below. Each step builds on the last, so by Step 4 you'll have everything you need to run it end to end. Copy each block in order and you're good to go.
What You'll Build
You're building a multi-agent customer support system in Python using the Anthropic SDK. It includes a ticket-routing agent, a knowledge-base lookup tool, and an orchestrator that decides which agent handles which request. The system accepts a raw customer message, classifies it, pulls relevant answers from a knowledge base, and returns a resolved response — autonomously.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and functions
anthropicPython SDK installed (pip install anthropic)- A terminal and a code editor (VS Code works great)
Step 1: Set Up Your Claude API Environment and Dependencies
First things first — let's get the environment wired up. You only need the Anthropic SDK for this project; no LangChain required unless you want to extend it later.
Create a project folder, set up a virtual environment, and install the SDK:
terminalmkdir claude-support-agents cd claude-support-agents python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install anthropic python-dotenv
Now create a .env file in your project root to store your API key safely. Never hardcode credentials in source files.
ANTHROPIC_API_KEY=sk-ant-your-key-here
Create your main file and confirm the SDK loads correctly before writing any agent logic:
verify_setup.pyimport os
from dotenv import load_dotenv
import anthropic
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# Quick ping to confirm the API key is valid
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Say 'API connected' and nothing else."}]
)
print(response.content[0].text)
# Expected output: API connectedRun python verify_setup.py and you should see API connected printed in your terminal. If you get an auth error, double-check the key in your .env file.
Step 2: Define Tool Functions for Ticket Routing and Knowledge Lookup
This is where the real meat of a Claude API multi-agent system lives. Tools are functions Claude can call during its reasoning loop — think of them as capabilities you're handing to the model. We'll define two: one to classify and route a ticket, and one to search a knowledge base.
Claude's tool-use feature works by passing a tools list to the API. The model decides when to call a tool and what arguments to pass. You handle the actual execution on your end and return the result.
import json
# ── Fake knowledge base (replace with your real DB or vector store) ──────────
KNOWLEDGE_BASE = {
"password_reset": "To reset your password, visit /account/reset and enter your email. You'll receive a link within 2 minutes.",
"order_status": "You can track your order at /orders using your order ID. Orders ship within 1-2 business days.",
"billing_issue": "For billing questions, log in and go to /billing. Charges appear within 3-5 business days.",
"technical_error": "Try clearing your browser cache and cookies first. If the issue persists, disable browser extensions.",
"refund_request": "Refunds are processed within 5-7 business days back to your original payment method.",
"account_locked": "Accounts lock after 5 failed login attempts. Wait 30 minutes or contact support for an immediate unlock.",
"general": "Our support team is available Monday–Friday, 9am–6pm ET. Average response time is under 2 hours."
}
# ── Tool schemas passed to Claude ─────────────────────────────────────────────
TOOL_DEFINITIONS = [
{
"name": "route_ticket",
"description": (
"Classifies an incoming customer support message into a category "
"and assigns a priority level. Call this first for every new ticket."
),
"input_schema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"password_reset", "order_status", "billing_issue",
"technical_error", "refund_request", "account_locked", "general"
],
"description": "The category that best matches the customer's issue."
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": "Priority based on urgency and business impact."
},
"summary": {
"type": "string",
"description": "One sentence summarizing the customer's issue."
}
},
"required": ["category", "priority", "summary"]
}
},
{
"name": "lookup_knowledge_base",
"description": (
"Retrieves the relevant help article or resolution script for a "
"given support category. Call this after routing to get answer content."
),
"input_schema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"password_reset", "order_status", "billing_issue",
"technical_error", "refund_request", "account_locked", "general"
],
"description": "The ticket category to look up in the knowledge base."
}
},
"required": ["category"]
}
}
]
def route_ticket(category: str, priority: str, summary: str) -> dict:
"""Simulates writing the ticket to a routing system."""
ticket_id = f"TKT-{abs(hash(summary)) % 100000:05d}"
return {
"ticket_id": ticket_id,
"category": category,
"priority": priority,
"summary": summary,
"status": "routed"
}
def lookup_knowledge_base(category: str) -> dict:
"""Returns the knowledge base article for the given category."""
article = KNOWLEDGE_BASE.get(category, KNOWLEDGE_BASE["general"])
return {
"category": category,
"resolution": article,
"found": category in KNOWLEDGE_BASE
}
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Routes tool calls to the correct Python function and returns JSON string."""
if tool_name == "route_ticket":
result = route_ticket(**tool_input)
elif tool_name == "lookup_knowledge_base":
result = lookup_knowledge_base(**tool_input)
else:
result = {"error": f"Unknown tool: {tool_name}"}
return json.dumps(result)lookup_knowledge_base would call a vector database like Pinecone or pgvector. The fake dict here is just to keep the tutorial self-contained and runnable without any extra accounts.
Step 3: Create the Multi-Agent Orchestrator Class
The orchestrator is the brain of the system. It holds the conversation state, sends messages to Claude, and decides what to do when Claude calls a tool. Everything routes through this one class.
I structured this as a class so you can instantiate one orchestrator per incoming ticket and keep conversation history isolated. That's important when you're running multiple tickets in parallel.
orchestrator.pyimport os
import json
from dotenv import load_dotenv
import anthropic
from tools import TOOL_DEFINITIONS, execute_tool
load_dotenv()
SYSTEM_PROMPT = """You are a customer support AI agent for a SaaS company.
Your job is to:
1. Call route_ticket to classify and prioritize every incoming message.
2. Call lookup_knowledge_base to retrieve the right resolution.
3. Respond to the customer with a clear, friendly, and complete answer.
Always call both tools before writing your final response.
Be concise. Use plain language. Never make up information not in the knowledge base."""
class SupportOrchestrator:
"""
Manages the full lifecycle of a single customer support ticket.
One instance = one ticket conversation.
"""
def __init__(self):
self.client = anthropic.Anthropic(
api_key=os.environ.get("ANTHROPIC_API_KEY")
)
self.model = "claude-sonnet-4-6"
self.conversation_history = []
self.tool_call_log = [] # Stores every tool call for auditing
self.ticket_metadata = {} # Populated after route_ticket runs
def add_user_message(self, message: str) -> None:
"""Appends a user message to the conversation history."""
self.conversation_history.append({
"role": "user",
"content": message
})
def _handle_tool_use(self, tool_use_block) -> dict:
"""
Executes the tool Claude requested and returns a tool_result block.
This is the bridge between Claude's decision and your Python functions.
"""
tool_name = tool_use_block.name
tool_input = tool_use_block.input
tool_use_id = tool_use_block.id
print(f" [Tool Call] {tool_name}({json.dumps(tool_input, indent=2)})")
# Run the actual Python function
result_json = execute_tool(tool_name, tool_input)
result_dict = json.loads(result_json)
# Save routing metadata when the routing tool fires
if tool_name == "route_ticket":
self.ticket_metadata = result_dict
# Log every tool call for audit trails
self.tool_call_log.append({
"tool": tool_name,
"input": tool_input,
"output": result_dict
})
print(f" [Tool Result] {result_json}")
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_json
}
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=1024,
system=SYSTEM_PROMPT,
tools=TOOL_DEFINITIONS,
messages=self.conversation_history
)Step 4: Build the Agent Run Loop with Message Handling
This is the part most tutorials skip over — the actual agentic loop. Claude doesn't always finish in one call. When it needs to use a tool, it stops and waits for your code to execute the tool and return the result. You keep looping until Claude's stop_reason is end_turn.
Add the following method to your SupportOrchestrator class, then add the process_ticket wrapper and the main runner below it:
def run_agent_loop(self) -> str:
"""
The core agentic loop. Keeps calling Claude and handling tool use
until Claude signals it's done with stop_reason == 'end_turn'.
Returns Claude's final text response.
"""
max_iterations = 10 # Safety cap to prevent runaway loops
iteration = 0
while iteration < max_iterations:
iteration += 1
print(f"\n--- Agent Loop Iteration {iteration} ---")
response = self.call_claude()
print(f"Stop reason: {response.stop_reason}")
# Collect tool results if Claude requested any tools
tool_results = []
final_text = ""
for block in response.content:
if block.type == "tool_use":
tool_result = self._handle_tool_use(block)
tool_results.append(tool_result)
elif block.type == "text":
final_text = block.text
# If Claude called tools, add its response + results to history and loop
if response.stop_reason == "tool_use":
# Append Claude's assistant message (with tool_use blocks)
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
# Append all tool results as a user message
self.conversation_history.append({
"role": "user",
"content": tool_results
})
continue # Go back to the top and call Claude again
# Claude is done — return its final text answer
if response.stop_reason == "end_turn":
return final_text
return "Error: agent loop exceeded maximum iterations."
def process_ticket(self, customer_message: str) -> dict:
"""
Public method: takes a raw customer message and returns a full result dict.
This is what you'd call from your API endpoint or webhook handler.
"""
print(f"\n{'='*60}")
print(f"INCOMING TICKET: {customer_message}")
print(f"{'='*60}")
self.add_user_message(customer_message)
final_response = self.run_agent_loop()
return {
"customer_message": customer_message,
"ticket_metadata": self.ticket_metadata,
"final_response": final_response,
"tool_call_log": self.tool_call_log
}
# ── Runner — demonstrates the full system with three sample tickets ───────────
if __name__ == "__main__":
test_tickets = [
"Hi, I forgot my password and can't log in. I've tried three times already.",
"Where is my order? I placed it four days ago and haven't heard anything.",
"I was charged twice for my subscription this month and need a refund."
]
for message in test_tickets:
orchestrator = SupportOrchestrator() # Fresh instance per ticket
result = orchestrator.process_ticket(message)
print(f"\n{'='*60}")
print("FINAL RESULT")
print(f"{'='*60}")
print(f"Ticket ID : {result['ticket_metadata'].get('ticket_id', 'N/A')}")
print(f"Category : {result['ticket_metadata'].get('category', 'N/A')}")
print(f"Priority : {result['ticket_metadata'].get('priority', 'N/A')}")
print(f"Response :\n{result['final_response']}")
print(f"{'='*60}\n")Run the full system with python orchestrator.py. Here's what the output looks like for the first ticket:
============================================================
INCOMING TICKET: Hi, I forgot my password and can't log in. I've tried three times already.
============================================================
--- Agent Loop Iteration 1 ---
Stop reason: tool_use
[Tool Call] route_ticket({
"category": "password_reset",
"priority": "medium",
"summary": "Customer cannot log in after forgetting their password and has made three failed attempts."
})
[Tool Result] {"ticket_id": "TKT-47821", "category": "password_reset", "priority": "medium", "summary": "Customer cannot log in...", "status": "routed"}
[Tool Call] lookup_knowledge_base({
"category": "password_reset"
})
[Tool Result] {"category": "password_reset", "resolution": "To reset your password, visit /account/reset and enter your email. You'll receive a link within 2 minutes.", "found": true}
--- Agent Loop Iteration 2 ---
Stop reason: end_turn
============================================================
FINAL RESULT
============================================================
Ticket ID : TKT-47821
Category : password_reset
Priority : medium
Response :
Hi there! No worries — resetting your password is quick and easy.
Head to **/account/reset** and enter your email address. You'll receive a reset link within 2 minutes. Click the link and you'll be back in your account right away.
If you don't see the email, check your spam folder. Let me know if you need anything else!
============================================================How It Works
Claude doesn't call your Python functions directly — it returns a structured response saying "I want to call this tool with these arguments." Your orchestrator reads that response, runs the function, and hands the result back. Then Claude continues from there.
The loop in run_agent_loop handles this back-and-forth automatically. Each iteration either processes tool calls and feeds results back in, or it hits end_turn and breaks out with Claude's final answer. The conversation history grows with each pass, so Claude always has full context.
The reason we instantiate a fresh SupportOrchestrator per ticket is isolation — each ticket gets its own history, its own tool log, and its own metadata. That makes it easy to log, audit, and debug individual tickets without one conversation bleeding into another.
Common Errors and Fixes
Error 1: anthropic.AuthenticationError — Invalid API Key
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}This almost always means your .env file isn't loading or the key has a typo. Make sure load_dotenv() is called before you instantiate the Anthropic client, and verify your key starts with sk-ant-. Also confirm you're running the script from the same directory as your .env file.
Error 2: ValidationError on Tool Input Schema
anthropic.BadRequestError: Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error', 'message': "tools.0.input_schema: 'required' is required"}}The Anthropic SDK requires every tool definition to have a required array inside input_schema, even if it's empty. Check your TOOL_DEFINITIONS list and make sure every tool has "required": [...] set. Missing or misspelled keys in the schema cause this same error.
Error 3: Agent Loop Never Terminates
# No error message — the script just hangs or runs forever # You'll see "--- Agent Loop Iteration N ---" printing endlessly
This happens when tool results aren't appended to the conversation history in the right format, so Claude keeps requesting tools without ever reaching end_turn. The fix is making sure your tool_results list uses the exact {"type": "tool_result", "tool_use_id": ..., "content": ...} structure. The max_iterations cap in the loop above prevents this from running forever while you debug.
Next Steps
This system is a solid foundation. Here are four directions worth exploring once you have it running:
- Swap the fake knowledge base for a real vector store. Connect Pinecone or pgvector and replace
lookup_knowledge_basewith a semantic similarity search. This makes the system handle any question, not just the categories you pre-defined. - Add a human escalation tool. Build a third tool called
escalate_to_humanthat fires when priority isurgentor when Claude can't find a resolution. It can trigger a Slack message or create a Zendesk ticket via API. - Run tickets in parallel with asyncio. Wrap
process_ticketin an async version and useasyncio.gatherto process a batch of tickets simultaneously. This is how you handle high-volume queues without waiting for each one to finish. - Add memory across sessions. Store
ticket_metadataand conversation history in a database keyed by