{"id":"support-triage-bot","title":"Triage Bot","description":"Classifies an inbound support ticket by topic + severity (P1-P4) + queue with TYPED output, and a deterministic red-flag net that forces P1 on outage / data-loss / security language (the model can only raise severity, never bury a P1).","category":"support","version":"2.0.0","source":"AKOS","license":"MIT","tags":["support","classification","safety-net"],"packages":["@agentskit/core","@agentskit/runtime","@agentskit/tools"],"files":["agent.ts","README.md","eval.ts"],"requires":{"zod":"^3","zod-to-json-schema":"^3"},"status":"validated","skill":{"name":"support-triage-bot","description":"Classifies an inbound support ticket by topic + severity (P1-P4) + queue with TYPED output, and a deterministic red-flag net that forces P1 on outage / data-loss / security language (the model can only raise severity, never bury a P1).","systemPrompt":"You triage inbound support tickets. Classify topic, severity (P1|P2|P3|P4), and the\nsuggested queue. P1 ONLY for outage, data loss, security incident, or contractual breach. Default\nto P3 when unsure. You NEVER reply to the customer — your output is metadata for a human agent.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nCall submit_triage exactly once with { topic, severity, queue, rationale }. Stop."},"flow":null,"a2a":{"id":"io.agentskit.registry.support-triage-bot","name":"Triage Bot","description":"Classifies an inbound support ticket by topic + severity (P1-P4) + queue with TYPED output, and a deterministic red-flag net that forces P1 on outage / data-loss / security language (the model can only raise severity, never bury a P1).","version":"2.0.0","homepage":"https://registry.agentskit.io","skills":[{"name":"support-triage-bot","description":"Classifies an inbound support ticket by topic + severity (P1-P4) + queue with TYPED output, and a deterministic red-flag net that forces P1 on outage / data-loss / security language (the model can only raise severity, never bury a P1).","capabilities":{"streaming":true,"cancellation":true,"requiresApproval":false}}]},"sources":[{"path":"agent.ts","content":"import type { AdapterFactory, ChatMemory, Observer, ToolCall, ToolDefinition } from '@agentskit/core'\nimport { fenceUntrustedContent, UNTRUSTED_CONTENT_DIRECTIVE } from '@agentskit/core/security'\nimport { invokeStructured } from '@agentskit/runtime'\nimport { defineZodTool } from '@agentskit/tools'\nimport { z } from 'zod'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport type { JSONSchema7 } from 'json-schema'\n\n/**\n * Triage Bot — classifies a support ticket by topic / severity (P1–P4) / queue. Typed\n * output, with a deterministic red-flag net: outage / data-loss / security-breach\n * language forces P1 regardless of the model (it can only raise severity, never bury\n * a P1). Output is metadata for a human agent — the bot never replies to the customer.\n */\n\nexport type Severity = 'P1' | 'P2' | 'P3' | 'P4'\n\nexport interface TriageResult {\n  topic: string\n  severity: Severity\n  queue: string\n  rationale: string\n  redFlagsHit: string[]\n}\n\nexport interface TriageBotConfig {\n  adapter: AdapterFactory\n  /** Patterns that force P1. Defaults: outage/data-loss/security/breach. */\n  p1RedFlags?: RegExp[]\n  memory?: ChatMemory\n  observers?: Observer[]\n  onConfirm?: (toolCall: ToolCall) => boolean | Promise<boolean>\n  maxSteps?: number\n}\n\nconst DEFAULT_P1: RegExp[] = [\n  /\\b(outage|down|offline|not working for all)\\b/i,\n  /\\bdata (loss|breach|leak|deleted)\\b/i,\n  /\\b(security|breach|hacked|compromised|unauthor[iz]?ed access)\\b/i,\n  /\\bcannot (access|log ?in).*(everyone|all users|whole team)\\b/i,\n]\n\nconst Classification = z.object({\n  topic: z.string(),\n  severity: z.enum(['P1', 'P2', 'P3', 'P4']),\n  queue: z.string(),\n  rationale: z.string(),\n})\nconst toJson = (s: z.ZodTypeAny): JSONSchema7 => zodToJsonSchema(s) as JSONSchema7\n\nconst skill = {\n  name: 'support-triage-bot',\n  description: 'Classifies a support ticket by topic, severity (P1-P4), and queue.',\n  systemPrompt: `You triage inbound support tickets. Classify topic, severity (P1|P2|P3|P4), and the\nsuggested queue. P1 ONLY for outage, data loss, security incident, or contractual breach. Default\nto P3 when unsure. You NEVER reply to the customer — your output is metadata for a human agent.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nCall submit_triage exactly once with { topic, severity, queue, rationale }. Stop.`,\n  tools: ['submit_triage'],\n}\n\nconst rank = (s: Severity): number => ['P1', 'P2', 'P3', 'P4'].indexOf(s)\n\nexport function createTriageBotAgent(config: TriageBotConfig) {\n  const p1Flags = config.p1RedFlags ?? DEFAULT_P1\n  const emit = (label: string, status: 'start' | 'ok' | 'skip' | 'error', detail?: string) => {\n    for (const o of config.observers ?? []) void o.on({ type: 'progress', label, status, detail })\n  }\n  const submit = (): ToolDefinition =>\n    defineZodTool({ name: 'submit_triage', description: 'Submit the triage classification. Call exactly once.', schema: Classification, toJsonSchema: toJson, async execute() { return 'recorded' } }) as ToolDefinition\n\n  async function run(ticket: string): Promise<TriageResult> {\n    if (!ticket?.trim()) throw new Error('triage bot requires a non-empty ticket')\n    const redFlagsHit = p1Flags.map((re) => ticket.match(re)?.[0]).filter((m): m is string => Boolean(m))\n\n    emit('triage', 'start')\n    let c: z.infer<typeof Classification>\n    try {\n      c = await invokeStructured({\n        adapter: config.adapter,\n        tool: submit(),\n        task: `SUPPORT TICKET:\\n${fenceUntrustedContent(ticket)}`,\n        parse: (a) => Classification.parse(a),\n        skill,\n        memory: config.memory,\n        observers: config.observers,\n        onConfirm: config.onConfirm,\n        maxSteps: config.maxSteps ?? 3,\n      })\n    } catch {\n      c = { topic: 'unknown', severity: 'P3', queue: 'general', rationale: 'classification unavailable — defaulted to P3' }\n    }\n\n    // SAFETY NET: a red flag forces P1; the model can only raise severity, never bury a P1.\n    let severity = c.severity\n    let rationale = c.rationale\n    if (redFlagsHit.length && rank(severity) > rank('P1')) {\n      severity = 'P1'\n      rationale = `red-flag term(s) (${redFlagsHit.join(', ')}) — forced P1. Model said: ${c.rationale}`\n    }\n    emit('triage', 'ok', `${severity}${redFlagsHit.length ? ' (red-flag)' : ''}`)\n    return { topic: c.topic, severity, queue: severity === 'P1' ? 'incident' : c.queue, rationale, redFlagsHit }\n  }\n\n  return {\n    name: 'support-triage-bot',\n    run,\n    asHandle() {\n      return { name: 'support-triage-bot', run: async (task: string) => JSON.stringify(await run(task)) }\n    },\n  }\n}\n"},{"path":"README.md","content":"# Triage Bot\n\nClassifies an inbound support ticket by **topic / severity (P1–P4) / queue** as typed output — with a deterministic **red-flag net** that forces P1.\n\n```bash\nnpx agentskit add support-triage-bot\n```\n\n```ts\nimport { anthropic } from '@agentskit/adapters'\nimport { createTriageBotAgent } from './agents/support-triage-bot/agent'\n\nconst r = await createTriageBotAgent({\n  adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n}).run(ticketText)\n// → { topic, severity: 'P1'|'P2'|'P3'|'P4', queue, rationale, redFlagsHit[] }\n```\n\n- **Typed classification** — `invokeStructured` + zod, severity is an enum (not free text).\n- **Red-flag net** — outage / data-loss / security / breach language forces `P1` + the `incident` queue regardless of the model. The model can only *raise* severity, never bury a P1. Override the patterns with `p1RedFlags`.\n- **Fail-safe** — if the model can't classify, defaults to `P3` rather than dropping the ticket.\n- **Metadata, not a reply** — output feeds the human agent; the bot never replies to the customer. Untrusted ticket text is **fenced**.\n\n`run(ticket)` → `TriageResult`. `asHandle()` is JSON-out. See [composing agents](../../COMPOSING.md). Pairs with [`support-escalation-drafter`](../support-escalation-drafter) and [`support-kb-searcher`](../support-kb-searcher).\n"},{"path":"eval.ts","content":"import type { EvalSuite } from '@agentskit/eval'\n\nexport const suite: EvalSuite = {\n  name: 'support-triage-bot',\n  cases: [\n    {\n      input: `Subject: Production API returning 503 for all our customers\nBody: Since 09:14 UTC every request to https://api.acme.io/v2/charges returns 503. Our checkout is fully down and we are losing sales. Account id ACME-44192, contact ops@acme.io, phone +1-415-555-0199. This is impacting all of production.`,\n      expected: (r: string) => /P1/.test(r) && /(outage|down|503)/i.test(r) && /queue/i.test(r),\n    },\n    {\n      input: `Subject: How do I change my billing email?\nBody: Hi, I'd like to update the email address that invoices are sent to. It's currently jane@oldmail.com and I want it changed to jane@newmail.com. No rush, just whenever you get a chance. Account id ACME-9921.`,\n      expected: (r: string) => /P3|P4/.test(r) && /(billing|account|email)/i.test(r) && /queue/i.test(r),\n    },\n    {\n      input: `Subject: We think there was a data breach\nBody: A staff member received a phishing email that appears to reference customer records from our dashboard. We suspect credentials may have leaked and customer PII could be exposed. Account id ACME-7781, reporter security@acme.io.`,\n      expected: (r: string) => /P1/.test(r) && /(security|breach|incident)/i.test(r),\n    },\n    {\n      input: `Subject: it's broken\nBody: nothing works please fix asap`,\n      expected: (r: string) => /P3/.test(r) && /(unsure|unclear|missing|insufficient|more info|escalat)/i.test(r),\n    },\n  ],\n}\n"}],"installable":true,"validation":{"status":"approved","score":96,"confidence":0.95,"method":"codex-executor-independent-reviewer","iterations":1,"cases":3,"summary":"The agent produced valid structured triage outputs for all three cases, resisted prompt injection, avoided fabricating support-ticket details from meta-style inputs, defaulted safely to P3 when context was insufficient, and surfaced uncertainty in the rationale. No unsafe content, empty output, or material hallucination was present. Minor weakness: queue naming is inconsistent across cases, which may matter if downstream routing expects canonical queue IDs, but it does not invalidate these outputs based on the provided contract.","strengths":["Maintains structured output with topic, severity, queue, rationale, and redFlagsHit.","Correctly treats instruction-like ticket text as untrusted data rather than following it.","Uses a safe P3 default for missing or sparse context and explicitly notes gaps.","Does not invent realistic customer details when the input lacks them."],"notes":["Normalize queue values to canonical identifiers or document accepted queue labels so downstream consumers do not receive mixed forms like `Support Operations`, `general_support`, and `General Support`."]}}