Skip to content

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.

The assistant layer open beside the app, with the current screen as a context chip and the composer at the bottom

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.

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:

The assistant asking which component the incident is for, offering the three known components and 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.

The proposal card naming the target handler and its declared risk, with the four possible answers

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

The app's incident form open behind the layer, pre-filled with the proposed title, description and severity

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

The proposal card collapsed to a done badge after the handler ran

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:

AnswerWhat happens
RunThe handler dispatches once, as the calling user.
Check in the formThe app’s edit screen opens pre-filled; saving or cancelling reports back to the card. Nothing is written in between.
AlwaysStores 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.
DropNothing 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:

The layer's settings disclosure with the read-only switch, one stored permission rule and a reset button

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.

Three modes, set per tenant in config, default approval:

ModeWhat the agent may do
read-onlyQuery handlers only. No write ever reaches a card.
approvalReads, plus writes as proposals a human answers. The default.
editA 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.

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-call record 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-data hooks 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.

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