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. 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 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 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", {
reason: 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", {
reason: 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,
reason: 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.