Skip to content
Learn Agentic AI
Learn Agentic AI15 min read8 views

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:

  1. Triage Agent — First point of contact, routes customers to the right department
  2. Billing Agent — Handles invoice questions, charge disputes, and payment methods
  3. Refund Agent — Processes refund requests with approval logic
  4. 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,
        ),
    ],
)

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.

Share
C

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.