---
title: "Building a Customer Support Multi-Agent System from Scratch"
description: "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."
canonical: https://callsphere.ai/blog/building-customer-support-multi-agent-system-openai-agents-sdk
category: "Learn Agentic AI"
tags: ["OpenAI", "Customer Support", "Multi-Agent", "Tutorial", "Production"]
author: "CallSphere Team"
published: 2026-03-14T00:00:00.000Z
updated: 2026-05-06T01:02:41.572Z
---

# 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

```bash
# 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:

```mermaid
flowchart LR
    INPUT(["User input"])
    AGENT["Agent
name plus instructions"]
    HAND{"Handoff to
another agent?"}
    SUB["Sub-agent
specialist"]
    GUARD{"Guardrail
passed?"}
    TOOL["Tool call"]
    SDK[("Tracing
OpenAI dashboard")]
    OUT(["Final output"])
    INPUT --> AGENT --> HAND
    HAND -->|Yes| SUB --> GUARD
    HAND -->|No| GUARD
    GUARD -->|Yes| TOOL --> AGENT
    GUARD -->|Block| OUT
    AGENT --> OUT
    AGENT --> SDK
    style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
    style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
    style SDK fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
    style OUT 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:

```python
# 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:

```python
# tools/customer_tools.py
from agents import function_tool

# Simulated customer database
CUSTOMERS = {
    "cust_001": {
        "name": "Alice Johnson",
        "email": "alice@example.com",
        "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": "bob@example.com",
        "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

```python
# 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

```python
# 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

```python
# 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():

```python
# 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:

```python
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:

```python
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

```python
# 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

```python
# 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:

```bash
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.

---

Source: https://callsphere.ai/blog/building-customer-support-multi-agent-system-openai-agents-sdk
