Skip to content

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.

The agent layer open next to the app, with the current screen as context chip
  • The agent-tools feature 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-agent plus ai-foundation and 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.

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:

  • version is 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 descriptions block exists because r.crud generates the handlers. Without it, the doc-gap check reports create, list and detail as three separate 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):

Terminal window
bun kumiko agent lint

It reads a kumiko.config.ts in the current directory, which exports the features to check:

kumiko.config.ts
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:

The agent asking a clarifying question instead of guessing the missing filter

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(),
];
}

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 agent permission list: per-handler rules with their scope and decision

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.

In approval mode, a model write creates a proposal card. The user chooses what happens next:

The approval card for a proposed write, with the four possible answers
AnswerWhat happens
RunThe handler dispatches once, as the calling user.
Check in the formThe normal edit form opens, pre-filled; saving or cancelling reports back to the card.
AlwaysAn always rule is stored for this handler, so the next call runs without asking. Refused for risk: "high".
DropNothing runs; the conversation continues.

“Check in the form” opens the normal edit form with the proposed values before anything is written:

The pre-filled edit form opened from the approval card

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.

The completed action confirmed in the conversation, with the app updated behind it
  • 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 description does.
  • r.crud verbs need their own descriptions. A description on 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 never cannot 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.”

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.