Skip to content

An in-app AI agent for your own feature (ai-agent)

A small service desk whose tickets an AI agent can search and, with a human in the loop, close, but never purge. The domain feature stays ordinary Kumiko; what makes it agent-usable is that each handler says what it does and how dangerous it is, and that the tenant pre-decides which of those the agent may propose at all.

  • Exposure is fail-closed, a handler without description is invisible to the model, whatever roles the caller has. src/feature.ts therefore carries a description on every handler, including the per-verb descriptions for the r.crud-generated create/list/detail, and a description on the entity itself so the agent can explain the schema.
  • Risk is declared, not inferred, ticket:close is agent: { risk: "mid" }, ticket:purge is "high". High risk is enforced, not decorative: write:approve-always refuses to create an always rule for it, so purging can never become unattended.
  • Mount both halves, src/mount.ts puts createAiAgentFeature() next to the domain feature server-side, src/client.ts contributes aiAgentClient() to the web app.
  • Pre-seed the tenant’s answers, src/tenant-permissions.ts writes ai-agent-permission rules up front instead of waiting for the first “may I?” prompt. never beats everything and a tenant rule outranks a user rule, so the tenant-wide never on ticket:purge is a guardrail no user of that tenant can dial back.
  • Approval mode means proposing is not doing, the integration test drives search -> clarify -> proposal over real SSE, asserts the ticket is still open at the proposal, and only write:approve actually runs it.

Any feature where a chat surface should be able to act on the user’s records rather than only talk about them, support desks, back-office queues, admin consoles. Start here, then reach for ai-agent-edit if some low-risk writes should run unattended.

ai-agent requires ai-foundation (provider host), tenant, config, secrets and cap-counter; mount an LLM provider plugin alongside (ai-provider-anthropic in production, ai-provider-mock here). The integration test under src/__tests__/ boots exactly the list in src/mount.ts against real Postgres + Redis and scripts the provider, so every claim above is proven rather than described.

Terminal window
bun --env-file=../.env test --config=bunfig.integration.toml samples/recipes/ai-agent-basic

The feature entry point, embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// 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" },
},
);
});

Enterprise recipe, samples/recipes/ai-agent-basic/src/feature.ts in the private kumiko-enterprise workspace.