Skip to content

AI-drafted replies (ai-draft)

Turn an incoming message into a reply draft for review. A small support-inbox feature stores tickets. One write-handler calls the enterprise ai-draft feature through ctx.query, handles the full result union (draft / clarify / error), and stores the draft on the ticket. A human then reviews and sends it.

  • Server-side consumption via ctx.query, the cross-feature bridge runs ai-draft:query:draft as the current user, inside the same transaction. Trade-off: the LLM call can take seconds and holds that DB connection for its whole duration, fine for an admin-triggered action, wrong for bulk loops (dispatch a job per ticket instead). Feature boundaries stay at the handler contract; the sample never imports ai-draft internals.
  • Handling the result union explicitly, draft is persisted for review, clarify hands the model’s question back to the caller as a structured 422 (ticket untouched), error rejects without storing anything. Nothing is ever auto-sent.
  • Per-tenant prompt tuning, no deploy, setting the ai-draft:config:draft-system-prompt config-key changes what reaches the LLM provider on the next call; the integration test asserts the override arrives verbatim.
  • Access stays honest, ai-draft gates its query to TenantAdmin/SystemAdmin, and ctx.query runs as the current user, so the bridging handler carries the same gate instead of escalating through queryAs.

Any flow where users answer incoming messages, support inboxes, booking requests, tenant communication, and you want AI-suggested replies with a human in the loop. The same bridge pattern applies to the other enterprise AI features (ai-triage, ai-generate, ai-patch).

ai-draft 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-draft

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-Draft Sample
// Shows: a support-inbox feature that consumes the enterprise `ai-draft`
// feature server-side through ctx.query — the cross-feature bridge. One
// write-handler asks ai-draft for a reply and stores the draft on the
// ticket; the result union (draft | clarify | error) is handled explicitly,
// and nothing is ever auto-sent.
import {
createEntity,
createEntityExecutor,
createTextField,
defineEntityCreateHandler,
defineEntityDetailHandler,
defineEntityListHandler,
defineFeature,
} from "@cosmicdrift/kumiko-framework/engine";
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
import {
aiDraftFeature,
DRAFT_HANDLER_QN,
type DraftMessageResult,
} from "@cosmicdriftgamestudio/kumiko-ai-draft";
import { z } from "zod";
export const ticketEntity = createEntity({
table: "read_sample_support_tickets",
fields: {
subject: createTextField({ required: true }),
from: createTextField({ required: true }),
body: createTextField({ required: true }),
// Filled by draft-reply. A human reviews and sends — the entity keeps
// the draft, not an outbox.
draftedSubject: createTextField(),
draftedReply: createTextField(),
},
});
// ai-draft'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 supportInboxFeature = defineFeature("support-inbox", (r) => {
r.requires(aiDraftFeature.name);
r.entity("support-ticket", ticketEntity);
const { executor } = createEntityExecutor("support-ticket", ticketEntity);
r.writeHandler(defineEntityCreateHandler("support-ticket", ticketEntity, adminOnly));
r.queryHandler(defineEntityListHandler("support-ticket", ticketEntity, adminOnly));
r.queryHandler(defineEntityDetailHandler("support-ticket", ticketEntity, adminOnly));
// The point of this sample: ask ai-draft for a reply, store it for review.
r.writeHandler(
"draft-reply",
z.object({ id: z.uuid(), purpose: z.string().min(1) }),
async (event, ctx) => {
const ticket = await executor.detail({ id: event.payload.id }, event.user, ctx.db);
if (!ticket) {
return failNotFound("support-ticket", event.payload.id);
}
// Rows come back type-erased — parse at the boundary instead of
// sprinkling `as` casts over every field access. safeParse instead of
// parse: a shape mismatch is a data problem, not a bug — it must
// surface as failUnprocessable like every other failure path here,
// not as an uncaught ZodError.
const parsedTicket = z
.object({ subject: z.string(), from: z.string(), body: z.string(), version: z.number() })
.safeParse(ticket);
if (!parsedTicket.success) {
return failUnprocessable("ticket has unexpected shape", {
causeNote: parsedTicket.error.message,
});
}
const fields = parsedTicket.data;
// Cross-feature bridge: runs ai-draft's queryHandler as the current
// user, sharing the active transaction. Feature boundaries stay at
// the handler contract — no imports of ai-draft internals.
let result: DraftMessageResult;
try {
result = (await ctx.query(DRAFT_HANDLER_QN, {
sourceMessage: { subject: fields.subject, from: fields.from, body: fields.body },
purpose: event.payload.purpose,
})) as DraftMessageResult; // boundary: ctx.query erases the target handler's return type
} catch (err) {
// Provider/transport exceptions (timeout, 5xx) surface as a clean
// handler failure instead of an uncaught 500.
return failUnprocessable("draft failed", {
causeNote: err instanceof Error ? err.message : String(err),
});
}
switch (result.type) {
case "draft":
// Store the draft on the ticket; sending stays a human decision.
return executor.update(
{
id: event.payload.id,
version: fields.version,
changes: { draftedSubject: result.subject, draftedReply: result.body },
},
event.user,
ctx.db,
);
case "clarify":
// The model needs input it cannot assume — hand the question back
// to the caller; the ticket is left untouched.
return failUnprocessable("draft needs clarification", {
question: result.question,
causeNote: result.reason,
});
case "error":
return failUnprocessable("draft failed", { message: result.message });
default: {
// A fourth DraftMessageResult variant must not fall through silently.
const exhaustive: never = result;
return failUnprocessable("draft failed", { message: `unknown result ${exhaustive}` });
}
}
},
adminOnly,
);
});

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