Support Triage Agent
Classifies inbound support tickets by urgency and product area, writes the classification back to the ticket store, and pages a human for anything it marks P1.
Repository: github.com/velerion/example-support-triage
(TODO(velerion): confirm the repository name before publishing.)
Run it
Section titled “Run it”git clone https://github.com/velerion/example-support-triagecd example-support-triagenpm installcp .env.example .env.local # fill in DATABASE_URL and PAGERDUTY_TOKEN
velerion env use devvelerion connection create tickets-rw --type postgres --env dev --secret DATABASE_URLvelerion mcp install velerion/postgres@2.4.1 --env dev --connection tickets-rw
npm run seed # 200 synthetic ticketsvelerion dev --agent support-triageWhat it demonstrates
Section titled “What it demonstrates”- A custom tool with a Zod-validated input schema and a real side effect.
- Structured output enforced by a schema rather than parsed from prose.
- A deliberate human-in-the-loop boundary: the agent may propose a P1 but a person confirms it.
- An evaluation suite that runs in CI against a fixed ticket set.
Structured output
Section titled “Structured output”import { defineAgent, mcp, tool, output } from '@velerion/sdk';import { z } from 'zod';
const Triage = z.object({ urgency: z.enum(['P1', 'P2', 'P3']), productArea: z.enum(['billing', 'auth', 'ingest', 'ui', 'other']), // Forcing an evidence list makes low-quality classifications visible in // review instead of hiding behind a confident-sounding summary. evidence: z.array(z.string()).min(1).max(4), needsHuman: z.boolean(),});
export default defineAgent({ name: 'support-triage', model: 'claude-opus-5', output: output(Triage), instructions: ` Classify the ticket. Quote the exact ticket fields that drove your decision in "evidence". Set needsHuman when the ticket is ambiguous, mentions data loss, or names a customer on an enterprise plan. `, tools: [ mcp('velerion/postgres', { connection: 'tickets-rw', tools: ['query'] }), tool({ name: 'record_triage', description: 'Persist the triage result against the ticket.', input: z.object({ ticketId: z.string(), triage: Triage }), async run({ ticketId, triage }, ctx) { await db.tickets.update(ticketId, triage); ctx.logger.info('recorded', { ticketId, urgency: triage.urgency }); return { ok: true }; }, }), ],});The escalation boundary
Section titled “The escalation boundary”const run = await velerion.agents.run('support-triage', { input: { ticket } });
if (run.output.urgency === 'P1' || run.output.needsHuman) { // The agent never pages directly. It raises a candidate; on-call confirms. await queue.push('p1-review', { ticket, triage: run.output, runId: run.id });} else { await db.tickets.update(ticket.id, run.output);}Keeping the page behind a human confirmation is the whole point of the example. An agent with a direct paging tool will eventually wake somebody at 03:00 for a duplicate ticket.
Evaluation
Section titled “Evaluation”npm run eval support-triage eval/tickets.jsonl (200 cases) urgency exact match 91.5% (target 85%) product area exact match 88.0% (target 85%) P1 recall 97.1% (target 95%) P1 precision 74.3% (target 60%) evidence non-empty 100.0% (target 100%)P1 recall is weighted far above precision: a missed P1 is an outage, a false P1 is a minute of someone’s attention. Set your own targets deliberately rather than optimising a single accuracy number.
