An in-app AI agent for your own feature (ai-agent)
Preview
Section titled “Preview”Rendered by this sample’s own Playwright run — theme and viewport are the real matrix, not a mockup.













































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.
What it shows
Section titled “What it shows”- Exposure is fail-closed. A handler without
descriptionis invisible to the model, whatever roles the caller has.src/feature.tstherefore carries adescriptionon every handler, including the per-verbdescriptionsfor ther.crud-generated create/list/detail, and adescriptionon the entity itself so the agent can explain the schema. - Risk is declared, not inferred.
ticket:closeisagent: { risk: "mid" },ticket:purgeis"high". High risk is enforced, not decorative:write:approve-alwaysrefuses to create analwaysrule for it, so purging can never become unattended. - Mount both halves.
src/mount.tsputscreateAiAgentFeature()next to the domain feature server-side,src/client.tscontributesaiAgentClient()to the web app. - Pre-seed the tenant’s answers.
src/tenant-permissions.tswritesai-agent-permissionrules up front instead of waiting for the first “may I?” prompt.neverbeats everything and a tenant rule outranks a user rule, so the tenant-wideneveronticket:purgeis 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:approveactually runs it.
When to reach for it
Section titled “When to reach for 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.
Working sample
Section titled “Working sample”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, conversations and photos
Section titled “Window, conversations and photos”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.
Voice in, read aloud out
Section titled “Voice in, read aloud out”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.
FILE_STORAGE_PROVIDER=inmemory bun run src/app/server.ts, openhttp://localhost:4190, sign in asadmin@service-desk.test/service-desk-demo.- 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.
- 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.
- Reload, open the assistant and get an answer; “Read aloud” appears under it.
Push-to-talk
Section titled “Push-to-talk”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.
Hands-free
Section titled “Hands-free”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.
Pop-out
Section titled “Pop-out”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.
Approving a proposal
Section titled “Approving a proposal”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.

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:kindsfrompublic,costsandirreversibleplus atextof up to 300 characters that the user reads. A call with a consequence gets its own confirmation card, is never approved automatically and allows noalwaysrule.kinds: []keeps that own card but shows no badge, for a step that deserves its own confirmation without being public, costly or irreversible.costandpreviewrows 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.
Streamed answers and the turn budget
Section titled “Streamed answers and the turn budget”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).

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.
Editing mail templates with the assistant
Section titled “Editing mail templates with the assistant”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.

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

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.
Preview with a real record
Section titled “Preview with a real record”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.


Three pieces wire it up:
- The notification names the entity and maps placeholders to fields:
record: { entity: "ticket", map: { title: "subject", number: "number", status: "status" } }(src/feature.ts). - The server mounts
createTemplateRecordPreviewFeature()from@cosmicdriftgamestudio/kumiko-ai-agent/template-preview; it needstemplate-resolver(src/mount.ts). - 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.
Wiring
Section titled “Wiring”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.
bun --env-file=../.env test --config=bunfig.integration.toml samples/recipes/ai-agent-basicAI connections and the EU/ZDR policy
Section titled “AI connections and the EU/ZDR policy”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.
When text is not set up
Section titled “When text is not set up”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.

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.
| Reason | Meaning | Fix |
|---|---|---|
noModelSelected | No connection or model for text in any source. | Set one in a standard models screen or in the environment. |
noKey | The chosen platform connection has no key. | The SystemAdmin stores the key in AI platform. |
noProviderCapable | No mounted provider can do text. | Mount a provider feature in the app. |
modelNotAllowed | The 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. |
euZdrBlocked | EU/ZDR is required and the address is not an EU endpoint. | Clear the OpenRouter address or use the EU one. |
notConfigured | The 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
Section titled “AI calls”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.

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.
System check
Section titled “System check”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.


| Probe | Area | Scope | What it checks |
|---|---|---|---|
ai-connections | AI | tenant, free | Every connection of the tenant answers and lists models. |
ai-standard-models | AI | tenant, free | Each standard model is offered by its connection and allowed for the tenant’s tier. |
ai-platform-connections | AI | system, free | The platform connections with a key. |
ai-extract-test-invoice | AI | tenant, paid | Extracts a test invoice and compares supplier, date and total (60 second timeout). Skipped for tenants on the platform connection. |
parse-scan | Text recognition | system, free | document-ingest-provider-liteparse reads a built-in scan. |
read-xrechnung | E-invoice | system, free | document-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.
Source code
Section titled “Source code”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.