Multi-Agent Support Desk

Build a multi-agent support desk with handoffs, tools, and guardrails.

Introduction

30 Second Summary

When you contact customer support, your message gets routed to the right team. A billing specialist handles your invoice question while a different agent troubleshoots your login issue.

In this project, you will build a multi-agent support desk using the OpenAI Agents SDK. You'll wire up specialized agents, add guardrails, and trace every decision as customer queries flow through the system.

What You'll Build

You'll type a customer question into your terminal and watch the right specialist agent pick it up, pull data with its own tools, and respond.

By the end of this project, you'll have:

  • A working support desk where a triage agent reads your query and hands it off to a billing or technical specialist via handoffs. Each specialist is equipped with function tools that pull real data like invoices and system status.
  • A manager agent that calls both specialists as tools on a single question and delivers one unified answer, even when the query spans billing and technical domains.
  • An input guardrail that blocks off-topic queries before any agent runs, plus full tracing visible in the OpenAI Traces dashboard showing exactly which agents fired and why.
  • Secret Mission: Add an output guardrail that validates the specialist's response before it reaches the customer.

Are there any prerequisites?

This project assumes you're comfortable with Python's async/await syntax and have called an LLM API before. No prior experience with the OpenAI Agents SDK is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the SDK and Run a Single Agent

Before wiring up multiple agents, you need a foundation. That means the OpenAI Agents SDK installed, your API key connected, and a running agent that proves everything works.

In this step, you'll create a single monolithic agent that handles all support queries. Running it will reveal exactly why one agent isn't enough.

In this step, get ready to:
  • Set up your project directory and install the OpenAI Agents SDK.
  • Write a monolithic support agent and run it.
  • Observe why a single agent gives generic, unspecialized answers.
Create your project and install the SDK
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir support-desk
cd support-desk
  • Create a Python virtual environment by running this command:
python3 -m venv venv

Windows users

On Windows, use python instead of python3 for all commands in this project.

  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

This same command works on Linux.

Windows

.\venv\Scripts\Activate.ps1

Your terminal prompt now starts with (venv). This confirms the virtual environment is active.

Don't see (venv) in your prompt?

  • Make sure you ran the activation command for your operating system. The macOS and Windows commands are different.
  • On Windows PowerShell, you may need to allow script execution first. Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser and try activating again.
  • Confirm you're still in the support-desk folder where you created the venv.

Help me activate my Python virtual environment.

  • Install the OpenAI Agents SDK by running:
pip install openai-agents

You should see output ending with a list of successfully installed packages.

Install failing?

  • Check that (venv) appears in your prompt. If it doesn't, the virtual environment isn't active.
  • If you see a permissions error, make sure you're using the venv's pip, not your system pip. Reactivate the virtual environment and try again.

Help me install the openai-agents package.

The SDK needs your OpenAI API key to make calls. You'll set it as an environment variable so the SDK picks it up automatically.

  • Export your API key by running the command for your operating system:

macOS

export OPENAI_API_KEY=your-api-key-here

This same command works on Linux.

Windows

$env:OPENAI_API_KEY = "your-api-key-here"

Where do I find my API key?

Go to platform.openai.com, sign in, and navigate to API keys in the left sidebar. Create a new key and paste it into the command above, replacing your-api-key-here with your actual key.

This environment variable only lasts for the current terminal session. If you close your terminal, you'll need to export it again.

Write a single support agent

You'll start with a single agent that tries to handle every type of customer query. This is the simplest setup possible.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type the name of your code editor (such as VS Code or Cursor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the support-desk folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, click the new file icon (a page with a "+" symbol) at the top of the file list.
  • Name the file support_desk.py and press Enter.
  • Set up the imports and define the support agent by copying this code into support_desk.py:
import asyncio
from agents import Agent, Runner

# Create a single agent that handles all support queries
support_agent = Agent(
    name="Support Agent",
    instructions=(
        "You are a customer support agent. Help customers with billing questions "
        "(invoices, charges, refunds) and technical issues (outages, bugs, portal problems). "
        "Be helpful and concise."
    ),
)

What does this code do?

  • Agent and Runner are the two core classes in the OpenAI Agents SDK. An Agent defines who the agent is, and a Runner executes it.
  • The instructions string is the agent's system prompt. It tells the model what role to play and what topics to cover.
  • This agent has broad instructions covering both billing and technical support. It has no tools, no specialization, and no way to look up real data.
  • Add the main block below the agent definition to test it with two different queries:
if __name__ == "__main__":
    # Test with a billing question
    result = Runner.run_sync(support_agent, "What is the status of my invoice INV-001?")
    print(result.final_output)
    # Test with a technical question
    result2 = Runner.run_sync(support_agent, "The portal is down, what should I do?")
    print(result2.final_output)

What does this code do?

  • Runner.run_sync() sends a prompt to the agent and waits for the full response. It's the synchronous wrapper around the SDK's async engine.
  • result.final_output holds the agent's text reply. You're printing it so you can read the response in the terminal.
  • The two test prompts cover billing (invoice lookup) and technical support (portal outage). Both go to the same agent.
  • Save support_desk.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

Here's the complete support_desk.py file so far. Make sure yours matches:

import asyncio
from agents import Agent, Runner

support_agent = Agent(
    name="Support Agent",
    instructions=(
        "You are a customer support agent. Help customers with billing questions "
        "(invoices, charges, refunds) and technical issues (outages, bugs, portal problems). "
        "Be helpful and concise."
    ),
)

if __name__ == "__main__":
    result = Runner.run_sync(support_agent, "What is the status of my invoice INV-001?")
    print(result.final_output)
    result2 = Runner.run_sync(support_agent, "The portal is down, what should I do?")
    print(result2.final_output)
Run the agent and see its limits

The agent is ready to run. The first prompt asks about invoice INV-001, and the second reports a portal outage.

What kind of answers do you think a single, tool-less agent will give? Make a prediction before you run it.

  • Switch back to your terminal.
  • Run the script by entering this command:
python support_desk.py

You'll see two responses printed in the terminal, one after the other. Read the answers carefully.

What should I see?

The billing answer is generic. The agent can't actually look up invoice INV-001 because it has no tool to query invoice data. It gives a vague, helpful-sounding response instead of real information.

The technical answer is equally generic. The agent has no way to check whether the portal is actually down. It can only suggest general troubleshooting steps.

Why are the answers so generic?

This single agent has three problems. It has no domain specialization, so it gives surface-level answers about everything. It has no tools, so it can't look up invoices or check system status.

It also has no routing logic, so every query gets the same treatment regardless of topic. This is exactly the gap the rest of the project fixes.

Seeing an error instead of output?

  • If you see an authentication error, double check that you exported your OPENAI_API_KEY correctly in this terminal session. The key must be valid and have credits available.
  • If you see a module not found error, make sure (venv) appears in your prompt. If it doesn't, reactivate the virtual environment and try again.
  • On Windows, use python support_desk.py instead of python3.

Help me debug my support agent script.

Your environment is working and you've seen the problem firsthand. A single agent with no specialization and no tools gives vague answers to everything. Next up, you'll split this into specialist agents that can actually look up invoices and check system status.

Split Into Specialists and Add Handoffs

In Step 1, the single support agent could answer billing and technical questions. The problem was that its answers were generic. It had no way to look up a specific invoice or check whether a service was actually down.

The fix is specialization. In this step, you'll split the monolithic agent into a billing specialist and a technical support specialist, each with their own tools. A triage agent will read each incoming query and hand off control to the right specialist using the SDK's handoff mechanism.

In this step, get ready to:
  • Add function tools that give each specialist real capabilities.
  • Create specialist agents and wire up handoffs between them.
  • Build a conversation loop that prints every handoff and tool call in real time.
Add function tools for each specialist

Before creating the agents, you need to define the tools they'll use. The billing agent will look up invoices by confirmation number. The technical agent will check the status of a service.

These are mock implementations that return hardcoded data, which is perfect for learning the pattern without needing a real database.

  • Replace the entire contents of support_desk.py with the following imports and first function tool:
import asyncio
from agents import Agent, Runner, handoff, trace
from agents import (
    HandoffOutputItem,
    ItemHelpers,
    MessageOutputItem,
    ToolCallItem,
    ToolCallOutputItem,
)
from agents.tool import function_tool

# -- Function tools --

@function_tool
def lookup_invoice(confirmation_number: str) -> str:
    """Look up an invoice by confirmation number."""
    invoices = {
        "INV-001": "Invoice INV-001: $49.99 charged on Aug 1, 2026. Status: Paid.",
        "INV-002": "Invoice INV-002: $129.00 charged on Aug 15, 2026. Status: Pending.",
    }
    return invoices.get(confirmation_number, f"No invoice found for {confirmation_number}.")

What does this code do?

The imports bring in everything you'll need for multi-agent orchestration: handoff for routing between agents, trace for observability, and the event types (HandoffOutputItem, ToolCallItem, etc.) for inspecting what happened during a run.

The @function_tool decorator turns a regular Python function into a tool the agent can call. The SDK auto-generates the tool schema from the function's signature and docstring. The lookup_invoice function returns mock invoice data from a dictionary.

  • Add the second function tool below lookup_invoice by pasting this code:
@function_tool
def check_system_status(service_name: str) -> str:
    """Check the current status of a service."""
    statuses = {
        "portal": "Portal: Operational. Last incident resolved Aug 20, 2026.",
        "api": "API: Degraded performance. Investigating since 10:00 UTC.",
        "billing": "Billing system: Operational. No issues.",
    }
    return statuses.get(
        service_name.lower(),
        f"Unknown service: {service_name}. Available: portal, api, billing.",
    )

What does this code do?

The check_system_status tool takes a service name and returns its current status from a mock dictionary. It normalizes the input to lowercase so Portal and portal both work. If the service name is unrecognized, it returns a helpful error listing the available services.

  • Save support_desk.py.
  • Verify the file loads without errors by running this command:
python -c "import support_desk; print('Tools loaded successfully')"

You should see Tools loaded successfully printed with no traceback.

Seeing an import error?

  • Make sure you deleted the old support_agent and Runner.run_sync code from Step 1. The file should start with the new imports.
  • Check that your virtual environment is still activated. You should see its name in your terminal prompt.
  • Confirm openai-agents is installed by running pip list | grep openai-agents.

Still stuck? Help me debug an import error in support_desk.py after replacing the file contents with new imports and function tools.

Create specialist agents and wire up handoffs

Each specialist agent gets its own instructions, its own tools, and a handoff_description that tells the model when to route queries its way.

  • Add the specialist agents below check_system_status by pasting this code:
# -- Specialist agents --

billing_agent = Agent(
    name="Billing Agent",
    handoff_description="Handles billing questions: invoices, charges, refunds, payment issues.",
    instructions=(
        "You are a billing specialist. Help customers with invoice lookups, "
        "charge explanations, and payment issues. Use the lookup_invoice tool "
        "when a customer provides a confirmation number. Be concise and helpful."
    ),
    tools=[lookup_invoice],
)

technical_agent = Agent(
    name="Technical Support Agent",
    handoff_description="Handles technical issues: outages, bugs, system status, portal problems.",
    instructions=(
        "You are a technical support specialist. Help customers with system issues, "
        "outages, and portal problems. Use the check_system_status tool to look up "
        "service status. Be concise and helpful."
    ),
    tools=[check_system_status],
)

What is handoff_description?

The handoff_description tells the triage agent when to route to this specialist. The model reads these descriptions and matches them against the customer's query to pick the right agent. Think of it as a job title the triage agent checks before forwarding a call.

Each specialist also gets a tools list containing the function tool it can call. The billing agent can call lookup_invoice. The technical agent can call check_system_status.

  • Add the triage agent and handback wiring below the specialist agents by pasting this code:
# -- Triage agent --

triage_agent = Agent(
    name="Triage Agent",
    instructions=(
        "You are a customer support triage agent. Determine what the customer needs "
        "and hand off to the appropriate specialist. For billing questions (invoices, "
        "charges, refunds), hand off to the Billing Agent. For technical issues "
        "(outages, bugs, portal problems), hand off to the Technical Support Agent. "
        "If the query doesn't fit either category, let the customer know you can only "
        "help with billing and technical support."
    ),
    handoffs=[
        handoff(agent=billing_agent, tool_name_override="transfer_to_billing"),
        handoff(agent=technical_agent, tool_name_override="transfer_to_technical"),
    ],
)

# Allow specialists to hand back to triage
billing_agent.handoffs.append(
    handoff(agent=triage_agent, tool_name_override="transfer_to_triage")
)
technical_agent.handoffs.append(
    handoff(agent=triage_agent, tool_name_override="transfer_to_triage")
)

How do handoffs work?

The handoff() function creates a routing connection between agents. The triage_agent has two handoffs: one to billing_agent and one to technical_agent. When the model decides a query is about billing, it triggers the transfer_to_billing handoff and the billing agent takes over the conversation.

The last two lines wire the reverse direction. If a specialist receives a query outside its domain, it can hand back to triage. Without this, a specialist would be stuck trying to answer questions it knows nothing about.

  • Save support_desk.py.
  • Verify the agents loaded correctly by running:
python -c "import support_desk; print(support_desk.triage_agent.name)"

You should see Triage Agent printed in the terminal.

Seeing a NameError?

  • Check that billing_agent and technical_agent are defined above triage_agent. Python reads top to bottom, so the triage agent's handoffs list references agents that must already exist.
  • Confirm that lookup_invoice and check_system_status are defined above the specialist agents. Each agent's tools list references these functions.

Still stuck? Help me fix a NameError when loading my triage agent and specialist agents.

Build the conversation loop

The agents are defined but there is no way to talk to them yet. You need a loop that accepts user input, runs the triage agent, and tracks which agent is currently active across turns.

  • Add the conversation loop at the bottom of support_desk.py by pasting this code:
async def main():
    print("=== Multi-Agent Support Desk (Handoff Pattern) ===")
    print("Type 'quit' to exit.\n")

    current_agent = triage_agent
    input_items = []

    while True:
        user_input = input("You: ")
        if user_input.lower() == "quit":
            break

        input_items.append({"content": user_input, "role": "user"})

        with trace("Support Desk Handoffs"):
            result = await Runner.run(current_agent, input_items)

            input_items = result.to_input_list()
            current_agent = result.last_agent

        print()


if __name__ == "__main__":
    asyncio.run(main())

What does this code do?

  • The while True loop reads user input and appends it to input_items as a message dict. This builds up the conversation history across turns.
  • The Runner.run() call is async (unlike the Runner.run_sync() from Step 1) because multi-agent workflows with handoffs need the async runner.
  • After each run, result.to_input_list() converts the result back into the input format for the next turn. And result.last_agent returns whichever agent was active at the end, so the conversation continues with the right specialist.
  • Save support_desk.py.

Before you run the script, do you think asking a billing question will show the response directly, or will something be missing from the output?

  • Run the support desk by running:
python support_desk.py
  • Type What is the status of invoice INV-001? and press Enter.

You see the banner and the You: prompt. After you type a question, the agent processes it behind the scenes, but the terminal shows nothing except a blank line. The loop is working, but it does not print what the agents are doing.

  • Type quit and press Enter to exit.

The loop processes queries but does not show you the handoffs, tool calls, or responses. Let's fix that by adding event printing.

  • In support_desk.py, find this line inside the with trace block:
            result = await Runner.run(current_agent, input_items)
  • Add the following event-printing loop between that line and input_items = result.to_input_list():
            for item in result.new_items:
                agent_name = item.agent.name
                if isinstance(item, HandoffOutputItem):
                    print(
                        f"  [Handed off from {item.source_agent.name} "
                        f"to {item.target_agent.name}]"
                    )
                elif isinstance(item, MessageOutputItem):
                    text = ItemHelpers.text_message_output(item)
                    if text:
                        print(f"{agent_name}: {text}")
                elif isinstance(item, ToolCallItem):
                    print(f"  [{agent_name} calling a tool...]")
                elif isinstance(item, ToolCallOutputItem):
                    print(f"  [{agent_name} tool result: {item.output}]")

What does each event type show?

  • HandoffOutputItem: prints which agent handed off to which. The source_agent and target_agent attributes give you both ends of the handoff.
  • MessageOutputItem: the agent's text response. ItemHelpers.text_message_output() extracts just the text content from the raw output.
  • ToolCallItem: fires when an agent invokes one of its function tools (like lookup_invoice).
  • ToolCallOutputItem: the return value from the function tool, which the agent then uses to craft its response.
  • Save support_desk.py.

This time, do you think you'll see the handoff message before or after the specialist's response?

  • Run the support desk again:
python support_desk.py
  • Type What is the status of invoice INV-001? and press Enter.

You should see output like this in the terminal:

  [Handed off from Triage Agent to Billing Agent]
  [Billing Agent calling a tool...]
  [Billing Agent tool result: Invoice INV-001: $49.99 charged on Aug 1, 2026. Status: Paid.]
Billing Agent: ...

The handoff message appears first, then the tool call, then the tool result, and finally the specialist's response. The triage agent read your question, matched it to the billing agent's handoff_description, and transferred control.

  • Try a technical question like Is the portal down? to see the handoff route to the technical agent instead.
  • Type quit to exit when you're done.

Not seeing handoff messages?

  • Make sure the for item in result.new_items: loop is inside the with trace block, after result = await Runner.run(current_agent, input_items) and before input_items = result.to_input_list().
  • Check indentation carefully. The for loop should be indented at the same level as the result line (three levels deep: function, while, with).
  • Verify your OPENAI_API_KEY is still exported. If the key expired or was unset, the agent run will fail.

Still stuck? Help me debug why my handoff messages are not printing in the conversation loop.

✔️ Awesome, I've got everything!

Great. Double check you've saved support_desk.py before moving on.

ⓧ I'd like to double check the full code

Your support_desk.py should contain these sections in order. Scroll up and confirm each one matches the code blocks above:

  • Imports: asyncio, Agent, Runner, handoff, trace, event types, and function_tool.
  • lookup_invoice function with the @function_tool decorator.
  • check_system_status function with the @function_tool decorator.
  • billing_agent and technical_agent with handoff_description and tools.
  • triage_agent with handoffs to both specialists.
  • Handback wiring: both specialists append a handoff back to triage_agent.
  • async def main() with the conversation loop, event printing for HandoffOutputItem, MessageOutputItem, ToolCallItem, and ToolCallOutputItem.
  • if __name__ == "__main__": asyncio.run(main()) at the very bottom.

Your agents now specialize and route queries to the right expert. Next up, you'll rebuild this same system using the agent-as-tool pattern, where a manager agent stays in control and synthesizes responses from multiple specialists.

Rebuild as Agent-as-Tool Orchestration

Your handoff system routes each customer query to the right specialist. But what happens when a customer asks about billing and a technical issue in the same message? The triage agent picks one specialist and the other concern gets dropped.

The agent-as-tool pattern solves this. Instead of handing off full control, a manager agent stays in charge and calls specialists as tools. The manager can call both specialists, collect their answers, and compose a single unified response.

In this step, get ready to:
  • Create support_desk_tools.py with specialist agents registered as tools on a Manager Agent.
  • Run a cross-domain query and see the manager synthesize a response from both specialists.
  • Compare handoff vs agent-as-tool patterns side by side with the same question.
Create the new file with function tools

You will build the agent-as-tool version in a separate file so you can run both patterns side by side for comparison.

  • Create a new file called support_desk_tools.py in the same folder as support_desk.py.
  • Add the imports and the first function tool by pasting this code:
import asyncio
from agents import (
    Agent,
    Runner,
    trace,
)
from agents.tool import function_tool


# -- Function tools --

@function_tool
def lookup_invoice(confirmation_number: str) -> str:
    """Look up an invoice by confirmation number."""
    invoices = {
        "INV-001": "Invoice INV-001: $49.99 charged on Aug 1, 2026. Status: Paid.",
        "INV-002": "Invoice INV-002: $129.00 charged on Aug 15, 2026. Status: Pending.",
    }
    return invoices.get(confirmation_number, f"No invoice found for {confirmation_number}.")

What does this code do?

These imports and the lookup_invoice tool are the same ones you used in support_desk.py. You are reusing them in a new file so both orchestration patterns stay available for comparison.

The trace import will be used later when you wrap runs in a named trace.

  • Save support_desk_tools.py.
  • Confirm the file exists by running:
ls

You should see support_desk_tools.py alongside support_desk.py in the output.

  • Add the second function tool below the lookup_invoice function in support_desk_tools.py:

@function_tool
def check_system_status(service_name: str) -> str:
    """Check the current status of a service."""
    statuses = {
        "portal": "Portal: Operational. Last incident resolved Aug 20, 2026.",
        "api": "API: Degraded performance. Investigating since 10:00 UTC.",
        "billing": "Billing system: Operational. No issues.",
    }
    return statuses.get(
        service_name.lower(),
        f"Unknown service: {service_name}. Available: portal, api, billing.",
    )

What does this code do?

This is the same check_system_status tool from before. It returns mock service status data so the technical specialist has something to work with.

Add specialist agents and the manager

Now you will define the specialist agents and then the Manager Agent that calls them as tools.

  • Add the specialist agents below the check_system_status function:


# -- Specialist agents (used as tools) --

billing_agent = Agent(
    name="Billing Agent",
    instructions=(
        "You are a billing specialist. Help customers with invoice lookups, "
        "charge explanations, and payment issues. Use the lookup_invoice tool "
        "when a customer provides a confirmation number. Be concise."
    ),
    tools=[lookup_invoice],
)

technical_agent = Agent(
    name="Technical Support Agent",
    instructions=(
        "You are a technical support specialist. Help customers with system issues, "
        "outages, and portal problems. Use the check_system_status tool to look up "
        "service status. Be concise."
    ),
    tools=[check_system_status],
)

How are these different from the handoff version?

These specialists have no handoff_description parameter. They do not need one because no triage agent will hand off to them. Instead, the manager will call them as tools.

They also have no handoffs back to triage. The manager stays in control the entire time.

  • Save support_desk_tools.py.
  • Verify the agents load correctly by running:
python -c "from support_desk_tools import billing_agent, technical_agent; print(billing_agent.name, technical_agent.name)"

You should see Billing Agent Technical Support Agent printed in the terminal.

Seeing an import error?

  • Make sure you saved support_desk_tools.py before running the command.
  • Check that your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
  • Verify your imports match the code above exactly. A missing comma or parenthesis will cause a syntax error.

help me debug an import error in support_desk_tools.py

  • Now add the Manager Agent below the specialist agents. This is the key new code in this file:


# -- Manager agent (agent-as-tool pattern) --

manager_agent = Agent(
    name="Support Manager",
    instructions=(
        "You are a customer support manager. Use your tools to delegate to the "
        "appropriate specialist. For billing questions, use the billing_query tool. "
        "For technical issues, use the technical_query tool. If a question spans "
        "both domains, call both tools and synthesize a unified response. "
        "Never answer domain questions yourself. Always use the tools."
    ),
    tools=[
        billing_agent.as_tool(
            tool_name="billing_query",
            tool_description="Delegate a billing question (invoices, charges, refunds) to the billing specialist.",
        ),
        technical_agent.as_tool(
            tool_name="technical_query",
            tool_description="Delegate a technical question (outages, bugs, system status) to the technical specialist.",
        ),
    ],
)

What does .as_tool() do?

The .as_tool() method wraps an entire Agent as a callable tool. When the manager calls billing_query, the SDK runs billing_agent behind the scenes and returns its answer to the manager.

The critical difference from handoffs: the manager never gives up control. It receives the specialist's output, decides if it needs more information, and composes the final response itself.

Why agent-as-tool instead of handoffs?

Handoffs work well when each query belongs to exactly one specialist. The triage agent routes, the specialist takes over, done. But a query like "I was overcharged and the portal is down" spans two domains.

With handoffs, the triage agent picks one specialist and the other concern is lost. With agent-as-tool, the manager calls both billing_query and technical_query, then weaves both answers into one unified response.

Run the manager with a cross-domain query

The manager agent is defined. Now you need a main() function to run it and a cross-domain query to test it.

  • Add the main() function below the manager_agent definition:


async def main():
    print("=== Multi-Agent Support Desk (Agent-as-Tool) ===")
    print("Type 'quit' to exit.\n")

    while True:
        user_input = input("You: ")
        if user_input.lower() == "quit":
            break

        with trace("Support Desk"):
            result = await Runner.run(manager_agent, user_input)
            print(f"Support Manager: {result.final_output}")

        print()


if __name__ == "__main__":
    asyncio.run(main())

What does this code do?

This loop is simpler than the handoff version. It passes the user's input directly to manager_agent using Runner.run() and prints result.final_output.

You do not need to track current_agent or input_items because control never leaves the manager. The specialist calls happen internally and the manager owns the final answer.

  • Save support_desk_tools.py.

Before you run this, think about what will happen when you ask a question that touches both billing and technical support. Do you think the manager will call one specialist or both?

  • Run the agent-as-tool version:
python support_desk_tools.py
  • When you see the You: prompt, type:
I was overcharged and the portal is down

The manager calls both billing_query and technical_query, then prints a synthesized response that addresses both the billing overcharge and the portal outage.

  • Type quit to exit.

Manager only addressing one concern?

  • LLM responses are non-deterministic. Run the script again and try the same query. The manager's instructions explicitly say to call both tools for cross-domain queries.
  • Make sure your manager_agent instructions contain the line "If a question spans both domains, call both tools and synthesize a unified response."

help me troubleshoot why my manager agent is not calling both specialist tools

Now run the handoff version with the exact same cross-domain query and compare the results.

  • Run the handoff version:
python support_desk.py

What do you think will happen when you type the same cross-domain question into the handoff version? Will the triage agent call both specialists or pick just one?

  • When you see the You: prompt, type:
I was overcharged and the portal is down

The triage agent hands off to one specialist. It picks either the Billing Agent or the Technical Support Agent, but not both. The other concern goes unanswered.

  • Type quit to exit.

Why does the handoff version miss one concern?

A handoff transfers full control to the specialist. The triage agent can only hand off to one agent at a time, so it picks whichever domain it considers the primary concern.

The agent-as-tool manager has no such limitation. It treats specialists as callable tools and can invoke both in a single turn, then compose the results into one response.

✔️ Awesome, I've got everything!

Great. Double check that you have saved support_desk_tools.py.

ⓧ I'd like to double check the full code

Your support_desk_tools.py should contain the following sections in order. Scroll up and confirm each one is present:

  • Imports (asyncio, Agent, Runner, trace, function_tool).
  • lookup_invoice function tool.
  • check_system_status function tool.
  • billing_agent and technical_agent specialist agents (no handoff_description, no handoffs).
  • manager_agent with .as_tool() calls for billing_query and technical_query.
  • main() async function with the conversation loop and asyncio.run(main()) at the bottom.

support_desk.py should be unchanged from the previous step.

You now have two orchestration patterns running side by side. The handoff version is great for single-domain queries where the specialist should respond directly. The agent-as-tool version shines when a manager needs to coordinate multiple specialists. Next up, you will add a guardrail that blocks off-topic queries before they reach any agent.

Add an Input Guardrail

Your manager agent can now delegate to both specialists and synthesize cross-domain answers. But there is a gap in the system: any input reaches the agents, including completely off-topic queries like math homework or recipe requests.

Every off-topic query that reaches your agents wastes tokens and costs money. An input guardrail screens every query with a cheap, fast model before the main agents run. If the query is off-topic, the guardrail blocks it instantly.

In this step, get ready to:
  • Create a guardrail agent that classifies queries as on-topic or off-topic.
  • Wire the guardrail into the manager agent so it screens every input.
  • Test that legitimate queries pass through and off-topic queries get blocked.
Create the guardrail agent and screening function

The guardrail needs three pieces: a Pydantic model to structure its output, a lightweight agent that classifies queries, and a function that wires the classification into the SDK's guardrail system.

  • In support_desk_tools.py, find the import block at the top of the file.
  • Find this code:
import asyncio
from agents import (
    Agent,
    Runner,
    trace,
)
from agents.tool import function_tool
  • Replace it with the following updated imports:
import asyncio
from pydantic import BaseModel
from agents import (
    Agent,
    GuardrailFunctionOutput,
    InputGuardrailTripwireTriggered,
    Runner,
    trace,
)
from agents.decorators import input_guardrail
from agents.tool import function_tool

What changed in the imports?

  • BaseModel from pydantic lets you define a structured output schema for the guardrail agent.
  • GuardrailFunctionOutput and InputGuardrailTripwireTriggered are the SDK types for returning guardrail results and catching blocked queries.
  • The @input_guardrail decorator from agents.decorators turns a regular function into a guardrail the SDK knows how to run.
  • Now scroll down to the line that says # -- Manager agent (agent-as-tool pattern) --.
  • Add the following guardrail code directly above that comment:
# -- Input guardrail --

class TopicCheckOutput(BaseModel):
    is_off_topic: bool
    reasoning: str


guardrail_agent = Agent(
    name="Topic Guardrail",
    instructions=(
        "Determine if the user's message is a legitimate customer support query "
        "(billing, invoices, technical issues, system status, account problems). "
        "If the message is unrelated to customer support (e.g., math homework, "
        "general knowledge, creative writing), mark it as off-topic."
    ),
    output_type=TopicCheckOutput,
    model="gpt-4o-mini",
)


@input_guardrail
async def topic_guardrail(ctx, agent, input):
    result = await Runner.run(guardrail_agent, input, context=ctx.context)
    return GuardrailFunctionOutput(
        output_info=result.final_output,
        tripwire_triggered=result.final_output.is_off_topic,
    )

What does this code do?

  • TopicCheckOutput defines the shape of the guardrail's response: a boolean is_off_topic and a reasoning string explaining the decision.
  • guardrail_agent is a lightweight agent that runs on gpt-4o-mini (the cheapest model at $0.15 per million input tokens). Its only job is to classify queries as on-topic or off-topic.
  • topic_guardrail is the function decorated with @input_guardrail. It runs the guardrail agent, reads the is_off_topic field, and returns a GuardrailFunctionOutput. If tripwire_triggered is True, the SDK halts the main agent run immediately.

Setting output_type=TopicCheckOutput on the agent forces the model to return structured JSON matching your Pydantic model. You get typed fields instead of free-text guesswork.

The guardrail function exists but nothing is using it yet. The next substep connects it to the manager.

Attach the guardrail and handle the exception

The guardrail function needs to be registered on the manager agent. The SDK checks the agent's input_guardrails list before every run.

  • In support_desk_tools.py, scroll down to manager_agent and find the closing parenthesis of the tools list.
  • Find this line:
    ],
)
  • Replace it with the following, which adds input_guardrails before closing the agent:
    ],
    input_guardrails=[topic_guardrail],
)
  • Now scroll down to the main() function and replace the entire function with this updated version:
async def main():
    print("=== Multi-Agent Support Desk (Agent-as-Tool + Guardrail) ===")
    print("Type 'quit' to exit.\n")

    while True:
        user_input = input("You: ")
        if user_input.lower() == "quit":
            break

        with trace("Support Desk"):
            try:
                result = await Runner.run(manager_agent, user_input)
                print(f"Support Manager: {result.final_output}")
            except InputGuardrailTripwireTriggered:
                print("Guardrail tripped: off-topic query blocked.")

        print()


if __name__ == "__main__":
    asyncio.run(main())

What changed in main()?

  • The banner now says Agent-as-Tool + Guardrail so you can tell which version you are running.
  • Runner.run() is wrapped in a try/except block. If the guardrail trips, the SDK raises InputGuardrailTripwireTriggered and the except block prints a rejection message instead of crashing.
  • Save support_desk_tools.py.
  • Run the script with a legitimate support query by running:
python support_desk_tools.py
  • When the You: prompt appears, type a billing question like:
I was charged twice on invoice INV-001

The guardrail classifies this as a legitimate support query, so the manager agent processes it normally. You should see a response from Support Manager addressing the billing issue.

Not seeing a response?

  • Make sure you saved support_desk_tools.py after all three edits (imports, guardrail code, and main function).
  • Check that input_guardrails=[topic_guardrail] is inside the manager_agent definition, not outside it.
  • Verify your OPENAI_API_KEY is still exported in your terminal session.

Help me debug why my input guardrail is not working in the OpenAI Agents SDK.

Test the guardrail with an off-topic query

The legitimate query passed through. Now let's see what happens when someone sends a query that has nothing to do with customer support.

  • Type quit to exit the current session, then run the script again:
python support_desk_tools.py

Before you type anything, think about what you expect to happen. Will the manager agent try to answer a math question, or will something else intercept it?

  • At the You: prompt, type:
Help me solve 2x + 3 = 11

You should see this output:

Guardrail tripped: off-topic query blocked.

The guardrail agent classified the math question as off-topic, set is_off_topic to True, and the SDK raised InputGuardrailTripwireTriggered before the manager agent ever started. No specialist tokens were spent.

  • Type quit to exit.

Why use a separate model for the guardrail?

The guardrail agent runs on gpt-4o-mini at $0.15 per million input tokens. The main agents use the SDK's default model, which is more capable but costs more. Screening every input with a cheap model prevents expensive models from wasting tokens on junk queries.

The guardrail runs in parallel with the main agent by default. If the guardrail trips, the SDK halts the main agent run and raises the exception you caught in the except block.

✔️ Awesome, I've got everything!

Great. Double check you have saved support_desk_tools.py.

ⓧ I'd like to double check the full code

Your support_desk_tools.py should have these sections in order. Scroll through the file and confirm each is present:

  • Imports (including BaseModel, GuardrailFunctionOutput, InputGuardrailTripwireTriggered, and input_guardrail).
  • Function tools (lookup_invoice and check_system_status).
  • Specialist agents (billing_agent and technical_agent).
  • Input guardrail section: TopicCheckOutput class, guardrail_agent, and topic_guardrail function.
  • Manager agent with input_guardrails=[topic_guardrail].
  • main() function with the try/except block for InputGuardrailTripwireTriggered.

Your support desk now has a bouncer at the door. Any off-topic query gets stopped before the manager agent or its specialists spend a single token on it. Next up, you will enable tracing so you can see exactly what happens inside each run.

Enable and Inspect Tracing

Your input guardrail now catches off-topic queries before they waste tokens. The support desk routes, answers, and enforces policy. But with multiple agents, tool calls, and guardrails all firing in sequence, how do you debug a bad response when you can't see which component misbehaved?

The OpenAI Agents SDK records a trace for every run, logging each agent, tool call, and guardrail as a nested span. Your code already wraps each run in a named trace() context manager. In this step, you'll open the Traces dashboard and see exactly which components fired during each conversation.

In this step, get ready to:
  • Run the support desk with a legitimate query and inspect the trace in the OpenAI Traces dashboard.
  • Run an off-topic query and compare how the trace changes when the guardrail blocks execution.
Review the tracing already in your code

Tracing is enabled by default in the SDK. Every call to Runner.run() automatically records spans for agents, tool calls, guardrails, and LLM generations. Without a named trace() wrapper, all these spans show up under a generic name: Agent workflow.

  • Switch back to support_desk_tools.py in your code editor.
  • Find the main() function and locate this block:
        with trace("Support Desk"):
            try:
                result = await Runner.run(manager_agent, user_input)
                print(f"Support Manager: {result.final_output}")
            except InputGuardrailTripwireTriggered:
                print("Guardrail tripped: off-topic query blocked.")

What does this trace wrapper do?

The with trace("Support Desk"): line groups every span from a single run under the name Support Desk in the Traces dashboard. Without it, every run would appear as a generic Agent workflow entry, making it impossible to tell runs apart.

The SDK automatically nests spans inside this named trace. Each guardrail check becomes a guardrail span. Each agent run becomes an agent span. Each LLM call becomes a generation span. Each tool call becomes a function span. You don't create these manually.

What is group_id?

The trace() context manager also accepts an optional group_id parameter. In a production chat app, you'd set this to the conversation thread ID so that every message in the same conversation groups together in the dashboard. For this project, each run is independent, so you don't need it.

Run a legitimate query and inspect the Traces dashboard

Now that you know what the trace wrapper does, run the support desk and generate a real trace you can inspect.

  • Return to your terminal and run the agent-as-tool version:
python support_desk_tools.py
  • When you see the You: prompt, type a billing question:
I was charged twice on invoice INV-001, can you help?
  • Wait for the response from the Support Manager.
  • Type quit to exit the script.

The script exits and the SDK exports the trace in the background. Give it a few seconds before checking the dashboard.

  • Open your browser and navigate to:
https://platform.openai.com/traces
  • Make sure you're viewing the correct organization and project that matches the API key you exported.
  • Look for a trace named Support Desk in the list. Click on it to expand the trace view.

Inside the trace, you should see nested spans showing the full journey of your query.

What spans should I see?

For a legitimate billing query, the trace shows the full pipeline:

  • A guardrail span for the topic_guardrail check (with triggered: false).
  • An agent span for the Support Manager that orchestrated the response.
  • Generation spans for each LLM call the manager and specialist agents made.
  • Function spans for each tool call, such as billing_query and lookup_invoice.

Don't see your trace?

  • Check that you're signed into the correct OpenAI organization and project. The trace is sent to whatever project owns the API key you exported as OPENAI_API_KEY.
  • Traces can take a few seconds to appear. Refresh the page if the list is empty.
  • If you see traces named Agent workflow instead of Support Desk, those are from earlier runs before the named trace wrapper was in place.

Still stuck? Help me find my traces in the OpenAI Traces dashboard.

Run an off-topic query and compare the trace

You've seen what a successful query trace looks like. Now run the same script with an off-topic query and see how the trace differs when the guardrail blocks execution.

  • Run the script again:
python support_desk_tools.py

Before you type the off-topic query, think about this: the guardrail blocks execution before the manager agent runs. What do you think the trace will look like compared to the billing query trace?

  • At the You: prompt, type this off-topic message:
Help me solve 2x + 3 = 11
  • You should see Guardrail tripped: off-topic query blocked. printed in the terminal.
  • Type quit to exit the script.
  • Return to the Traces dashboard at platform.openai.com/traces and refresh the page.
  • Find the new Support Desk trace and click on it.

Compare this trace to the billing query trace. The guardrail span shows triggered: true this time. The agent span and function spans are absent because the guardrail blocked execution before the manager agent ever started.

Why does this matter in production?

In a production system, tracing lets you answer questions like: did the guardrail fire? Which specialist did the manager call? How long did the LLM generation take? When a customer reports a bad answer, you open the trace and see exactly which component failed.

The difference between the two traces you just compared proves the guardrail is working correctly. Legitimate queries produce full pipelines. Off-topic queries stop at the guardrail span.

Secret mission

Add an Output Guardrail

Your input guardrail catches bad requests before they reach the agents. But what about bad responses? A specialist could promise an unauthorized refund or leak internal system details. Your mission: add an output guardrail that screens every response before it reaches the customer.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with pay-per-use API calls to OpenAI. There are no ongoing costs once you stop running the scripts.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder on your Desktop containing support_desk.py, support_desk_tools.py, and a Python virtual environment.
  • OpenAI API usage for gpt-4o-mini calls (pay-per-use, no ongoing charges).
  • Traces stored in the OpenAI Traces dashboard.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with the agents further.

  • Your project files stay on your Desktop and you can run the scripts anytime.
  • The OPENAI_API_KEY environment variable is session-scoped. It clears automatically when you close your terminal. You will need to export it again in a new terminal session.
  • Your traces remain visible in the OpenAI Traces dashboard at platform.openai.com/traces.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files stay on disk so you can pick up where you left off.

  • If a script is still running in your terminal, press Ctrl+C to stop it.
  • If your virtual environment is active, deactivate it by running:
deactivate
  • Close your terminal. The OPENAI_API_KEY environment variable clears automatically when the session ends.
  • When you come back, reactivate the virtual environment and re-export your API key to resume.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your code, the virtual environment, and all installed packages.

  • If a script is still running, press Ctrl+C to stop it.
  • If your virtual environment is active, deactivate it by running:
deactivate
  • Navigate to your Desktop and delete the project folder by running these commands:

macOS

cd ~/Desktop
rm -rf support-desk

Windows

cd ~\Desktop
rmdir /s /q support-desk

Folder name may differ

Replace support-desk with whatever you named your project folder in Step 1.

  • Confirm the folder is gone by running:
ls

Your project folder should no longer appear in the output.

  • Close your terminal. The OPENAI_API_KEY environment variable clears automatically when the session ends.
  • To delete your traces, open platform.openai.com/traces in your browser. Select the traces from this project and delete them.

Nice Work!

Nice Work!

Nice work! You just built a multi-agent customer support desk that intelligently routes, answers, and safeguards customer queries using the OpenAI Agents SDK.

You've learned how to:

  • Built specialist agents with handoffs, where a triage agent reads customer queries and routes them to a billing or technical support specialist, each equipped with their own function tools to look up invoices and check system status.
  • Rebuilt the same system using the agent-as-tool pattern, where a manager agent stays in control, calls multiple specialists as tools, and synthesizes a unified response that addresses cross-domain questions.
  • Hardened the system with an input guardrail that blocks off-topic queries before they consume tokens, and enabled tracing to inspect every agent run, tool call, and guardrail check in the OpenAI Traces dashboard.
  • Secret Mission: Added an output guardrail that screens agent responses for policy violations before they reach the customer.

Ready to quiz yourself?