Skip to content

AI-triaged inboxes (ai-triage)

Classify incoming requests against your own action catalogue. A small helpdesk feature stores requests; one write-handler bridges into the enterprise ai-triage feature via ctx.query, hands it the catalogue this feature owns, and stores the model’s category plus its best action suggestion for review — executing an action stays a human decision.

  • The catalogue is the contractai-triage is domain-agnostic and only proposes action types from the list the caller supplies. Defining HELPDESK_ACTIONS in 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 explicitlytriage is persisted for review, clarify hands the model’s question back as a structured 422 (request untouched), error rejects without storing anything.
  • Per-tenant prompt tuning, no deploy — setting the ai-triage:config:triage-system-prompt config-key changes what reaches the LLM provider on the next call; the integration test asserts the override arrives verbatim.

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.

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.

Terminal window
bun --env-file=../.env test --config=bunfig.integration.toml samples/recipes/ai-triage

The complete feature — embedded straight from the source file, so the code here is exactly what runs:

// 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,
reason: result.reason,
});
case "error":
return failUnprocessable("triage failed", { reason: result.reason });
}
},
adminOnly,
);
});

Enterprise recipe — samples/recipes/ai-triage/src/feature.ts in the private kumiko-enterprise workspace.