Building a Customer Support Multi-Agent System from Scratch
A complete end-to-end tutorial building a production-grade customer support system with triage, billing, refund, and FAQ agents using handoffs, RECOMMENDED_PROMPT_PREFIX, and prompt_with_handoff_instructions in the OpenAI Agents SDK.
What We Are Building
In this tutorial, we build a complete customer support system with four specialized agents:
- Triage Agent — First point of contact, routes customers to the right department
- Billing Agent — Handles invoice questions, charge disputes, and payment methods
- Refund Agent — Processes refund requests with approval logic
- FAQ Agent — Answers common product questions from a knowledge base
The system uses handoffs for routing, structured input types for metadata, callbacks for logging, and the SDK's built-in prompt helpers for consistent agent behavior.
Project Setup
# Create project
mkdir customer-support-agents && cd customer-support-agents
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install openai-agents pydantic
# Set your API key
export OPENAI_API_KEY="sk-..."
Create the project structure:
flowchart TD
START["Building a Customer Support Multi-Agent System fr…"] --> A
A["What We Are Building"]
A --> B
B["Project Setup"]
B --> C
C["Step 1: Define Handoff Models"]
C --> D
D["Step 2: Build Customer Tools"]
D --> E
E["Step 3: Build the FAQ Agent"]
E --> F
F["Step 4: Build the Refund Agent"]
F --> G
G["Step 5: Build the Billing Agent"]
G --> H
H["Step 6: Build the Triage Agent"]
H --> DONE["Key Takeaways"]
style START fill:#4f46e5,stroke:#4338ca,color:#fff
style DONE fill:#059669,stroke:#047857,color:#fff
customer-support-agents/
├── agents/
│ ├── __init__.py
│ ├── triage.py
│ ├── billing.py
│ ├── refund.py
│ └── faq.py
├── models/
│ ├── __init__.py
│ └── handoff_models.py
├── tools/
│ ├── __init__.py
│ └── customer_tools.py
├── config.py
└── main.py
Step 1: Define Handoff Models
Start with the Pydantic models that structure the metadata passed during handoffs:
# models/handoff_models.py
from pydantic import BaseModel, Field
from enum import Enum
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Sentiment(str, Enum):
POSITIVE = "positive"
NEUTRAL = "neutral"
FRUSTRATED = "frustrated"
ANGRY = "angry"
class BillingHandoffInput(BaseModel):
issue_type: str = Field(
description="One of: charge_dispute, invoice_question, payment_method, plan_change"
)
amount_mentioned: float | None = Field(
default=None,
description="Dollar amount if the customer mentioned one"
)
priority: Priority
sentiment: Sentiment
summary: str = Field(description="One-sentence summary of the billing issue")
class RefundHandoffInput(BaseModel):
original_charge_amount: float | None = Field(
default=None,
description="The charge amount the customer wants refunded"
)
reason: str = Field(description="Why the customer wants a refund")
order_id: str | None = Field(
default=None,
description="Order or transaction ID if mentioned"
)
priority: Priority
sentiment: Sentiment
class FAQHandoffInput(BaseModel):
topic: str = Field(description="The general topic area of the question")
specific_question: str = Field(description="The specific question to answer")
Step 2: Build Customer Tools
Define tools that agents can use to look up customer data:
# tools/customer_tools.py
from agents import function_tool
# Simulated customer database
CUSTOMERS = {
"cust_001": {
"name": "Alice Johnson",
"email": "[email protected]",
"plan": "Pro",
"monthly_charge": 49.99,
"balance": 0.00,
"orders": [
{"id": "ord_101", "amount": 49.99, "date": "2026-02-01", "status": "paid"},
{"id": "ord_102", "amount": 49.99, "date": "2026-03-01", "status": "paid"},
],
},
"cust_002": {
"name": "Bob Smith",
"email": "[email protected]",
"plan": "Enterprise",
"monthly_charge": 199.99,
"balance": 199.99,
"orders": [
{"id": "ord_201", "amount": 199.99, "date": "2026-03-01", "status": "unpaid"},
],
},
}
@function_tool
def lookup_customer(customer_id: str) -> str:
"""Look up a customer's account details by their customer ID."""
customer = CUSTOMERS.get(customer_id)
if not customer:
return f"No customer found with ID {customer_id}"
return (
f"Customer: {customer['name']}\n"
f"Email: {customer['email']}\n"
f"Plan: {customer['plan']}\n"
f"Monthly charge: ${customer['monthly_charge']}\n"
f"Current balance: ${customer['balance']}"
)
@function_tool
def lookup_orders(customer_id: str) -> str:
"""Look up all orders for a customer."""
customer = CUSTOMERS.get(customer_id)
if not customer:
return f"No customer found with ID {customer_id}"
orders = customer["orders"]
if not orders:
return "No orders found."
lines = []
for order in orders:
lines.append(
f"Order {order['id']}: ${order['amount']} on {order['date']} ({order['status']})"
)
return "\n".join(lines)
@function_tool
def process_refund(order_id: str, amount: float, reason: str) -> str:
"""Process a refund for a specific order. Returns confirmation or error."""
# In production, this would call your payment processor
if amount > 500:
return f"REQUIRES_APPROVAL: Refund of ${amount} for order {order_id} exceeds auto-approval limit. Manager approval needed."
return f"REFUND_PROCESSED: ${amount} refund for order {order_id} has been initiated. Reason: {reason}. Customer will see the credit in 5-10 business days."
@function_tool
def search_faq(query: str) -> str:
"""Search the FAQ knowledge base for answers to common questions."""
faqs = {
"cancel": "To cancel your subscription, go to Settings > Billing > Cancel Plan. You will retain access until the end of your billing period.",
"upgrade": "To upgrade your plan, go to Settings > Billing > Change Plan. The price difference is prorated for the current billing period.",
"export": "To export your data, go to Settings > Data > Export. You can export in CSV, JSON, or PDF format. Large exports may take up to 24 hours.",
"api": "API documentation is available at docs.example.com/api. Rate limits are 1000 requests/minute for Pro and 10000 requests/minute for Enterprise.",
"sso": "SSO is available on Enterprise plans. Contact your account manager to configure SAML 2.0 or OIDC integration.",
}
query_lower = query.lower()
matches = []
for key, answer in faqs.items():
if key in query_lower:
matches.append(answer)
if matches:
return "\n\n".join(matches)
return "No FAQ articles found for that query. Please describe your question in more detail."
Step 3: Build the FAQ Agent
# agents/faq.py
from agents import Agent
from tools.customer_tools import search_faq
faq_agent = Agent(
name="FAQAgent",
instructions="""You are an FAQ specialist for Acme Corp. Your job is to
answer common product questions using the FAQ knowledge base.
Guidelines:
- Always search the FAQ first before answering
- If the FAQ does not have an answer, say so honestly and suggest
the customer contact support for further help
- Be concise and direct in your answers
- Do not make up information not in the FAQ""",
model="gpt-4o-mini", # FAQ is simple enough for mini
tools=[search_faq],
)
Step 4: Build the Refund Agent
# agents/refund.py
from agents import Agent, handoff
from tools.customer_tools import lookup_customer, lookup_orders, process_refund
# We will add the escalation handoff after defining the escalation agent
refund_agent = Agent(
name="RefundAgent",
instructions="""You are a refund specialist for Acme Corp. You handle
refund requests following these rules:
REFUND POLICY:
- Refunds within 30 days of charge: automatic approval up to $500
- Refunds over $500: require manager approval (use escalation)
- Refunds older than 30 days: require manager approval
- Duplicate charges: always approve regardless of amount
PROCESS:
1. Look up the customer's account and orders
2. Verify the charge they want refunded exists
3. Check if it meets auto-approval criteria
4. Process the refund or escalate if needed
5. Confirm the refund details with the customer
Always be empathetic but follow the policy strictly.""",
model="gpt-4o",
tools=[lookup_customer, lookup_orders, process_refund],
)
Step 5: Build the Billing Agent
# agents/billing.py
from agents import Agent, handoff
from tools.customer_tools import lookup_customer, lookup_orders
from agents.refund import refund_agent
from models.handoff_models import RefundHandoffInput
billing_agent = Agent(
name="BillingAgent",
instructions="""You are a billing specialist for Acme Corp. You handle:
- Invoice questions
- Charge explanations
- Payment method updates
- Plan changes
If the customer needs a refund, hand them off to the RefundAgent.
Do NOT process refunds yourself.
Always look up the customer's account before answering billing questions.
Be precise with dollar amounts and dates.""",
model="gpt-4o",
tools=[lookup_customer, lookup_orders],
handoffs=[
handoff(
refund_agent,
description="Transfer to refund specialist for refund requests",
tool_name_override="transfer_to_refunds",
input_type=RefundHandoffInput,
),
],
)
Step 6: Build the Triage Agent
This is the entry point. It uses RECOMMENDED_PROMPT_PREFIX and prompt_with_handoff_instructions():
flowchart LR
S0["Step 1: Define Handoff Models"]
S0 --> S1
S1["Step 2: Build Customer Tools"]
S1 --> S2
S2["Step 3: Build the FAQ Agent"]
S2 --> S3
S3["Step 4: Build the Refund Agent"]
S3 --> S4
S4["Step 5: Build the Billing Agent"]
S4 --> S5
S5["Step 6: Build the Triage Agent"]
style S0 fill:#4f46e5,stroke:#4338ca,color:#fff
style S5 fill:#059669,stroke:#047857,color:#fff
# agents/triage.py
from agents import Agent, handoff, RunContextWrapper
from agents import RECOMMENDED_PROMPT_PREFIX, prompt_with_handoff_instructions
from agents.billing import billing_agent
from agents.faq import faq_agent
from models.handoff_models import (
BillingHandoffInput,
FAQHandoffInput,
Priority,
Sentiment,
)
# ─── Handoff Callbacks ───
async def on_billing_handoff(
context: RunContextWrapper[dict],
data: BillingHandoffInput,
) -> None:
context.context["routed_to"] = "billing"
context.context["routing_metadata"] = data.model_dump()
print(f"[TRIAGE → BILLING] {data.issue_type} | Priority: {data.priority} | {data.summary}")
async def on_faq_handoff(
context: RunContextWrapper[dict],
data: FAQHandoffInput,
) -> None:
context.context["routed_to"] = "faq"
context.context["routing_metadata"] = data.model_dump()
print(f"[TRIAGE → FAQ] Topic: {data.topic} | {data.specific_question}")
# ─── Triage Agent with RECOMMENDED_PROMPT_PREFIX ───
base_instructions = """You are the front-line triage agent for Acme Corp customer service.
YOUR ROLE:
- Greet the customer professionally
- Understand their issue quickly (1-2 clarifying questions maximum)
- Route them to the correct specialist
ROUTING RULES:
- Billing questions (invoices, charges, payments, plan changes) → BillingAgent
- If they specifically mention wanting a refund, still route to billing first
- Common product questions (how-to, features, limits) → FAQAgent
- If unsure, ask one clarifying question before routing
IMPORTANT:
- Do NOT attempt to solve the problem yourself
- Do NOT make promises about outcomes
- Be warm but efficient — customers value their time"""
# Use RECOMMENDED_PROMPT_PREFIX for consistent behavior
triage_instructions = f"""{RECOMMENDED_PROMPT_PREFIX}
{base_instructions}"""
# Alternatively, use prompt_with_handoff_instructions() for auto-generated handoff docs
triage_instructions_with_handoffs = prompt_with_handoff_instructions(
base_instructions,
handoffs=[
handoff(
billing_agent,
description="Billing specialist for invoices, charges, payments, and plan changes",
),
handoff(
faq_agent,
description="FAQ specialist for common product questions",
),
],
)
triage_agent = Agent(
name="TriageAgent",
instructions=triage_instructions,
model="gpt-4o",
handoffs=[
handoff(
billing_agent,
description="Billing specialist for invoices, charges, payments, and plan changes",
tool_name_override="route_to_billing",
tool_description_override="Route to billing when the customer has questions about charges, invoices, payments, plan changes, or refunds",
input_type=BillingHandoffInput,
on_handoff=on_billing_handoff,
),
handoff(
faq_agent,
description="FAQ specialist for common product questions",
tool_name_override="route_to_faq",
tool_description_override="Route to FAQ when the customer has general product questions about features, how-to, or limits",
input_type=FAQHandoffInput,
on_handoff=on_faq_handoff,
),
],
)
Understanding RECOMMENDED_PROMPT_PREFIX
The RECOMMENDED_PROMPT_PREFIX is a pre-built string from the SDK that includes standard instructions for agent behavior. It covers:
- How to handle handoffs properly
- How to interpret tool results
- Standard response formatting guidelines
You prepend it to your custom instructions:
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
from agents import RECOMMENDED_PROMPT_PREFIX
instructions = f"""{RECOMMENDED_PROMPT_PREFIX}
Your custom instructions here..."""
Understanding prompt_with_handoff_instructions()
The prompt_with_handoff_instructions() function takes your base prompt and a list of handoff objects, and returns a prompt with auto-generated documentation about available handoffs:
from agents import prompt_with_handoff_instructions, handoff
enhanced_prompt = prompt_with_handoff_instructions(
"You are a triage agent.",
handoffs=[
handoff(billing_agent, description="Billing specialist"),
handoff(faq_agent, description="FAQ specialist"),
],
)
# The returned prompt includes something like:
# "You are a triage agent.
#
# HANDOFF INSTRUCTIONS:
# You can transfer the conversation to the following specialists:
# - route_to_billing: Billing specialist
# - route_to_faq: FAQ specialist
# When you determine the customer needs one of these specialists,
# use the appropriate transfer tool."
This is useful when you want the SDK to generate consistent handoff documentation rather than writing it manually in your instructions.
Step 7: Configuration
# config.py
from agents import RunConfig
run_config = RunConfig(
# Nest history so each agent clearly sees what happened before
nest_handoff_history=True,
# Limit total model calls to prevent infinite loops
max_turns=25,
)
Step 8: Main Entry Point
# main.py
import asyncio
from agents import Runner
from agents.triage import triage_agent
from config import run_config
async def handle_customer_message(message: str) -> dict:
"""Process a customer message through the multi-agent system."""
context = {
"routed_to": None,
"routing_metadata": None,
"customer_id": None,
}
result = await Runner.run(
triage_agent,
input=message,
context=context,
run_config=run_config,
)
return {
"response": result.final_output,
"final_agent": result.last_agent.name,
"routed_to": context.get("routed_to"),
"metadata": context.get("routing_metadata"),
}
async def interactive_session():
"""Run an interactive customer support session."""
print("Acme Corp Customer Support")
print("Type 'quit' to exit")
print("-" * 40)
while True:
user_input = input("\nYou: ").strip()
if user_input.lower() == "quit":
break
result = await handle_customer_message(user_input)
print(f"\n[{result['final_agent']}]: {result['response']}")
if result["routed_to"]:
print(f" (Routed to: {result['routed_to']})")
async def run_test_scenarios():
"""Run predefined test scenarios."""
scenarios = [
"I was charged twice this month and want my money back",
"How do I export my data to CSV?",
"Can you explain what the $49.99 charge on March 1st was for?",
"I want to upgrade from Pro to Enterprise",
]
for scenario in scenarios:
print(f"\n{'=' * 60}")
print(f"Customer: {scenario}")
print("-" * 60)
result = await handle_customer_message(scenario)
print(f"[{result['final_agent']}]: {result['response']}")
print(f"Routed to: {result['routed_to']}")
print(f"Metadata: {result['metadata']}")
if __name__ == "__main__":
asyncio.run(run_test_scenarios())
Testing the System
Run the test scenarios:
python main.py
Expected routing:
| Customer Message | Expected Route | Expected Agent |
|---|---|---|
| "charged twice...want money back" | billing → refund | RefundAgent |
| "How do I export data to CSV?" | faq | FAQAgent |
| "explain the $49.99 charge" | billing | BillingAgent |
| "upgrade from Pro to Enterprise" | billing | BillingAgent |
Production Considerations
Error handling: Wrap Runner.run() in try/except to catch API errors, timeout errors, and validation errors. Never let an unhandled exception reach the customer.
Timeouts: Set max_turns in RunConfig to prevent infinite handoff loops. A customer should never be stuck in an agent loop.
Logging: The on_handoff callbacks provide structured logging. In production, send these to your observability platform (Datadog, Grafana, etc.) for routing accuracy dashboards.
Fallback: Add a fallback agent that catches any routing failures and provides a generic helpful response plus an option to contact a human.
Conversation persistence: The example above handles single-turn conversations. For multi-turn, store the conversation history and pass it back to Runner.run() on each turn.
Summary
This tutorial walked through building a complete customer support multi-agent system. The key architectural decisions were: using a triage agent as the single entry point, structuring handoff metadata with Pydantic models, using callbacks for observability, and leveraging RECOMMENDED_PROMPT_PREFIX and prompt_with_handoff_instructions() for consistent agent behavior. The system handles billing questions, refund processing, and FAQ lookups through specialized agents connected by typed handoffs.
Written by
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.