Building a Production Multi-Agent Customer Support System with OpenAI and DNotifier

Part three of our OpenAI series. Parts one and two covered the integration basics and the orchestration tradeoff — this one is the actual build.
A single AI agent answering support questions is a good demo. It's also, in most real support queues, the wrong shape for the problem. Billing questions need access to invoice data. Technical questions need product docs and sometimes a diagnostic tool call. Anything involving a refund over a certain amount needs a human to sign off before it goes out. Trying to cram all of that into one system prompt and one model call is how you end up with an agent that's mediocre at everything instead of good at anything.
This post walks through building the thing properly: three specialized agents, each running an OpenAI model suited to its job, coordinated through a shared workflow, with a human approval step before anything sensitive goes out the door. Everything here uses DNotifier's actual defineAgent / Workflow primitives — this isn't pseudocode dressed up to look like a tutorial.
The shape of the system
Here's what we're building, end to end:
[ Customer message ]
│
▼
┌─────────────────────────┐
│ Router agent │
│ (gpt-4o-mini) │
└───────────┬─────────────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Billing │ │ Technical │ │ Escalation │
│ agent │ │ agent │ │ agent │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────┴────────┬───────┘
▼
Shared sessionId (one ticket)
Observability on every step
│
Escalation path ──► Human approval gate
One ticket, one shared session, three specialized agents, and a human who signs off before anything sensitive ships.
A customer message comes in. A lightweight router agent classifies it and reads whatever's already in session memory for this ticket. Based on that classification, it hands off to one of three specialists — billing, technical, or escalation — each running against a knowledge base scoped to their domain. The escalation path always ends at a human approval step before a response goes out. Every agent shares the same session, so nobody has to re-explain the problem to the next agent in line, and every handoff is visible in the observability dashboard.
Step 1: Define the agents
DNotifier's agent primitive is DNotifier.defineAgent({ name, run(ctx) }). The ctx object your run function receives gives you access to the shared workflow state, the input for this step, and sendAI() scoped to that agent's own model configuration.
import { DNotifier } from "@dnotifier-realtime/dnotifier";
const routerAgent = DNotifier.defineAgent({
name: "router-agent",
model: "gpt-4o-mini", // cheap, fast, this step doesn't need more
async run(ctx) {
const classification = await ctx.sendAI({
message: {
text: `Classify this support message into one category:
billing, technical, or escalation. Message: "${ctx.input.message}"
Respond with just the category word.`,
},
});
ctx.state.category = classification.text.trim().toLowerCase();
return ctx.state.category;
},
});
const billingAgent = DNotifier.defineAgent({
name: "billing-agent",
model: "gpt-4o",
async run(ctx) {
const answer = await ctx.sendAI({
message: { text: ctx.input.message },
// knowledge base scoped to billing docs/invoices is attached
// to this agent's app config — no separate retrieval code needed
});
ctx.state.draftResponse = answer.text;
return answer.text;
},
});
const technicalAgent = DNotifier.defineAgent({
name: "technical-agent",
model: "gpt-4o",
async run(ctx) {
const answer = await ctx.sendAI({
message: { text: ctx.input.message },
});
ctx.state.draftResponse = answer.text;
return answer.text;
},
});
const escalationAgent = DNotifier.defineAgent({
name: "escalation-agent",
model: "gpt-4o",
async run(ctx) {
const draft = await ctx.sendAI({
message: {
text: `Draft a careful, empathetic response to this escalated
issue, and flag it for human review before sending:
"${ctx.input.message}"`,
},
});
ctx.state.draftResponse = draft.text;
ctx.state.needsHumanApproval = true;
return draft.text;
},
});Notice the model field is set per agent, not once for the whole system. The router runs on gpt-4o-mini because classifying "my invoice looks wrong" versus "the app keeps crashing" doesn't need a flagship model — it needs to be fast and cheap, and it'll run on every single incoming message. The specialist agents run on gpt-4o because they're doing the actual reasoning work a customer will judge the quality of. This is the cost-tiering pattern we mentioned in part one of this series, applied for real.
Step 2: Wire them into a workflow
The workflow is what ties the agents together, holds shared state, and gives you the entry logic that decides where a ticket goes next.
const supportWorkflow = new DNotifier.Workflow({
name: "customer-support-pipeline",
description: "Routes and resolves inbound support tickets across three specialist agents",
observability: true,
async entry(ctx) {
const category = await ctx.agents.run(routerAgent, { message: ctx.input.message });
let result;
if (category === "billing") {
result = await ctx.agents.run(billingAgent, { message: ctx.input.message });
} else if (category === "technical") {
result = await ctx.agents.run(technicalAgent, { message: ctx.input.message });
} else {
result = await ctx.agents.run(escalationAgent, { message: ctx.input.message });
}
return {
category,
response: result,
needsHumanApproval: ctx.state.needsHumanApproval ?? false,
};
},
});
supportWorkflow.registerAgents([routerAgent, billingAgent, technicalAgent, escalationAgent]);Setting observability: true here is what makes every step of this — the router's classification, which specialist ran, what it returned — show up in the dashboard as it happens. When a customer says "your bot gave me a weird answer," you're not reconstructing what happened from a chat transcript. You open the run and see exactly which agent handled it, on which model, with which input.
Step 3: Run it against a real ticket
const notifier = new DNotifier({
appId: process.env.DNOTIFIER_APP_ID,
secret: process.env.DNOTIFIER_SECRET,
userId: "support-system",
transport: "ws",
WebSocketImpl: WebSocket,
});
await notifier.connect();
const outcome = await notifier.runWorkflow(supportWorkflow, {
message: "I was charged twice for my subscription this month.",
sessionId: "ticket-88213",
});
console.log(outcome);
// { category: 'billing', response: '...', needsHumanApproval: false }That ticket routes straight to the billing agent and resolves without a human in the loop, because it's a routine, well-understood category. Now watch what happens with a different message on the same session:
const followUp = await notifier.runWorkflow(supportWorkflow, {
message: "This is the third time this has happened and I want to cancel and get a full refund for the year.",
sessionId: "ticket-88213",
});
// { category: 'escalation', response: '...', needsHumanApproval: true }Because it's tied to the same sessionId, the escalation agent isn't starting cold — it has the prior turn's context about the duplicate charge already available through session memory, without your application code having to reassemble that history and pass it along manually.
Step 4: The human approval gate
needsHumanApproval coming back as true is your signal to hold the response rather than auto-send it. In practice, this is where you'd push the drafted response into whatever review surface your support team already uses — a Slack channel, an internal dashboard, a queue in your helpdesk tool — and only call your "send to customer" function once someone approves it.
if (outcome.needsHumanApproval) {
await notifyReviewQueue({
ticketId: "ticket-88213",
draft: outcome.response,
});
// response is held here until a human approves it
} else {
await sendToCustomer(outcome.response);
}This matters more than it might look like on the page. A system that can draft a refund confirmation and one that can send a refund confirmation without anyone checking it first are very different systems from a risk standpoint — and "which of our AI agents can take irreversible action without a human" is a question every team building this ends up needing a real answer to, not an assumed one.
Why three specialized agents beats one generalist agent here
We get pushback on this sometimes — isn't it simpler to just write one really good prompt? For a while, sure. It breaks down for a few concrete reasons once you're past the prototype:
The knowledge base gets muddier. A billing agent grounded only in invoice and pricing docs gives more precise answers than one generalist agent searching across billing docs, technical docs, and policy docs simultaneously, because the retrieval step has less noise to sort through.
Cost stops making sense. Running every single message — including "what's your refund policy," which needs almost no reasoning — through your most expensive model because it's also the model handling complex escalations is money spent for no quality gain on the easy 80%.
Debugging gets genuinely harder. When one enormous prompt handles everything and something goes wrong, you're debugging a single sprawling set of instructions. When three focused agents each do one job, the observability dashboard tells you exactly which one misfired.
The approval logic gets fuzzy. "Escalate to human if it's a refund over $500" is a clean rule for a dedicated escalation agent. It's a much easier rule to lose track of buried inside one long system prompt trying to also handle billing lookups and technical troubleshooting.
None of this is unique to support tickets, either — the same pattern (router → specialists → shared state → optional human gate) shows up in research pipelines, content review systems, and internal ops tooling. Support is just the clearest example to build first.
Frequently asked questions
Do all the agents have to use OpenAI, or can I mix providers?
Nothing here requires it — the model field on defineAgent is set per agent, so you could run the router on a cheap OpenAI model and the escalation agent on a different provider entirely if you had a reason to. This example keeps everything on OpenAI for simplicity and because GPT-4o's tool-calling and structured classification tend to be a strong, boring-in-a-good-way default for exactly this kind of routing.
What happens if the router misclassifies a ticket?
In practice, add a re-routing path: if the billing agent determines partway through that a ticket is actually technical, it can update ctx.state and hand off, rather than forcing every ticket down a single fixed path. We kept this example linear for clarity, but the workflow entry function can branch as many times as your actual support taxonomy needs.
How does this scale to high ticket volume?
The realtime, event-driven layer underneath this (the same pub/sub infrastructure covered in DNotifier's core platform docs) is built for exactly this — agents aren't polling a queue, they're addressed directly and messages route by ID. Volume scaling is a platform-level concern here, not something your workflow code needs to account for.
Can a customer's session span multiple tickets over time?
Yes, if you design your sessionId scheme that way — tie it to a customer ID instead of a single ticket ID, and their history persists across separate conversations, not just within one.
Is this pattern specific to support, or does it generalize?
It generalizes. Swap "billing/technical/escalation" for "research/writing/fact-checking" and you have a content pipeline. Swap it for "intake/diagnosis/scheduling" and you're closer to a healthcare triage assistant. The router-plus-specialists-plus-shared-state shape is the reusable part.
The Bottom Line
That's the full loop — from a single OpenAI-backed sendAI() call in part one, to weighing OpenAI's own orchestration tooling against a model-agnostic layer in part two, to an actual multi-agent system with a human still holding the final approval here.
Read part one: Building AI Agents on OpenAI with DNotifier. Read part two: OpenAI Agents API vs. DNotifier. For the full Workflow and defineAgent API, explore dnotifier.com.
DNotifier × OpenAI
Part 3 of 3
- Part 1Building AI Agents on OpenAI with DNotifier: A Practical Integration Guide
- Part 2OpenAI's Agents API vs. DNotifier: A Managed Harness or a Model-Agnostic Control Plane?
- Part 3Building a Production Multi-Agent Customer Support System with OpenAI and DNotifier
Related articles

OpenAI's Agents API vs. DNotifier: A Managed Harness or a Model-Agnostic Control Plane?
OpenAI's September 2026 Agents API is a serious option for OpenAI-only teams. This post lays out the real tradeoffs versus a model-agnostic control plane like DNotifier — without the spin.

Building AI Agents on OpenAI with DNotifier: A Practical Integration Guide
Part of a series on AI workflows across model providers — connect OpenAI through one sendAI() integration, with session memory, RAG, prompt testing, and observability handled for you.

Building Research Agents with Claude
Part four of the DNotifier × Claude series — a three-agent research pipeline (Sonnet, Opus, Fable) with shared workflow state and explicit fact-checking, built with defineAgent and Workflow.