Make your app agent-ready
Enterprise The agent runtime (ai-agent, ai-agent-edit) is part of the commercial Kumiko Enterprise offering. The exposure rules on this page live in the open framework (agent-tools), they apply whether or not you have the agent 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 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 the second half. It is not a label the UI renders
somewhere, risk: "high" is enforced: ai-agent:write:approve-always refuses
to create an always rule for a high-risk handler, so a destructive action can
never become unattended, whatever the mode.
// 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" }, }, );});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 is enough to see them: it runs the check at boot against
the registry the server actually composed, and warns once per start.
[agent-tools] 3 handler/screen/entity gap(s) invisible to the AI agent: handler tickets.purge, no description ...To run the same check on demand, in CI, or while writing the feature, call the engine yourself. 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 the inverse case: a screen is visible unless it opts out with
agent: { expose: false }, so the lint flags an undescribed screen rather than
silently dropping it.
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.
import { capCounterFeature } from "@cosmicdrift/kumiko-bundled-features/cap-counter";import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";import { createTenantFeature } from "@cosmicdrift/kumiko-bundled-features/tenant";import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";import { createAiAgentFeature } from "@cosmicdriftgamestudio/kumiko-ai-agent";import { aiFoundationFeature } from "@cosmicdriftgamestudio/kumiko-ai-foundation";import { aiProviderMockFeature } from "@cosmicdriftgamestudio/kumiko-ai-provider-mock";import { serviceDeskFeature } from "./feature";
export function serviceDeskServerFeatures(): readonly FeatureDefinition[] { return [ createConfigFeature(), createTenantFeature(), createSecretsFeature(), capCounterFeature, aiFoundationFeature, aiProviderMockFeature, // 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(), serviceDeskFeature, ];}Client side, aiAgentClient() contributes the agent’s provider and
translations; the chat surface renders inside that provider:
// The client half of the mount. `aiAgentClient()` contributes the agent's// provider and translations to the web app; the chat surface itself// (AgentTrigger / AgentLayer) renders inside that provider.
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";import { aiAgentClient } from "@cosmicdriftgamestudio/kumiko-ai-agent/web";
export function serviceDeskClientFeatures(): readonly ClientFeatureDefinition[] { return [aiAgentClient()];}Pre-seed the tenant’s approval rules
Section titled “Pre-seed the tenant’s approval rules”Without a rule, the default answer is ask, every write is proposed and a
human answers. A tenant that already knows its policy can write the rules up
front instead of waiting for the first prompt:
// 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 { SET_PERMISSION_QN } from "@cosmicdriftgamestudio/kumiko-ai-agent";import { TICKET_CLOSE_QN, TICKET_PURGE_QN } from "./feature";
export type AgentPermissionRule = { readonly handlerQn: string; readonly scope: "user" | "tenant"; readonly decision: "always" | "ask" | "never";};
export const SERVICE_DESK_AGENT_PERMISSIONS: readonly AgentPermissionRule[] = [ { 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: AgentPermissionRule) => Promise<unknown>,): Promise<void> { for (const rule of SERVICE_DESK_AGENT_PERMISSIONS) { await write(SET_PERMISSION_QN, rule); }}
Resolution order is worth memorising: never beats everything, a tenant
rule outranks a user rule, and the default without any rule is ask. So a
tenant-wide never is a guardrail no user of that tenant can dial back, while a
tenant ask pins the default in place explicitly.
What the framework does
Section titled “What the framework does”A proposed write is not a write. In the default approval mode the model can
only produce a proposal card, and the user answers it:

| 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” is the answer that makes an unfamiliar action safe to accept : the user sees the actual values before anything is written:

Every accepted tool call goes through the ordinary handler pipeline: access rules, validation, hooks, audit. The agent dispatches as the calling user, never as a system user, so a role the user does not have is a role the agent does not have either. Field-level read rules strip fields out of tool results before the model sees them. The mode only ever narrows what is reachable; it never widens it.

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. That is the point of it. If a handler legitimately should be runnable unattended, it is not high risk, change the risk, do not fight the rule.- 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.