Make your app agent-ready
Enterprise The agent runtime (ai-agent, ai-agent-edit) is part of Kumiko Enterprise. The exposure rules on this page live in the open framework (agent-tools) and apply even when the agent runtime is not mounted.
Kumiko stores your entities, fields, handlers, screens, navigation, and labels
in the registry. The agent reads that registry directly. Add a description to
each handler so the agent knows its purpose. Exposure is fail-closed:
handlers without a description remain invisible to the model.

Prerequisites
Section titled “Prerequisites”- The
agent-toolsfeature mounted (open framework), it builds the tool catalog and the agent manifest from the registry, and reports documentation gaps at boot. - For the chat surface itself:
ai-agentplusai-foundationand an LLM provider plugin (Enterprise). - A feature of your own with at least one write handler.
The runtime is fail-closed for handlers: a handler is exposed only when it has
description or an explicit agent: { expose: true }. Screens use the
opposite default and remain visible unless they opt out.
The code
Section titled “The code”Describe every handler, and the entity behind it
Section titled “Describe every handler, and the entity behind it”Each described handler becomes one tool in the catalog. r.crud needs
descriptions per verb, the generated create/list/detail are separate tools,
so one description on the feature would not cover them. The entity needs a
description too: once a described handler makes it reachable, the agent has to
explain the schema it is filling.
agent: { risk: ... } is enforced policy. risk: "high" prevents
ai-agent:write:approve-always from creating an unattended rule for that
handler.
// 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, createNumberField, createSelectField, createTextField, defineFeature, templateVariable,} from "@cosmicdrift/kumiko-framework/engine";import { failNotFound, failTransition } from "@cosmicdrift/kumiko-framework/errors";import type { EntityEditScreenDefinition, EntityListScreenDefinition,} from "@cosmicdrift/kumiko-framework/ui-types";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`;
type TicketStatus = "open" | "closed" | "purged";
// 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: { number: createTextField({ searchable: true, personal: false, reason: "support_ticket_number_is_business_data", }), subject: createTextField({ required: true, searchable: true, filterable: true, sortable: true, personal: false, reason: "support_ticket_subject_is_business_data", }), 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 ticketCloseActionSchema = z.object({ id: z.uuid(), version: z.number(), status: z.literal("closed").default("closed"),});const ticketPurgeActionSchema = z.object({ id: z.uuid(), version: z.number() });
const variableParagraph = (before: string, variable: string, after: string): string => `<p>${before}<span data-variable="${variable}"></span>${after}</p>`;
const TICKET_REPLY_BODY = { en: [ variableParagraph("Your ticket ", "number", " is being handled."), variableParagraph("Subject: ", "title", ""), variableParagraph("Current status: ", "status", ""), ].join(""), de: [ variableParagraph("Ihr Ticket ", "number", " wird bearbeitet."), variableParagraph("Betreff: ", "title", ""), variableParagraph("Aktueller Status: ", "status", ""), ].join(""),} as const;
const writeAccess = { roles: ["Admin", "TenantAdmin"] } as const;const readAccess = { roles: ["User", "Member", "Admin", "TenantAdmin"] } as const;
export const ticketEditScreen: EntityEditScreenDefinition = { id: "ticket-edit", type: "entityEdit", entity: "ticket", layout: { sections: [ { title: "service-desk:section.ticket", columns: 2, fields: [{ field: "subject", span: 2 }], }, ], },};
export const ticketListScreen: EntityListScreenDefinition = { id: "ticket-list", type: "entityList", entity: "ticket", columns: ["subject", "status"], defaultSort: { field: "subject", dir: "asc" }, createLabel: "service-desk:actions.new", rowActions: [ { kind: "navigate", id: "edit", label: "service-desk:actions.edit", screen: "ticket-edit" }, ],};
export const serviceDeskFeature = defineFeature(FEATURE_NAME, (r) => { r.translations({ keys: { "service-desk:nav.tickets": { en: "Tickets", de: "Tickets" }, "service-desk:actions.new": { en: "New ticket", de: "Neues Ticket" }, "service-desk:workspace.main": { en: "Service desk", de: "Service-Desk" }, "service-desk:actions.edit": { en: "Edit", de: "Bearbeiten" }, "service-desk:section.ticket": { en: "Ticket", de: "Ticket" }, "screen:ticket-list.title": { en: "Service desk", de: "Service-Desk" }, "screen:ticket-edit.title": { en: "Edit ticket", de: "Ticket bearbeiten" }, "service-desk:entity:ticket:field:number": { en: "Number", de: "Nummer" }, "service-desk:entity:ticket:field:subject": { en: "Subject", de: "Betreff" }, "service-desk:entity:ticket:field:status": { en: "Status", de: "Status" }, "service-desk:entity:ticket:field:status:option:open": { en: "Open", de: "Offen" }, "service-desk:entity:ticket:field:status:option:closed": { en: "Closed", de: "Geschlossen" }, "service-desk:entity:ticket:field:status:option:purged": { en: "Purged", de: "Gelöscht" }, "screen:ticket-close-form.title": { en: "Close ticket", de: "Ticket schließen" }, "service-desk:entity:__action-form__:field:id": { en: "Ticket ID", de: "Ticket-ID" }, "service-desk:entity:__action-form__:field:version": { en: "Version", de: "Version" }, "service-desk:entity:__action-form__:field:status": { en: "Status", de: "Status" }, "service-desk:entity:__action-form__:field:status:option:closed": { en: "Closed", de: "Geschlossen", }, "service-desk:feature.label": { en: "Service desk", de: "Service-Desk" }, "service-desk:notify.ticket-reply.label": { en: "Ticket reply", de: "Ticket-Antwort" }, "service-desk:mail.variable.title": { en: "Ticket subject", de: "Betreff des Tickets" }, "service-desk:mail.variable.number": { en: "Ticket number", de: "Ticketnummer" }, "service-desk:mail.variable.status": { en: "Ticket status", de: "Status des Tickets" }, }, });
const { table, executor } = createEntityExecutor("ticket", ticketEntity);
// Without this guard a mid-risk close could undo a high-risk purge, which is // exactly what the risk tiers are meant to prevent. const transitionTicket = async ( payload: { readonly id: string; readonly version: number }, to: TicketStatus, allowedFrom: readonly TicketStatus[], user: Parameters<typeof executor.update>[1], db: Parameters<typeof executor.update>[2], ) => { const [current] = await db.selectMany(table, { id: payload.id }, { limit: 1 }); if (!current) return failNotFound("ticket", payload.id); const from = String(current["status"]); if (!allowedFrom.some((allowed) => allowed === from)) { return failTransition(from, to, allowedFrom); } return executor.update( { id: payload.id, version: payload.version, changes: { status: to } }, user, db, ); };
r.crud("ticket", ticketEntity, { write: { access: writeAccess }, read: { access: readAccess }, verbs: { create: true, update: true, delete: false, restore: false, list: true, detail: true }, // Status moves only through close/purge, which enforce the transition guard and // the risk tiers; a generic update could otherwise purge or un-purge a ticket. excludeFields: { update: ["status"] }, // 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.", update: "Update a support ticket's subject.", list: "Search support tickets by subject and status.", detail: "Read one support ticket by id.", }, });
r.notification("ticket-reply", { variables: { title: templateVariable.text({ label: "service-desk:mail.variable.title", required: true, example: "Printer on floor 3", }), number: templateVariable.text({ label: "service-desk:mail.variable.number", required: true, example: "T-1042", }), status: templateVariable.text({ label: "service-desk:mail.variable.status", required: true, example: "open", }), }, record: { entity: "ticket", map: { title: "subject", number: "number", status: "status" } }, template: { en: { body: TICKET_REPLY_BODY.en }, de: { body: TICKET_REPLY_BODY.de } }, meta: { en: { subject: "Your ticket {{number}}: {{title}}" }, de: { subject: "Ihr Ticket {{number}}: {{title}}" }, }, });
r.writeHandler({ name: "ticket:close", schema: ticketCloseActionSchema, handler: async (event, ctx) => transitionTicket(event.payload, "closed", ["open"], 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({ name: "ticket:purge", schema: ticketPurgeActionSchema, handler: async (event, ctx) => transitionTicket(event.payload, "purged", ["open", "closed"], event.user, ctx.db), access: writeAccess, description: "Mark a support ticket as purged by id.", agent: { risk: "high" }, });
r.screen({ id: "ticket-close-form", type: "actionForm", handler: TICKET_CLOSE_QN, fields: { id: createTextField({ required: true, personal: false, reason: "technical_reference", }), version: createNumberField({ required: true, integer: true, personal: false, reason: "technical_reference", }), status: createSelectField({ options: ["closed"] as const, required: true, default: "closed", personal: false, reason: "support_ticket_status", }), }, layout: { sections: [ { fields: [{ field: "id", visible: false }, { field: "version", visible: false }, "status"], }, ], }, access: writeAccess, dormant: true, cancelTarget: false, });
r.screen(ticketListScreen); r.screen(ticketEditScreen); r.nav({ id: "tickets", label: "service-desk:nav.tickets", icon: "list", screen: `${FEATURE_NAME}:screen:ticket-list`, order: 10, }); r.workspace({ id: "main", label: "service-desk:workspace.main", icon: "briefcase", default: true, access: { roles: ["User", "Member", "Admin", "TenantAdmin"] }, nav: [ `${FEATURE_NAME}:nav:tickets`, "config:nav:audience-tenant", "document-designer:nav:templates", "ai-foundation:nav:connections", "ai-foundation:nav:standard-models", "ai-pipeline:nav:trial-form", "ai-pipeline:nav:trial-runs", "ai-provider-openai-compat:nav:model-profiles", "prompt-store:nav:prompt-admin", ], });});Two details in that file are easy to miss:
versionis a required field of the action schema, not an optional one. The tool catalog strips required-but-injected fields from the model-facing JSON schema and dispatch fills in the current version, so the model never sees a version number it could go stale on.- The per-verb
descriptionsblock exists becauser.crudgenerates the handlers. Without it, the doc-gap check reports create, list and detail as three separate gaps.
Find the gaps
Section titled “Find the gaps”Mounting agent-tools runs the check at boot against the registry the server
actually composed. It reports gaps once per start.
[agent-tools] 3 handler/screen/entity gap(s) invisible to the AI agent — run `kumiko agent lint` for details: service-desk:write:ticket:archive — This handler has no description, so it stays invisible to the AI agent — set `description` to expose it, or `agent: { expose: false }` to opt out deliberately. ...To run the same check on demand or in CI, use the published CLI (since
@cosmicdrift/kumiko-cli 0.3.0):
bun kumiko agent lintIt reads a kumiko.config.ts in the current directory, which exports the
features to check:
import { myFeature } from "./src/features/my-feature";
export default { features: [myFeature] };It prints every gap and exits with code 1 if there is at least one, so it works as a CI step. Like the script below, it only sees the features you list, so pass the same list the server mounts.
For your own CI scripts, call the engine directly. It ships in the published package:
import { findAgentDocGaps, formatAgentDocGap,} from "@cosmicdrift/kumiko-bundled-features/agent-tools";import { APP_FEATURES } from "./src/run-config";
const gaps = findAgentDocGaps(APP_FEATURES);for (const gap of gaps) { console.log(formatAgentDocGap(gap));}process.exitCode = gaps.length === 0 ? 0 : 1;Pass the same list the server mounts, including the foundation features
composeFeatures prepends in auth mode, or the script reports a clean app
while the running one has gaps. The boot check has no such trap: it sees the
composed registry.
Screens are exposed unless they opt out with agent: { expose: false }.
When something is still missing at runtime the agent does not guess, it asks:

Mount both halves
Section titled “Mount both halves”Server side, createAiAgentFeature() sits next to the domain feature it should
be able to drive:
// The server-side mount for the agent: `createAiAgentFeature()` next to the// domain feature it should be able to drive. ai-agent needs ai-foundation// (LLM provider host), tenant, config, secrets and cap-counter; the provider// plugin is swappable — ai-provider-mock here, ai-provider-anthropic in// production. ai-provider-openai-compat also serves transcription (push-to-talk)// and read-aloud, once a provider and model are chosen in the settings hub.
import { createCapCounterFeature } from "@cosmicdrift/kumiko-bundled-features/cap-counter";import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";import { createFileFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/file-foundation";import { createFileProviderInMemoryFeature } from "@cosmicdrift/kumiko-bundled-features/file-provider-inmemory";import { createReadinessFeature } from "@cosmicdrift/kumiko-bundled-features/readiness";import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";import { createTemplateResolverFeature, type TenantEditableTemplate, templateEditAccess,} from "@cosmicdrift/kumiko-bundled-features/template-resolver";import { createTenantFeature } from "@cosmicdrift/kumiko-bundled-features/tenant";import { createDocumentDesignerFeature } from "@cosmicdrift/kumiko-document-designer";import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";import { createAiAgentAttachmentsFeature, createAiAgentFeature,} from "@cosmicdriftgamestudio/kumiko-ai-agent";import { createAiAgentEditFeature } from "@cosmicdriftgamestudio/kumiko-ai-agent/edit";import { createTemplateRecordPreviewFeature } from "@cosmicdriftgamestudio/kumiko-ai-agent/template-preview";import { aiFoundationFeature } from "@cosmicdriftgamestudio/kumiko-ai-foundation";import { aiProviderMockFeature } from "@cosmicdriftgamestudio/kumiko-ai-foundation/testing";import { createAiPipelineFeature } from "@cosmicdriftgamestudio/kumiko-ai-pipeline";import { aiProviderAnthropicFeature } from "@cosmicdriftgamestudio/kumiko-ai-provider-anthropic";import { aiProviderOpenAICompatFeature } from "@cosmicdriftgamestudio/kumiko-ai-provider-openai-compat";import { promptStoreFeature } from "@cosmicdriftgamestudio/kumiko-prompt-store";import { serviceDeskFeature } from "./feature.ts";import { serviceDeskAiFeature, ticketStepSpecs } from "./ticket-ai.ts";
// Template writes are SystemAdmin-only by default; the demo lets its own admins edit the ticket reply mail.const tenantEditableTemplates: readonly TenantEditableTemplate[] = [ { kind: "mail", slug: "service-desk:notify:ticket-reply", access: { roles: ["Admin", "TenantAdmin"] }, },];
export function serviceDeskServerFeatures(): readonly FeatureDefinition[] { return [ createConfigFeature(), createTenantFeature(), createSecretsFeature(), createReadinessFeature({ nav: { workspaces: ["service-desk:workspace:main"] } }), createCapCounterFeature(), aiFoundationFeature, promptStoreFeature, aiProviderMockFeature, // Never selected by the demo; mounted so the settings hub shows a provider with its own model select. aiProviderAnthropicFeature, // One package serves text, voice in and read-aloud out: the latter two stay dormant until a tenant admin picks // the provider in the settings hub, so the demo runs unchanged without them. aiProviderOpenAICompatFeature, // Two ticket steps give the KI-Probelauf and the prompt editor something to show. createAiPipelineFeature(ticketStepSpecs), serviceDeskAiFeature, // Default mode is `approval`: the model may propose a write, a human // approves it. `editEnabled` (plus the ai-agent-edit addon) is what // would let an `always` rule dispatch a write unattended. createAiAgentFeature({ editEnabled: true }), createAiAgentEditFeature(), // Camera and file attachments; demo storage is the in-memory provider (selected via the // file-foundation:config:provider key). createFileFoundationFeature(), createFileProviderInMemoryFeature(), createFilesFeature(), createAiAgentAttachmentsFeature(), // Mail template editing: the template-resolver tools reach the agent, the editor shows the previews. createTemplateResolverFeature({ tenantEditable: tenantEditableTemplates }), createRendererFoundationFeature(), createRendererMailHtmlFeature(), createDocumentDesignerFeature({ access: templateEditAccess(tenantEditableTemplates) }), createTemplateRecordPreviewFeature(), serviceDeskFeature, ];}Client side, aiAgentClient() contributes the agent’s provider and
translations; the chat surface renders inside that provider:
// The client half of the mount. The app shell adds AgentTrigger + AgentLayer;// this list contributes auth, locale, and the agent provider.
import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";import { textBlocksClient } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";import { documentDesignerClient } from "@cosmicdrift/kumiko-document-designer/web";import { localeDeClient } from "@cosmicdrift/kumiko-locale-de/web";import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";import { templateRecordPreviewClient } from "@cosmicdriftgamestudio/kumiko-ai-agent/template-preview/web";import { aiAgentClient } from "@cosmicdriftgamestudio/kumiko-ai-agent/web";import { promptStoreClient } from "@cosmicdriftgamestudio/kumiko-prompt-store/web";
export function serviceDeskClientFeatures(): readonly ClientFeatureDefinition[] { return [ localeDeClient(), emailPasswordClient(), textBlocksClient(), documentDesignerClient(), aiAgentClient(), templateRecordPreviewClient(), promptStoreClient(), ];}Pre-seed the tenant’s approval rules
Section titled “Pre-seed the tenant’s approval rules”Without a rule, the default is ask: every write is proposed for human review.
A tenant can write its policy in advance:
// Tenant-wide agent permissions, seeded once per tenant instead of waiting// for a user to answer the first "may I?" prompt.//// Resolution order: `never` beats everything, a tenant rule outranks a user// rule, and the default without any rule is `ask`. So a tenant `never` is a// hard guardrail no user of that tenant can dial back, while a tenant `ask`// pins the default in place explicitly.
import { type PermissionRule, SET_PERMISSION_QN } from "@cosmicdriftgamestudio/kumiko-ai-agent";import { TICKET_CLOSE_QN, TICKET_PURGE_QN } from "./feature.ts";
export const SERVICE_DESK_AGENT_PERMISSIONS: readonly PermissionRule[] = [ { handlerQn: TICKET_CLOSE_QN, scope: "tenant", decision: "ask" }, { handlerQn: TICKET_PURGE_QN, scope: "tenant", decision: "never" },];
/** * `set-permission` validates every handlerQn against the CALLER's own * role-filtered manifest, so `write` must dispatch as a tenant admin who * can actually reach both handlers. */export async function seedTenantAgentPermissions( write: (qn: string, payload: PermissionRule) => Promise<unknown>,): Promise<void> { for (const rule of SERVICE_DESK_AGENT_PERMISSIONS) { await write(SET_PERMISSION_QN, rule); }}
The resolution order is never, then a tenant rule, then a user rule, then
the default ask. A tenant-wide never cannot be relaxed by a user rule.
What the framework does
Section titled “What the framework does”In approval mode, a model write creates a proposal card. The user chooses
what happens next:

| Answer | What happens |
|---|---|
| Run | The handler dispatches once, as the calling user. |
| Check in the form | The normal edit form opens, pre-filled; saving or cancelling reports back to the card. |
| Always | An always rule is stored for this handler, so the next call runs without asking. Refused for risk: "high". |
| Drop | Nothing runs; the conversation continues. |
“Check in the form” opens the normal edit form with the proposed values before anything is written:

Every accepted tool call goes through access rules, validation, hooks, and audit. The agent dispatches as the calling user, so it cannot use a role the user does not have. Field-level read rules also filter tool results before the model sees them.
Treat tool results as untrusted input, not as instructions. A ticket subject,
an email body, or a customer field can carry text that tries to steer the
model. That is why approval is the default and why risk: "high" stays behind
approval. Only classify a handler as lower risk, or accept an always rule for
it, if running it unattended is still acceptable when the model’s input has
been manipulated.

Common gotchas
Section titled “Common gotchas”- A handler with roles but no description is invisible. Access rules and
agent exposure are independent gates. Adding a role does not expose a handler
to the agent; only a
descriptiondoes. r.crudverbs need their own descriptions. Adescriptionon the feature or the entity does not cover the generated create/list/detail tools.risk: "high"cannot be waived by a permission rule. If a handler can safely run unattended, classify it with the appropriate lower risk, but only when it stays safe with manipulated model input (see above).- A tenant
nevercannot be overridden per user. Seeding one is a policy decision, not a default worth setting casually. - Descriptions are read by a model, not by a colleague. Say what the handler does and when to reach for it, in one sentence. “Closes a ticket” is thinner than “Close a support ticket by id once the customer issue is resolved.”
Live example
Section titled “Live example”The ai-agent-basic recipe is the
complete version of the code on this page, a small service desk whose tickets
the agent can search and, with a human in the loop, close, but never purge. Its
integration test drives search, clarify, proposal and approval over real SSE
against ai-provider-mock, and asserts the ticket is still open at the proposal
step.
See also
Section titled “See also”- Enterprise features, where
ai-agentsits in the commercial tier. - Field-level permissions, the read rules that also filter what reaches the model.
- Audit, every executed tool call lands here.