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.
What it shows
Section titled “What it shows”- Server-side consumption via
ctx.query— the cross-feature bridge runsai-draft:query:draftas 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 explicitly —
draftis persisted for review,clarifyhands the model’s question back to the caller as a structured422(ticket untouched),errorrejects without storing anything. Nothing is ever auto-sent. - Per-tenant prompt tuning, no deploy — setting the
ai-draft:config:draft-system-promptconfig-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, andctx.queryruns as the current user, so the bridging handler carries the same gate instead of escalating throughqueryAs.
When to reach for it
Section titled “When to reach for it”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).
Wiring
Section titled “Wiring”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.
bun --env-file=../.env test --config=bunfig.integration.toml samples/recipes/ai-draftSource code
Section titled “Source code”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.