An in-app AI agent for your own feature (ai-agent)
A small service desk whose tickets an AI agent can search and, with a human in the loop, close, but never purge. The domain feature stays ordinary Kumiko; what makes it agent-usable is that each handler says what it does and how dangerous it is, and that the tenant pre-decides which of those the agent may propose at all.
What it shows
Section titled “What it shows”- Exposure is fail-closed, a handler without
descriptionis invisible to the model, whatever roles the caller has.src/feature.tstherefore carries adescriptionon every handler, including the per-verbdescriptionsfor ther.crud-generated create/list/detail, and adescriptionon the entity itself so the agent can explain the schema. - Risk is declared, not inferred,
ticket:closeisagent: { risk: "mid" },ticket:purgeis"high". High risk is enforced, not decorative:write:approve-alwaysrefuses to create analwaysrule for it, so purging can never become unattended. - Mount both halves,
src/mount.tsputscreateAiAgentFeature()next to the domain feature server-side,src/client.tscontributesaiAgentClient()to the web app. - Pre-seed the tenant’s answers,
src/tenant-permissions.tswritesai-agent-permissionrules up front instead of waiting for the first “may I?” prompt.neverbeats everything and a tenant rule outranks a user rule, so the tenant-wideneveronticket:purgeis a guardrail no user of that tenant can dial back. - Approval mode means proposing is not doing, the integration test
drives search -> clarify -> proposal over real SSE, asserts the ticket is
still open at the proposal, and only
write:approveactually runs it.
When to reach for it
Section titled “When to reach for it”Any feature where a chat surface should be able to act on the user’s records
rather than only talk about them, support desks, back-office queues, admin
consoles. Start here, then reach for ai-agent-edit if some low-risk writes
should run unattended.
Wiring
Section titled “Wiring”ai-agent requires ai-foundation (provider host), tenant, config,
secrets and cap-counter; mount an LLM provider plugin alongside
(ai-provider-anthropic in production, ai-provider-mock here). The
integration test under src/__tests__/ boots exactly the list in
src/mount.ts against real Postgres + Redis and scripts the provider, so
every claim above is proven rather than described.
bun --env-file=../.env test --config=bunfig.integration.toml samples/recipes/ai-agent-basicSource code
Section titled “Source code”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-Agent Basic Sample// Shows: what a domain feature has to say about itself before the in-app// agent can use it. Agent exposure is fail-closed — a handler without a// `description` stays invisible to the model no matter which roles the// caller has — so every handler here carries one, and the two custom// writes additionally declare how dangerous they are via `agent.risk`.
import { createEntity, createEntityExecutor, createSelectField, createTextField, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";
export const FEATURE_NAME = "service-desk";
export const TICKET_CLOSE_QN = `${FEATURE_NAME}:write:ticket:close`;export const TICKET_PURGE_QN = `${FEATURE_NAME}:write:ticket:purge`;
// The entity needs a description too: once a described handler makes it// reachable, the agent has to be able to explain the schema it is filling.export const ticketEntity = createEntity({ table: "read_sample_service_desk_tickets", description: "A customer support ticket: one subject line and its lifecycle status.", fields: { subject: createTextField({ required: true, searchable: true, filterable: true }), status: createSelectField({ options: ["open", "closed", "purged"] as const, filterable: true }), },});
// `version` is required rather than optional so the tool catalog strips it// from the model-facing schema and dispatch injects the current one — the// model never sees a version it could go stale on.const ticketActionSchema = z.object({ id: z.uuid(), version: z.number() });
const writeAccess = { roles: ["TenantAdmin"] } as const;const readAccess = { roles: ["User", "TenantAdmin"] } as const;
export const serviceDeskFeature = defineFeature(FEATURE_NAME, (r) => { const { executor } = createEntityExecutor("ticket", ticketEntity);
r.crud("ticket", ticketEntity, { write: { access: writeAccess }, read: { access: readAccess }, verbs: { create: true, update: false, delete: false, restore: false, list: true, detail: true }, // Per-verb descriptions are what turn generated CRUD into agent tools — // a verb without one reaches the model with no description of what it does. descriptions: { create: "Create a support ticket with a subject line.", list: "Search support tickets by subject and status.", detail: "Read one support ticket by id.", }, });
r.writeHandler( "ticket:close", ticketActionSchema, async (event, ctx) => executor.update( { id: event.payload.id, version: event.payload.version, changes: { status: "closed" } }, event.user, ctx.db, ), { access: writeAccess, description: "Close a support ticket by id once the customer issue is resolved.", agent: { risk: "mid" }, }, );
// `risk: "high"` is not just a label: `write:approve-always` refuses to // create an `always` rule for it, so purging can never become unattended. r.writeHandler( "ticket:purge", ticketActionSchema, async (event, ctx) => executor.update( { id: event.payload.id, version: event.payload.version, changes: { status: "purged" } }, event.user, ctx.db, ), { access: writeAccess, description: "Irreversibly purge a support ticket and its customer content by id.", agent: { risk: "high" }, }, );});Enterprise recipe, samples/recipes/ai-agent-basic/src/feature.ts in the private kumiko-enterprise workspace.