Skip to content

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.

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.

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:

  • 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 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:

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

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);
}
}

The agent permission list: per-handler rules with their scope and decision

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.

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:

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

The pre-filled edit form opened from the approval card

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.

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. 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 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.