The Assistant: talk to your app
Enterprise ai-agent and its ai-agent-edit add-on ship in the commercial Kumiko Enterprise offering. The exposure rules they build on (agent-tools) live in the open framework.
Every app in this stack already carries a precise description of itself: entities,
fields, handlers, screens, navs, labels. ai-agent opens a chat layer on top of the
running app and hands the model that description. The result is not a chatbot bolted
onto a support page, it is a second way to operate the app you already have. Ask a
question and it queries. Ask for a change and it proposes one, and a human answers.

Nothing on this page is a special integration. The screenshots come from PublicStatus, an ordinary Kumiko app with the agent mounted next to its incident and component features. Its UI runs in German; the tables below name the English labels for the same controls.
Thirty seconds in a running app
Section titled “Thirty seconds in a running app”The user is on the components list and types one sentence: open an incident because of the elevated latency. The model has the tool catalog for that app, but the incident handler needs a component and the sentence names none. So it does not guess, it asks, offering the components it can actually see plus a free-text answer:

With the answer in hand it still does not write. In the default mode a write can only become a proposal card: the handler it wants to call, the risk that handler declared for itself, and four ways to answer.

“Check in the form” is the answer that makes an unfamiliar action safe to accept. The app’s own edit screen opens, pre-filled with exactly the values the model proposed, title, description, severity, and the user reads them before anything is written. Note the context chip at the top of the layer: it followed the user onto the form.

Saving the form, or answering “Run” on the card, dispatches the handler once. The card collapses to a done state and the conversation continues:

It already knows your app
Section titled “It already knows your app”There is no second source of truth to maintain. The tool catalog is built from the
registry the server actually composed, per request and per caller. What a feature
owes the model is one sentence per handler, because exposure is fail-closed: a
handler without a description stays invisible no matter which roles the caller
has. The recipe’s service-desk feature is a complete example, a described entity,
per-verb descriptions on generated CRUD, and two custom writes that declare how
dangerous they are:
// 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 things the model never sees. Fields that are required but injected, version
is the usual one, are stripped from the model-facing JSON schema, so it cannot
propose a stale version number; dispatch fills in the current one. And a handler
whose access rule the caller does not satisfy is not in that caller’s catalog at
all, so the model cannot propose it and then be refused.
Make your app agent-ready is the working guide to the other side of this: what counts as a documentation gap, how to list the gaps in your own app, and how to pre-seed a tenant’s rules.
Permissions, the way Claude Code does them
Section titled “Permissions, the way Claude Code does them”A proposed write is not a write. The card’s four answers:
| Answer | What happens |
|---|---|
| Run | The handler dispatches once, as the calling user. |
| Check in the form | The app’s edit screen opens pre-filled; saving or cancelling reports back to the card. Nothing is written in between. |
| Always | Stores an always rule for this handler so the next call skips the card. Refused for a handler that declared risk: "high", and the ai-agent-edit add-on has to be mounted for the rule to be stored. |
| Drop | Nothing runs; the conversation continues. |
The stored rules are not hidden in an admin corner. The settings disclosure inside the layer lists what the current user has granted, deletes a single rule, or resets all of them at once:

Rules exist at user and tenant scope, with never beating everything and the
default without any rule being ask, a tenant can therefore write its policy up
front instead of waiting for someone to answer the first prompt. The guide has the
full resolution order and the seeding code.
Modes, and the ceiling the tenant sets
Section titled “Modes, and the ceiling the tenant sets”Three modes, set per tenant in config, default approval:
| Mode | What the agent may do |
|---|---|
read-only | Query handlers only. No write ever reaches a card. |
approval | Reads, plus writes as proposals a human answers. The default. |
edit | A write with an always rule and a risk below high dispatches without a card. Everything else still asks. |
edit is not a config value you can simply type: the option only appears when the
feature is mounted with editEnabled and the ai-agent-edit add-on is in the
registry, mount one without the other and boot fails rather than silently running
in the weaker mode. In the other direction the server clamps: the client may narrow
the mode for a conversation (the read-only switch in the screenshot above), never
widen it.
The ceiling is a cap, not a rate limit. ai-agent.tokens counts input plus output
tokens of every provider call against a monthly tenant budget (one million by
default), ai-agent.transcriptions counts voice transcriptions (a thousand). A
tenant over budget gets a rate-limit error that says when the budget resets, the
first of the next month, instead of a mysterious failure. Both are ordinary
cap-counter caps, so they show up wherever the
tenant’s other caps do.
What keeps it safe
Section titled “What keeps it safe”The chat layer adds no new path into your data. Every tool call takes the one that already exists:
- The catalog is per caller. It is built from the registry filtered by the caller’s roles, then filtered again by the permission rules and the mode. A handler the user cannot reach is not offered to the model.
- Dispatch happens as the user. Tool calls run through the same query and write paths a click would take, with the caller’s identity, never a system user. Access rules, validation, hooks and projections all run as usual.
- Write results are re-read, not echoed. After a successful write the agent reads the record back through the ordinary detail query, so field-level read rules strip fields out before the model sees them. The raw write result never goes back to the model.
- Every executed tool call is audited. It lands as an
ai-agent-tool-callrecord alongside the audit trail of the handler it invoked, so “what did the assistant do here” is answerable after the fact. - Conversation turns are personal data. A turn’s payload is declared personal
and keyed to its author, so a GDPR anonymize
crypto-shreds it rather than leaving old prompts
readable; export and erasure requests are served by the
ai-agent-user-datahooks like any other user-owned data.
The composer carries a microphone button, and only when the browser can actually
record, no dead control on a device without MediaRecorder. It is push-to-talk,
capped at two minutes: the recording is transcribed through the tenant’s configured
transcription provider (an ai-foundation extension point, e.g.
transcribe-provider-openai-compat),
the text lands in the input for the user to read and edit, and nothing is sent until
they send it. The audio itself is never persisted.
What it is not
Section titled “What it is not”- Not an autonomous agent. A turn only ever starts from a person typing or speaking in the layer. There is no scheduler, job or webhook that starts one, and nothing runs while nobody is looking.
- No undo. An executed tool call is an ordinary dispatch with ordinary
consequences; reversing it means the same handlers a human would use. A batch that
fails stops at the first failure rather than retrying. This is why
risk: "high"can never be answered with “Always”. - No memory across conversations. The model sees the turns of the conversation it is in, and nothing else. It does not learn your tenant’s habits between sessions, and a new conversation starts from the app’s own state.
- Make your app agent-ready, describe your handlers, find the gaps, seed the rules.
- ai-agent-basic recipe, the runnable service desk behind the code on this page.
- Handler slots:
description/agent, the two fields that decide what the model can see. - Field-level permissions, the read rules that also filter tool results.