Skip to content

AI-drafted replies (ai-draft)

Turn an incoming message into a ready-to-review reply draft. A small support-inbox feature stores tickets; one write-handler bridges into the enterprise ai-draft feature via ctx.query, handles the full result union (draft / clarify / error), and stores the draft on the ticket — a human reviews and sends.

  • Server-side consumption via ctx.query — the cross-feature bridge runs ai-draft:query:draft as the current user, inside the same transaction. Feature boundaries stay at the handler contract; the sample never imports ai-draft internals.
  • Handling the result union explicitlydraft 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 complete feature — embedded straight from the source file, so the code here is exactly what runs:

// 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);
}
// 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.
const result = (await ctx.query(DRAFT_HANDLER_QN, {
sourceMessage: {
subject: ticket["subject"] as string,
from: ticket["from"] as string,
body: ticket["body"] as string,
},
purpose: event.payload.purpose,
})) as DraftMessageResult; // boundary: ctx.query erases the target handler's return type
switch (result.type) {
case "draft":
// Store the draft on the ticket; sending stays a human decision.
return executor.update(
{
id: event.payload.id,
version: ticket["version"] as number,
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,
reason: result.reason,
});
case "error":
return failUnprocessable("draft failed", { reason: result.reason });
}
},
adminOnly,
);
});

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