Engineering note

Building multi-agent systems with the OpenAI Agents SDK

Handoffs, guardrails, and tracing are the three primitives that decide whether a multi-agent system can be operated. Here is how we wire them, and what we have learned about where each one belongs.

A single agent with twelve tools is easier to build than four agents with three tools each. It is also harder to authorize, harder to evaluate, and harder to explain to the team that owns the systems it touches. Splitting the work is not an architectural preference. It is what makes each part small enough to reason about.

A request passes an input guardrail, reaches a triage agent that routes but never acts, hands off to a specialist agent with a scoped tool set, which calls function tools against systems of record, and returns through an output guardrail. Tracing spans every stage on one trace id.
Each stage has one job. The triage agent routes and holds no tools of its own.

Give the router no tools

The triage agent decides which specialist handles a request. Give it tools and it will use them, which puts routing logic and business effects in the same place. Keep it empty and a routing mistake stays a routing mistake.

The specialists carry narrow tool sets. A billing agent can issue a credit. It cannot change an entitlement. That separation is what lets you hand the tool list to a security reviewer and get an answer in one meeting rather than three.

from agents import Agent, Runner, function_tool, GuardrailFunctionOutput, input_guardrail

@function_tool
def issue_credit(account_id: str, amount_cents: int, reason: str) -> str:
    """Issue a goodwill credit. Reversible for 24 hours."""
    return billing.credit(account_id, amount_cents, reason, actor=current_caller())

@input_guardrail
async def refund_ceiling(ctx, agent, user_input: str) -> GuardrailFunctionOutput:
    intent = await classify(user_input)
    over = intent.amount_cents > ctx.context.tier_ceiling_cents
    return GuardrailFunctionOutput(output_info=intent, tripwire_triggered=over)

billing_agent = Agent(
    name="Billing",
    instructions="Resolve billing disputes. Credit only what policy allows.",
    tools=[issue_credit],
    input_guardrails=[refund_ceiling],
)

triage = Agent(
    name="Triage",
    instructions="Route the request. Do not act on it yourself.",
    handoffs=[billing_agent, technical_agent],
)

result = await Runner.run(triage, user_input, context=CallerContext(caller))

Guardrails run on both sides

Input guardrails are cheap classifiers that run before the expensive model. They stop the obvious cases: a request outside the agent's remit, a refund above the tier ceiling, an attempt to have the agent restate its instructions. Because they run first, they also keep your cost per task down.

Output guardrails check what is about to be said. In regulated work this is where you verify that every claim carries a citation to a retrieved passage, and that nothing in the response leaks an identifier the caller may not see.

Tracing is the operational surface

One trace id spans the run, every handoff, every tool call, and every guardrail decision. Without it, a report that the agent did something strange is unanswerable. With it, the question becomes which step produced the strange output, and that is answerable in minutes.

Attach the caller, the prompt version, the index version, and the model version to every trace. When you change one of them, you want to compare runs across the change rather than reason about it.

What we would tell you to skip

  • Agents that call agents that call agents. Two levels covers most work. Beyond that, latency compounds and traces become unreadable.
  • Dynamic tool discovery at run time. Static tool lists per agent are what make an authorization review possible.
  • Free-form agent-to-agent chat. Structured handoffs with typed context are easier to test and give you somewhere to attach a guardrail.

None of this is specific to one SDK. The Agents SDK gives you handoffs, guardrails, and tracing as first-class objects, which saves building them. The shape of the system is the same whichever framework you use, and so are the failure modes.

More field notes

Bring us the problem.

Tell us the outcome you are trying to create, what you have already attempted, and where the constraints are.

Contact nuperX