stratus
Guides

Customer Support Agent

Build a multi-agent customer support system with triage, handoffs, and hooks

Route customer requests to specialized agents with tools, audit every handoff with hooks, and enforce guardrails on input. This guide builds a production-ready triage system from scratch.

Quick start

Here is a minimal working example. A triage agent routes to an order specialist and a refund specialist:

quick-start.ts
import { Agent, run } from "stratus-sdk/core";
import { AzureResponsesModel } from "stratus-sdk";

const model = new AzureResponsesModel({ deployment: "gpt-5.2" });

const orderAgent = new Agent({
  name: "order_specialist",
  model,
  instructions: "You help customers with order status and tracking.",
  handoffDescription: "Transfer here for order questions", 
});

const refundAgent = new Agent({
  name: "refund_specialist",
  model,
  instructions: "You help customers with refunds. Always check eligibility first.",
  handoffDescription: "Transfer here for refund requests", 
});

const triageAgent = new Agent({
  name: "triage",
  model,
  instructions: `You are a customer support triage agent. Greet the customer,
    understand their issue, and transfer to the right specialist.
    - Order questions -> order_specialist
    - Refund requests -> refund_specialist`,
  handoffs: [orderAgent, refundAgent], 
});

const result = await run(triageAgent, "I need to return order ORD-12345");
console.log(result.output);
console.log(`Handled by: ${result.lastAgent.name}`);

The rest of this guide adds tools, hooks, guardrails, and session support on top of this foundation.

Architecture

User -> Triage Agent -> Order Agent (tools: lookupOrder, trackShipment)
                     -> Refund Agent (tools: processRefund, checkEligibility)

Define your tools

Order lookup tool

Give the order specialist a tool to fetch order details from your database:

tools.ts
import { tool } from "stratus-sdk/core";
import { z } from "zod";

const lookupOrder = tool({
  name: "lookup_order",
  description: "Look up an order by ID and return its details",
  parameters: z.object({
    orderId: z.string().describe("The order ID, e.g. ORD-12345"),
  }),
  execute: async (ctx: AppContext, { orderId }) => {
    const order = await ctx.db.orders.findById(orderId);
    if (!order) return `Order ${orderId} not found`;
    return JSON.stringify({
      id: order.id,
      status: order.status,
      items: order.items,
      total: order.total,
    });
  },
});

Refund eligibility tool

Check whether an order falls within the refund window before processing:

tools.ts
const checkEligibility = tool({
  name: "check_refund_eligibility",
  description: "Check if an order is eligible for a refund",
  parameters: z.object({
    orderId: z.string(),
  }),
  execute: async (ctx: AppContext, { orderId }) => {
    const order = await ctx.db.orders.findById(orderId);
    if (!order) return "Order not found";
    const daysSincePurchase = daysBetween(order.createdAt, new Date());
    const eligible = daysSincePurchase <= 30 && order.status !== "refunded";
    return JSON.stringify({
      eligible,
      daysSincePurchase,
      reason: eligible ? null : "Past 30-day window or already refunded",
    });
  },
});

Create specialist agents

Each specialist gets its own tools and a handoffDescription that tells the triage agent when to route to it:

agents.ts
import { Agent } from "stratus-sdk/core";

const orderAgent = new Agent<AppContext>({
  name: "order_specialist",
  model,
  instructions: `You are an order specialist. Help customers with order lookups,
    status updates, and tracking. Be concise and professional.`,
  tools: [lookupOrder, trackShipment],
  handoffDescription: "Transfer here for order status, tracking, and delivery questions",
});

const refundAgent = new Agent<AppContext>({
  name: "refund_specialist",
  model,
  instructions: `You are a refund specialist. Check eligibility before processing.
    Always confirm the refund amount with the customer before proceeding.`,
  tools: [checkEligibility, processRefund],
  handoffDescription: "Transfer here for refund requests and return processing",
});

The handoffDescription is injected into the triage agent's tool definitions. Write it from the triage agent's perspective -- describe when to transfer, not what the specialist does internally.

Create the triage agent with hooks

Hooks let you observe and control the agent lifecycle. Here, beforeHandoff logs every transfer to an audit table and afterRun records the resolution:

triage.ts
import { Agent, run } from "stratus-sdk/core";
import type { ToolCallDecision } from "stratus-sdk/core";

const triageAgent = new Agent<AppContext>({
  name: "triage",
  model,
  instructions: `You are a customer support triage agent. Greet the customer,
    understand their issue, and transfer them to the right specialist.
    - Order questions -> order_specialist
    - Refund requests -> refund_specialist
    If unclear, ask a clarifying question.`,
  handoffs: [orderAgent, refundAgent],
  hooks: {
    beforeRun: async ({ input }) => {
      console.log(`[SUPPORT] New ticket: ${input.slice(0, 100)}`);
    },
    beforeHandoff: async ({ fromAgent, toAgent, context }) => { 
      await context.db.auditLog.create({ 
        event: "handoff", 
        from: fromAgent.name, 
        to: toAgent.name, 
        timestamp: new Date(), 
      }); 
    },
    afterRun: async ({ result, context }) => {
      await context.db.auditLog.create({
        event: "resolved",
        output: result.output.slice(0, 200),
        agent: result.lastAgent.name,
      });
    },
  },
});

Hooks run inline in the agent loop. Keep them fast -- offload heavy work (analytics, notifications) to a background queue rather than awaiting it directly.

Add input guardrails

Guardrails run in parallel with the first model call and trip a wire if the input is problematic. Add a toxicity check to reject abusive messages before they reach any agent:

guardrails.ts
import type { InputGuardrail } from "stratus-sdk/core";

const toxicityGuard: InputGuardrail<AppContext> = {
  name: "toxicity_check",
  execute: (input) => ({
    tripwireTriggered: containsToxicLanguage(input),
    outputInfo: { reason: "Toxic language detected" },
  }),
};

const triageAgent = new Agent<AppContext>({
  // ...same config as above
  inputGuardrails: [toxicityGuard], 
});

Input guardrails only run on the entry agent. After a handoff, the specialist agent's own guardrails (if any) take over.

Run as a session

Wrap everything in a session for multi-turn conversations. The session maintains message history and context across turns:

main.ts
import { createSession } from "stratus-sdk/core";

const session = createSession<AppContext>({
  model,
  instructions: triageAgent.instructions!,
  handoffs: [orderAgent, refundAgent],
  hooks: triageAgent.hooks,
  inputGuardrails: [toxicityGuard],
  context: {
    db: database,
    userId: "user_abc",
  },
});

// Customer conversation
session.send("Hi, I need to return order ORD-12345");
for await (const event of session.stream()) {
  if (event.type === "content_delta") process.stdout.write(event.content);
}

const result = await session.result;
console.log(`\nHandled by: ${result.lastAgent.name}`);

Advanced patterns

Permission control for high-value actions

Use beforeToolCall hook decisions to require approval for high-value refunds. Return "deny" with a reason and the model receives the denial as a tool result:

permission-hooks.ts
hooks: {
  beforeToolCall: async ({ toolCall, context }) => {
    if (toolCall.function.name === "process_refund") {
      const params = JSON.parse(toolCall.function.arguments);
      if (params.amount > 500) { 
        return { 
          decision: "deny", 
          reason: "Refunds over $500 require manager approval. Please escalate.", 
        }; 
      }
    }
  },
}

Hook decisions support three modes: "allow" (default), "deny" (block with reason), and "modify" (rewrite the tool call arguments). See the Hooks reference for full details.

Save and resume conversations

Persist support conversations across server restarts or shift changes with save() and resumeSession():

persistence.ts
// Save at end of shift
const snapshot = session.save();
await redis.set(`support:${snapshot.id}`, JSON.stringify(snapshot));

// Resume next shift
const saved = JSON.parse(await redis.get(`support:${sessionId}`));
const resumed = resumeSession(saved, { model, ...config });
resumed.send("I'm a different agent, picking up where my colleague left off.");

Session snapshots include the full message history. For long conversations, consider trimming older messages before saving to stay within token limits.

Next steps

Edit on GitHub

Last updated on

On this page