Skip to content

Example: customer-support agent

Status: Implemented — the flagship reference application for v0.1, runnable under examples/customer-support/ in the repository. Its test suite is the executable version of this document; pnpm --filter customer-support-example demo prints the end-to-end flow below.

One application, three clients of the same nine capabilities: a support dashboard (UI), an AI assistant in that dashboard (AI SDK), and an MCP endpoint for read-only external tooling. It exercises every v0.1 feature: authenticated context, org isolation, reads and writes, visibility, execution-time authorization, approval, audit, tracing, structured errors, cancellation, and deterministic tests.

Architecture

text
                       ┌─ Support dashboard UI ──── oRPC client ─┐
support agent (Dana) ──┤                                         │
                       └─ AI assistant (chat) ── AI SDK tools ───┤

external tools ──── MCP client ── @orpc-agent/mcp ──►  agent runtime (@orpc-agent/core)
                                                       policies · approvals · audit · otel
                                                                 │  oRPC call (middleware runs here)

                                              procedures ── services ── Postgres / payment gw / email
manager (Priya) ──── approvals dashboard ── runtime.approvals + runtime.resume

The UI calls procedures through its normal oRPC router and the assistant reaches the same procedures through the runtime — one implementation, two paths, identical middleware. (The UI could also route through surface: "direct" for a unified audit trail — shown as a variant in the example code.)

Capability inventory

CapabilitysideEffect / riskexposeGovernance
customers.searchread / mediumaiSdk, mcp, directSupport staff only (middleware support:read)
customers.getread / high (PII)aiSdk, mcp, directOutput redaction: mask email, drop payment methods
orders.listread / lowaiSdk, mcp, direct
orders.getread / mediumaiSdk, mcp, direct
orders.checkRefundEligibilityread / lowaiSdk, mcp, direct
orders.refundwrite / highaiSdk, direct (— not mcp)Middleware orders:refund; policy: > $500 ⇒ manager approval; ≥ $5000 ⇒ deny
messages.draftnone / lowaiSdk, directNo approval — creates a draft only
messages.sendexternal / highaiSdk, directStatic approval human-confirmation (inline) — a human clicks send, always
cases.escalatewrite / mediumaiSdk, directAudited like all writes; no approval

Not present at all: accounts.delete — account deletion is not modeled as a capability; the strongest exposure decision is nonexistence.

Definitions (representative)

ts
// src/capabilities/orders.ts
export const refundOrder = agentBase
  .use(requirePermission("orders:refund"))
  .use(requireSameOrg((input: { orderId: string }) => input.orderId))
  .meta({
    agent: {
      description: "Refund an order, fully or partially. Check orders.checkRefundEligibility first.",
      expose: { aiSdk: true, direct: true, test: true },
      sideEffect: "write",
      risk: "high",
      tags: ["orders", "money"],
      redact: {
        output: (o) => ({ ...(o as Refund), gatewayRef: undefined }),
        approvalInput: (i) => i as object,          // small input; show it all to approvers
      },
      policies: [refundLimit],
    },
  })
  .errors({ NOT_ELIGIBLE: { message: "Order is not eligible for refund." } })
  .input(z.strictObject({ orderId: z.string(), amount: z.number().positive().max(100_000), reason: z.string().min(4).max(500) }))
  .output(RefundResult)
  .handler(async ({ input, context, errors, signal }) => {
    const elig = await context.orders.checkEligibility(input.orderId, { signal });
    if (!elig.eligible || input.amount > elig.maxAmount) throw errors.NOT_ELIGIBLE();
    return context.payments.refund(input, { idempotencyKey: context.agent!.idempotencyKey, signal });
  });
ts
// src/capabilities/messages.ts — untrusted content labeled at the source
export const getCustomerThread = agentBase   // helper read used by drafts
  .meta({ agent: { description: "Load the customer's message thread.", expose: { aiSdk: true, direct: true, test: true }, sideEffect: "read", risk: "medium" } })
  .input(z.strictObject({ caseId: z.string() }))
  .output(z.object({ untrustedContent: z.array(z.object({ from: z.enum(["customer", "support"]), text: z.string() })) }))
  .handler(async ({ input, context }) => ({ untrustedContent: await context.cases.thread(input.caseId) }));
// The chat endpoint wraps untrustedContent in <customer> tags with a data-not-instructions notice.

export const sendMessage = agentBase
  .meta({ agent: {
    description: "Send a drafted message to the customer. Requires explicit confirmation.",
    expose: { aiSdk: true, direct: true, test: true },
    sideEffect: "external", risk: "high",
    approval: { required: true, type: "human-confirmation" },
  }})
  .input(z.strictObject({ draftId: z.string() }))
  .output(z.object({ messageId: z.string(), sentAt: z.date() }))
  .handler(async ({ input, context, signal }) => context.mailer.sendDraft(input.draftId, { signal }));
ts
// src/policies.ts
export const refundLimit = definePolicy("refund-limit", ({ capability, input }) => {
  if (capability.id !== "orders.refund") return allow();
  const { amount } = input as { amount: number };
  if (amount >= 5000) return deny("REFUND_TOO_LARGE", "Refunds of $5000 or more cannot be issued by agents.");
  if (amount > 500)   return requireApproval({ reason: `Refund of $${amount} exceeds $500`, approvalType: "manager" });
  return allow();
});

export const orgIsolation = definePolicy("org-isolation", ({ actor, context }) =>
  actor.attributes?.orgId === (context as AppContext).organizationId
    ? allow() : deny("ORG_MISMATCH", "Operation not available for this organization."));

export const mcpReadOnly = definePolicy("mcp-read-only", ({ surface, capability }) =>
  surface === "mcp" && !["read", "none"].includes(capability.meta.sideEffect)
    ? deny("MCP_READ_ONLY", "Write operations are not available over MCP.") : allow());

mcpReadOnly is belt-and-suspenders — writes aren't exposed to mcp anyway (SI-1); the policy guards against a future exposure mistake (defense in depth, cheap).

Wiring

ts
// src/runtime.ts
export const capabilities = createCapabilityRegistry({
  customers: { search: searchCustomers, get: getCustomer },
  orders: { list: listOrders, get: getOrder, checkRefundEligibility, refund: refundOrder },
  messages: { draft: draftMessage, send: sendMessage, getCustomerThread },
  cases: { escalate: escalateCase },
});

export const runtime = createAgentRuntime({
  registry: capabilities,
  policies: [orgIsolation, mcpReadOnly],
  approvals: {
    coordinator: createDbApprovalCoordinator(db),           // in-memory in dev
    handler: undefined,                                     // messages.send uses inline confirm in the chat UI variant
  },
  audit: { sinks: [dbAuditSink], strict: false },
  tracing: createOpenTelemetryTracing(),
});
ts
// src/api/chat.ts — the assistant endpoint
export async function POST(req: Request) {
  const session = await requireSession(req);
  const context = await createAppContext(session);
  const tools = await toAISDKTools(runtime, { actor: actorFrom(session), context });
  return streamText({ model, system: systemPrompt(session), messages: await req.json(), tools, stopWhen: stepCountIs(8) }).toUIMessageStreamResponse();
}

// src/api/mcp.ts — external, read-only in effect
export const mcp = createMCPServer(runtime, {
  serverInfo: { name: "acme-support", version: "0.1.0" },
  createContext: async (mcpSession) => {
    const principal = await verifyOAuth(mcpSession.authInfo);
    return { actor: { id: principal.sub, kind: "user", attributes: { orgId: principal.orgId } }, context: await createAppContextForUser(principal.sub) };
  },
});

The end-to-end flow (normative for the example)

Dana: "Customer alice@example.com says her order arrived damaged — refund it and let her know."

text
 1. model → customers_search {query:"alice@example.com"}     completed (read, low)
 2. model → orders_list {customerId:"c_alice"}               completed
 3. model → orders_checkRefundEligibility {orderId:"ord_42"} completed {eligible:true,maxAmount:649}
 4. model → orders_refund {orderId:"ord_42",amount:649,reason:"damaged item"}
      stage 7: refund-limit → require-approval (649 > 500)
      → { status:"approval-required", approvalId:"apr_9" }
      assistant: "Refund needs manager approval — request apr_9 pending."
 5. Priya (manager) approves apr_9 in the approvals dashboard   [capability.approved]
 6. dashboard worker → runtime.resume("apr_9", { context })
      integrity: hash ✓ · unconsumed ✓ · Priya ≠ Dana ✓ · consumed
      stage 11: middleware re-checks orders:refund + org  → handler refunds via gateway
      → completed { refundId:"ref_77", amount:649 }          [exe_02 linked to apr_9]
 7. app posts outcome into the chat; Dana: "tell her it's done"
 8. model → messages_draft {caseId, text…}                   completed (none/low, no approval)
 9. model → messages_send {draftId}
      static approval human-confirmation → inline handler → Dana clicks Send in the UI
      → completed { messageId }                              [external/high, human on the button]
10. audit trail: requested/approval_requested/approved/started/completed ×
    refund + draft + send · traces show agent.capability_call under the chat span

Failure branches (each covered by a test): a $9,000 amount → POLICY_DENIED at step 4; Priya rejects → APPROVAL_REJECTED at 6; 16 minutes idle → APPROVAL_EXPIRED; MCP client calls orders_refundCAPABILITY_NOT_FOUND (concealed, SI-8); payment gateway hangs → TIMEOUT, not retried (write, SI-11).

Tests shipped with the example

The example's suite is the executable version of this document — exposure snapshots per surface, the refund policy table (400/649/9000), the full approval lifecycle including self-approval and expiry, customers.get redaction, concealment over MCP, timeout with a signal-honoring stub, and the audit-sequence assertion for the flow above (guides/testing-capabilities.md shows every pattern).

What this example does not show

Durable approval resumption across restarts with a workflow engine (Deferred, ADR-007); streaming outputs; rate limiting (app-level, threat T5); multi-agent orchestration (out of scope entirely).

Independent community project — not affiliated with or endorsed by the oRPC maintainers.