Skip to content

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

Rendered by this sample’s own Playwright run — theme and viewport are the real matrix, not a mockup.

An in-app AI agent for your own feature (ai-agent) — ai-settings-anthropic-model
An in-app AI agent for your own feature (ai-agent) — anbieter-fehlergrund
An in-app AI agent for your own feature (ai-agent) — anbieter-liste
An in-app AI agent for your own feature (ai-agent) — anbieter-neu
An in-app AI agent for your own feature (ai-agent) — anbieter-plattform-fehler
An in-app AI agent for your own feature (ai-agent) — assistant-approval
An in-app AI agent for your own feature (ai-agent) — assistant-camera
An in-app AI agent for your own feature (ai-agent) — assistant-camera-still
An in-app AI agent for your own feature (ai-agent) — assistant-cap-exceeded
An in-app AI agent for your own feature (ai-agent) — assistant-clarify
An in-app AI agent for your own feature (ai-agent) — assistant-clarify-answered
An in-app AI agent for your own feature (ai-agent) — assistant-conversations
An in-app AI agent for your own feature (ai-agent) — assistant-docked
An in-app AI agent for your own feature (ai-agent) — assistant-done
An in-app AI agent for your own feature (ai-agent) — assistant-floating
An in-app AI agent for your own feature (ai-agent) — assistant-form-review
An in-app AI agent for your own feature (ai-agent) — assistant-layer
An in-app AI agent for your own feature (ai-agent) — assistant-minimized
An in-app AI agent for your own feature (ai-agent) — assistant-permissions
An in-app AI agent for your own feature (ai-agent) — assistant-provider-error-countdown
An in-app AI agent for your own feature (ai-agent) — assistant-provider-error-ready
An in-app AI agent for your own feature (ai-agent) — assistant-push-to-talk
An in-app AI agent for your own feature (ai-agent) — assistent-antwort-streamt
An in-app AI agent for your own feature (ai-agent) — assistent-einrichtung-admin
An in-app AI agent for your own feature (ai-agent) — assistent-einrichtung-link
An in-app AI agent for your own feature (ai-agent) — assistent-einrichtung-link-plattform
An in-app AI agent for your own feature (ai-agent) — assistent-einrichtung-mitglied
An in-app AI agent for your own feature (ai-agent) — ki-aufrufe
An in-app AI agent for your own feature (ai-agent) — model-profile-form
An in-app AI agent for your own feature (ai-agent) — plattform-standardmodelle
An in-app AI agent for your own feature (ai-agent) — probelauf-liste
An in-app AI agent for your own feature (ai-agent) — probelauf-modell
An in-app AI agent for your own feature (ai-agent) — prompt-detail
An in-app AI agent for your own feature (ai-agent) — prompt-modell
An in-app AI agent for your own feature (ai-agent) — prompt-modell-eigenes
An in-app AI agent for your own feature (ai-agent) — standard-modelle
An in-app AI agent for your own feature (ai-agent) — standard-modelle-plattform
An in-app AI agent for your own feature (ai-agent) — standard-modelle-plattform-erweitert
An in-app AI agent for your own feature (ai-agent) — systemcheck-bezahlt
An in-app AI agent for your own feature (ai-agent) — systemcheck-systemadmin
An in-app AI agent for your own feature (ai-agent) — systemcheck-tenantadmin
An in-app AI agent for your own feature (ai-agent) — template-patch-plan
An in-app AI agent for your own feature (ai-agent) — template-publish-card
An in-app AI agent for your own feature (ai-agent) — template-record-picker
An in-app AI agent for your own feature (ai-agent) — template-record-preview

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.

This recipe includes a runnable service-desk app under src/app/. Start it with FILE_STORAGE_PROVIDER=inmemory bun run src/app/server.ts, open http://localhost:4190, and use the assistant on the seeded ticket list. The browser suite drives the same app through the mock provider and renders the documented screenshots as a desktop/tablet/mobile × light/dark matrix.

The mock provider is only a deterministic demo backend. Replace it with ai-provider-anthropic or ai-provider-openai-compat when you connect the recipe to a real tenant provider.

The recipe is also the working sample used by the public documentation: /en/enterprise/samples/ai-agent-basic/.

Window modes (docked, floating, minimized), the conversation list, camera capture and attachments are described in the assistant guide. The recipe mounts the attachments feature, so all of them work in the sample app.

Transcription (push-to-talk) and read-aloud (a “Read aloud” button under every assistant answer) are capabilities of the AI provider settings. Each stays dormant until a tenant admin picks a connection and a model for it; the mic and the button only appear once a provider resolves for the tenant.

  1. FILE_STORAGE_PROVIDER=inmemory bun run src/app/server.ts, open http://localhost:4190, sign in as admin@service-desk.test / service-desk-demo.
  2. Open AI providers in the navigation and add a connection (see “AI connections” below), for example an OpenAI-compatible server such as a local Kokoro-FastAPI.
  3. Open Standard models and choose a connection and a model for Dictation (speech input) and Read aloud. The model select lists only models of that connection that support the capability.
  4. Reload, open the assistant and get an answer; “Read aloud” appears under it.

With a transcribe provider configured the composer shows a mic button: click it to start and stop, or hold Space (outside text fields) or the button itself to talk only while held. A level meter shows the live input while recording. “Send after dictation” in the assistant settings sends the transcript after a short, cancellable delay; without it the transcript only lands in the input.

Turn on “Offer hands-free mode” in the assistant settings. It needs both the transcribe and the speech provider; then a hands-free button appears next to the mic. The assistant listens, sends after a pause, reads the answer aloud and listens again until you press the button again or Escape.

In browsers with Document Picture-in-Picture (Chromium only) the panel header has a pop-out button that moves the assistant into its own always-on-top window; the main window shows a bubble that brings it back. Elsewhere the button is absent.

A read-aloud budget is opt-in: pass resolveSpeechBudget to createAiFoundationFeature() (characters per period, limit: null counts only). Without it there is no limit.

Every write the agent proposes lands on a card titled “What will happen”. Each step shows the handler title and a “Details” toggle with the input; “Apply” runs the plan, “Change something” sends it back to the agent, and “Run without asking from now on” stores an always rule where the handler allows one. The card describes consequences, never a risk level.

Approval card for closing a ticket

An app shapes the card per call with the resolveToolCallNotes option of createAiAgentFeature(). The resolver gets the handler context, the user and the proposed call and may return:

  • title: an i18n key or literal of up to 80 characters. It replaces the handler title on the plan step, the confirmation card, the checklist and the lists of open and failed steps, so “Close ticket” can read “Close T-1041: Printer on floor 3”.
  • consequence: kinds from public, costs and irreversible plus a text of up to 300 characters that the user reads. A call with a consequence gets its own confirmation card, is never approved automatically and allows no always rule. kinds: [] keeps that own card but shows no badge, for a step that deserves its own confirmation without being public, costly or irreversible.
  • cost and preview rows for what the step will spend or change.

A write handler that cannot run because inputs are missing can return their names in missing (or details.missing; the field name is exported as AGENT_MISSING_INPUTS_FIELD). The agent then asks the user for exactly those inputs instead of guessing. Names must look like identifiers, at most 64 characters each and at most 20 per result.

With a provider that supports chatStream (Anthropic, and every OpenAI-compatible text profile since 0.64.0), the answer grows in its bubble while the model is still writing. “Abort” stops it at any point. Technically, stream:turn emits short-lived { frameType: "text-delta", text } frames next to the stored turns; web clients that read the stream themselves can tell them apart with isAgentTurnFrame and isAgentTextDeltaFrame (type AgentStreamFrame).

The assistant mid-answer while the model is still writing

A whole turn, including every tool round, may take at most AGENT_TURN_BUDGET_MS (300 seconds); a single model call still has its own 120 second deadline. When the budget runs out the turn ends with an error turn (reason agent.turn_budget_exceeded) and the user reads “The answer took too long. Please try again or phrase the question more briefly.” Tests and special deployments can pass a different budget through the loop dependencies.

Untrusted text never reaches a prompt as instructions. fenceAsData(content, tag) from ai-foundation wraps it in a tag such as <message> or <document_text>, and the system prompt tells the model to treat the inside as data. ai-tasks does this for triage (TRIAGE_DATA_RULES) and extraction (EXTRACT_DATA_RULES), ai-pipeline for every step (PIPELINE_DATA_RULES). Use the same helper when an app feeds mail bodies, uploads or form text into its own prompts.

The recipe’s ticket-reply notification is a mail template, and the agent can edit and publish it. Ask for a change (“Change the subject of the ticket reply to Your ticket {{number}} is in progress and add a note”) and the plan card lists one preview row per change: the new subject, the new paragraph and where it goes. A patch with more than 50 rows shows 49 and a “More changes” row.

Plan card for a template edit with one row per change

Applying writes a draft; the live template does not change yet. Publishing is its own step with an irreversible consequence: the card shows the subject, the preview text and an excerpt of the body, a link to the template in the mail or PDF editor, and “Publish” or “Later”.

Confirmation card before the template goes live

These cards come from built-in notes for patch-draft, publish and publish-system. An app that has its own resolveToolCallNotes combines both with chainedToolCallNotes, so its notes cannot drop the built-in irreversible.

The template editor previews with sample values from the placeholder schema by default. With the record preview mounted, “Choose a record” in the sample values panel searches real tickets and fills the preview with one of them.

Record picker in the mail editor

Mail preview filled from ticket T-1041

Three pieces wire it up:

  1. The notification names the entity and maps placeholders to fields: record: { entity: "ticket", map: { title: "subject", number: "number", status: "status" } } (src/feature.ts).
  2. The server mounts createTemplateRecordPreviewFeature() from @cosmicdriftgamestudio/kumiko-ai-agent/template-preview; it needs template-resolver (src/mount.ts).
  3. The web app adds templateRecordPreviewClient() from @cosmicdriftgamestudio/kumiko-ai-agent/template-preview/web (src/client.ts).

The queries template-record-preview:query:records (up to 20 hits for a search) and template-record-preview:query:preview-record run as the caller, so the picker shows only records the user may read.

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 agent claim above is proven rather than described. The connection and EU/ZDR notes at the end describe ai-foundation and are covered by its own tests. A web app must also point Tailwind’s @source at @cosmicdriftgamestudio/kumiko-ai-agent/dist, or the agent’s classes are purged from the build.

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

ai-foundation stores a tenant’s AI providers as connections. Each connection has a kind (anthropic, openai or openai-compatible), an address for compatible servers and its own API key. The key is a write-only field: it is encrypted at rest and reads only say whether one is set. An empty field on edit keeps the stored key, unless the address or type changed; then the key must be entered again. The create and edit form is one entityEdit screen, and the Standard models screen assigns a connection and a model to each job (text, images, dictation, read-aloud). A SystemAdmin can also provide platform connections with one key per vendor.

OpenRouter is an openai-compatible connection. Its provider routing object is stored on the connection as providerRouting and sent with every chat request, but only to openrouter.ai and its subdomains over https. The fields are zdr, data_collection (allow or deny), allow_fallbacks, order and only. The edit form does not expose them; send them in the providerRouting field of ai-foundation:write:ai-connection:create or :update.

The EU/ZDR policy fails closed. A connection marked requireEuZdr (“EU only, zero data retention” in the form, shown for compatible connections) must be openai-compatible and point at one of the allowed EU origins (default https://eu.openrouter.ai) and always sends zdr: true and data_collection: "deny". Saves and every resolve are checked. Transcription runs through OpenRouter’s JSON endpoint with that routing (no live dictation); read-aloud cannot carry it and is refused with ai_foundation.connection_eu_zdr_request_not_routable. To apply the policy to every connection of the installation, pass enforceEuZdr: true to createAiFoundationFeature(); euZdrEndpoints replaces the allowed origins. The query ai-foundation:query:connection-data-policy returns a connection’s effective routing for feature gates.

Without a usable text model the assistant does not fail on the first prompt. It shows a notice with the reason instead. A TenantAdmin or SystemAdmin gets a button to the screen that fixes it; other users read that an admin has to set this up.

Setup notice with the admin button

A standard model comes from the first source that has a value: the tenant’s choice in Standard models, the SystemAdmin’s default in Platform standard models, then the app’s environment (AI_TEXT_CONNECTION, AI_TEXT_MODEL and the matching image and transcription variables). A tenant on a platform connection cannot change model or effort; that is the operator’s choice.

ReasonMeaningFix
noModelSelectedNo connection or model for text in any source.Set one in a standard models screen or in the environment.
noKeyThe chosen platform connection has no key.The SystemAdmin stores the key in AI platform.
noProviderCapableNo mounted provider can do text.Mount a provider feature in the app.
modelNotAllowedThe app limits the models a tenant may use on the platform key (resolvePlatformModels, often by plan) and this one is not allowed.Pick an allowed model or change the app rule.
euZdrBlockedEU/ZDR is required and the address is not an EU endpoint.Clear the OpenRouter address or use the EU one.
notConfiguredThe connection is set up only in part.Open it in AI providers.

The button leads a SystemAdmin to AI platform for noKey and euZdrBlocked and to Platform standard models otherwise. A TenantAdmin lands on Standard models; for a platform problem the text says the operator has to fix it.

App screens that use AI can show the same notice. useTextSetupGap(enabled) from @cosmicdriftgamestudio/kumiko-ai-agent/web returns null when text works, or a TextSetupGap with reason and adminCanFix:

const gap = useTextSetupGap(true);
if (gap !== null) return <AgentSetupNotice gap={gap} />;

The full setup guide for operators, with the platform key, EU/ZDR and the problem column of the provider list, is in the Enterprise repository.

AI calls (ai-foundation:screen:ai-calls, nav entry ai-calls) lists every call of an AI model in the active tenant, newest first: time, function, model, result, tokens in and out, and cost in USD. TenantAdmins and SystemAdmins see it. Without a period it shows the last 30 days, and it loads further pages while you scroll. Costs use the price on the day of the call and stay empty when no price is stored for the model.

AI calls of the tenant, newest first

Two options of createAiFoundationFeature() adjust it:

createAiFoundationFeature({
aiCalls: {
costs: "hidden",
handlerLabels: {
"billing:write:invoice:extract": {
en: "Read invoice",
de: "Rechnung auslesen",
es: "Leer factura",
},
},
},
});

costs: "hidden" removes the cost column and drops costUsd, reportedCostUsd, totalCostUsd and callsWithoutPrice from the screen and the query for every caller, SystemAdmin included; token figures stay. The default is "shown" (DEFAULT_AI_CALLS_COSTS). ai-pipeline has the same switch: createAiPipelineFeature(steps, { costs: "hidden" }). handlerLabels names the app’s handlers in the “Function” column, keyed by qualified handler name; a handler without a label shows its name.

The query ai-foundation:query:ai-calls returns pages (limit default 50, at most 200) with a nextCursor; from and to are optional. Its aggregate covers the newest 10,000 calls and sets truncated beyond that. A SystemAdmin reading another tenant’s calls is recorded as a cross-tenant read.

ai-foundation and the two document ingest providers add probes to the framework’s System check (/main/system-check). “Start check” runs every free probe; the paid one runs only from “More actions”, then “Test AI extraction · about 1 cent”. A failed row links to the screen that fixes it, and the texts reuse the reasons of the provider list.

System check as the operator, with the AI rows of the latest run

The paid AI extraction check under More actions

ProbeAreaScopeWhat it checks
ai-connectionsAItenant, freeEvery connection of the tenant answers and lists models.
ai-standard-modelsAItenant, freeEach standard model is offered by its connection and allowed for the tenant’s tier.
ai-platform-connectionsAIsystem, freeThe platform connections with a key.
ai-extract-test-invoiceAItenant, paidExtracts a test invoice and compares supplier, date and total (60 second timeout). Skipped for tenants on the platform connection.
parse-scanText recognitionsystem, freedocument-ingest-provider-liteparse reads a built-in scan.
read-xrechnungE-invoicesystem, freedocument-ingest-provider-einvoice reads a KoSIT test XRechnung.

The probes come with the features; mounting ai-foundation, the liteparse or the einvoice provider next to the readiness feature is all it takes. They write nothing besides the run entry. A TenantAdmin sees the rows of the own tenant, the SystemAdmin also the system probes.

Upgrading to 0.65.0: old provider ids removed

Section titled “Upgrading to 0.65.0: old provider ids removed”

Release 0.65.0 removed the migration from the old provider settings to connections: the boot jobs migrate-ai-connections-v2 and migrate-ai-platform, the config migratedLegacyConnections, the field legacyProviderId with its index and canonicalConnectionId. A stored standard model or prompt override that still says anthropic, openai-compat or openrouter now counts as an unknown connection.

Before (old provider id in a standard model):

ai-foundation:config:text-connection = "anthropic"

After (platform slot or the id of an own connection):

ai-foundation:config:text-connection = "platform:anthropic"
ai-foundation:config:text-connection = "0193f6d2-6a1e-7c55-9b1e-2f4c8d7a1b30"

The table loses a column. Run kumiko migrate generate in the app. Because ai_connections is a managed projection, the generator writes DROP TABLE, CREATE TABLE and a .rebuild.json. Replace the generated SQL with

DROP INDEX IF EXISTS "ai_connections_tenant_legacy_provider_uniq";
ALTER TABLE "ai_connections" DROP COLUMN IF EXISTS "legacy_provider_id";

and delete the .rebuild.json. The same steps are the migration text of the 0.65.0 entry in packages/ai-foundation/src/changes.json.


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

Enterprise recipe source is included in the generated docs mirror; the commercial workspace is not publicly browsable.