AI-triaged inboxes (ai-triage)
Classify incoming requests against your own action catalogue. A small
helpdesk feature stores requests. One write-handler calls the enterprise
ai-triage feature through ctx.query, passes it the catalogue, and stores
the model’s category and suggested action for review. A human decides whether
to execute the action.
What it shows
Section titled “What it shows”- The catalogue is the contract,
ai-triageis domain-agnostic and only proposes action types from the list the caller supplies. DefiningHELPDESK_ACTIONSin this feature is the whole integration step; the test asserts the catalogue reaches the model. - Persist the decision, return the evidence, the request keeps the
category and the single top suggestion (what you query later); the full
ranked list rides back on the write response via
withResponseData(what you render once). - Handling the result union explicitly,
triageis persisted for review,clarifyhands the model’s question back as a structured422(request untouched),errorrejects without storing anything. - Per-tenant prompt tuning, no deploy, setting the
ai-triage:config:triage-system-promptconfig-key changes what reaches the LLM provider on the next call; the integration test asserts the override arrives verbatim.
When to reach for it
Section titled “When to reach for it”Any shared inbox where requests need routing before anyone acts, helpdesks,
operations queues, tenant communication. Pair it with the ai-draft recipe:
triage picks the lane, draft writes the reply, a human stays in the loop for
both.
Wiring
Section titled “Wiring”ai-triage requires ai-foundation (provider host), config, and
prompt-store; mount an LLM provider plugin (ai-provider-anthropic,
ai-provider-openai-compat, or a mock in tests) alongside. The integration
test under src/__tests__/ boots that exact stack against real Postgres +
Redis and scripts the provider, so every assertion above is proven, not
described.
bun --env-file=../.env test --config=bunfig.integration.toml samples/recipes/ai-triageSource code
Section titled “Source code”The feature entry point, embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// AI-Triage Sample// Shows: a helpdesk inbox that consumes the enterprise `ai-triage` feature// server-side through ctx.query. The action catalogue is owned by THIS// feature — ai-triage stays domain-agnostic and only proposes actions from// the list it is handed. The triage decision is stored for review; executing// an action stays a human decision.
import { createEntity, createEntityExecutor, createLongTextField, createTextField, defineEntityCreateHandler, defineEntityDetailHandler, defineEntityListHandler, defineFeature, withResponseData,} from "@cosmicdrift/kumiko-framework/engine";import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";import { type AvailableAction, aiTriageFeature, TRIAGE_HANDLER_QN, type TriageMessageResult,} from "@cosmicdriftgamestudio/kumiko-ai-triage";import { z } from "zod";
// The catalogue is the integration contract: ai-triage never invents action// types, so adding a capability to this list is the whole wiring step.export const HELPDESK_ACTIONS: readonly AvailableAction[] = [ { type: "reset-password", description: "Send a self-service password-reset link." }, { type: "create-incident", description: "Open an incident for the on-call engineer." }, { type: "escalate-network-team", description: "Hand the request to the network team queue." }, { type: "close-as-duplicate", description: "Close it; an open request already covers this." },];
export const requestEntity = createEntity({ table: "read_sample_helpdesk_requests", fields: { subject: createTextField({ required: true }), from: createTextField({ required: true }), body: createTextField({ required: true }), // Filled by triage-request: the category plus the single best suggestion. // The full ranked list goes back in the write response only — persist // what you query later, return what you render now. triagedCategory: createTextField(), suggestedAction: createTextField(), suggestedActionRationale: createLongTextField({ maxLength: 10_000 }), },});
// ai-triage's query is gated to TenantAdmin/SystemAdmin, and ctx.query runs// as the CURRENT user — so every handler that bridges into it carries the// same gate instead of silently escalating via queryAs.const adminOnly = { access: { roles: ["TenantAdmin", "SystemAdmin"] } } as const;
export const helpdeskInboxFeature = defineFeature("helpdesk-inbox", (r) => { r.requires(aiTriageFeature.name); r.entity("helpdesk-request", requestEntity);
const { executor } = createEntityExecutor("helpdesk-request", requestEntity);
r.writeHandler(defineEntityCreateHandler("helpdesk-request", requestEntity, adminOnly)); r.queryHandler(defineEntityListHandler("helpdesk-request", requestEntity, adminOnly)); r.queryHandler(defineEntityDetailHandler("helpdesk-request", requestEntity, adminOnly));
// The point of this sample: classify a request against OUR catalogue and // store the model's pick for review. r.writeHandler( "triage-request", z.object({ id: z.uuid() }), async (event, ctx) => { const request = await executor.detail({ id: event.payload.id }, event.user, ctx.db); if (!request) { return failNotFound("helpdesk-request", event.payload.id); }
// Cross-feature bridge: runs ai-triage's queryHandler as the current // user, sharing the active transaction — same pattern as the ai-draft // recipe. const result = (await ctx.query(TRIAGE_HANDLER_QN, { message: { subject: request["subject"] as string, from: request["from"] as string, body: request["body"] as string, }, availableActions: HELPDESK_ACTIONS, })) as TriageMessageResult; // boundary: ctx.query erases the target handler's return type
switch (result.type) { case "triage": { // The model may return zero suggestions (e.g. spam → category only); // keep the highest-confidence one when there is any. const top = [...result.proposedActions].sort((a, b) => b.confidence - a.confidence)[0]; const updated = await executor.update( { id: event.payload.id, version: request["version"] as number, changes: { triagedCategory: result.category, ...(top && { suggestedAction: top.actionType, suggestedActionRationale: top.rationale, }), }, }, event.user, ctx.db, ); // Response-only payload: callers render the ranked list once, // nobody queries it later. return withResponseData(updated, { id: event.payload.id, proposedActions: result.proposedActions, }); } case "clarify": // The model needs input it cannot assume — hand the question back // to the caller; the request is left untouched. return failUnprocessable("triage needs clarification", { question: result.question, causeNote: result.reason, }); case "error": return failUnprocessable("triage failed", { message: result.message }); } }, adminOnly, );});Enterprise recipe, samples/recipes/ai-triage/src/feature.ts in the private kumiko-enterprise workspace.