Structured and background AI
Run validated model tasks outside an interactive chat request.
Use runAiStructured() for one schema-valid model result.
It is the right API for classification, extraction, enrichment, and other bounded tasks that do not need a conversation.
runAiStructured() executes a model request. It does not authorize domain access. Optional usage attribution is metadata,
not authorization; an optional trusted usageSubject selects personal chat
quota accounting instead of background accounting.
Authorize the domain read first. Send only the fields required by the task.
Run a structured task
import { runAiStructured } from "@k2b/cloud/ai";
import { z } from "zod";
const item = await loadItemForAi({
itemId,
actor,
accessSubject,
});
if (!item) throw new Error("Item not found");
const result = await runAiStructured({
task: "inventory-categorize",
appId: "inventory",
input: JSON.stringify({
name: item.name,
description: item.description,
}),
systemPrompt: "Classify the item using the supplied text only.",
outputName: "classification",
output: z.object({
category: z.enum(["hardware", "office", "other"]),
confidence: z.number().min(0).max(1),
}),
temperature: 0,
maxOutputTokens: 200,
signal,
});
console.log(result.output.category);loadItemForAi() is the authorization boundary. Its return value is the
redacted model input.
The returned value includes the parsed output, model profile ID, usage, and structured-output metadata.
Structured tasks and context compaction keep their previous reasoning requests:
low for OpenAI, OpenRouter, vLLM and OpenAI-compatible profiles, a zero thinking
budget for Gemini, and no thinking field for Anthropic, Mistral and Ollama, which
keep their model defaults. The profile thinking level does not apply. Profile
extraBody still applies and can override nested provider parameters; see
Thinking level.
Set the task fields
| Field | Purpose |
|---|---|
task |
Short stable name used in tracing |
input |
User or application input |
output |
Zod schema for the result |
outputName |
Optional provider-facing schema name |
systemPrompt |
Optional task instructions |
requestedModelId |
Optional explicit profile |
temperature |
Task-level override |
maxOutputTokens |
Task-level output limit |
signal |
Cancellation |
traceParent |
Parent span for existing background work |
appId |
Application attribution |
attribution |
Optional authorized user, conversation, turn, and workflow-run identifiers for usage analysis |
The function resolves the model, requests structured output, validates the result, and records a trace span.
Cloud streams the answer from the model provider. A model that sends nothing for 60 seconds, before or during its answer, fails the call. A slow model that keeps answering can take as long as it needs.
Prompts and model output are not written to the trace. Metadata includes the model, duration, token counts, output mode, repair state, and attempts.
Choose the background model
Model resolution follows this order:
requestedModelId;- the
ai.background_model_idsetting; - the platform default.
Resolution fails when AI is disabled or the model is unavailable.
Cloud's built-in chat enrichment, personalization learning, and long-chat compaction use one prompt model: a code-owned task, optional administrator guidance, and a final code-owned output and safety contract. Administrators configure the shared background model, chat enrichment schedule, personalization learning schedule, and each task's additional instructions in Settings → AI → Background jobs. Additional instructions can supply local terminology or conventions; they cannot replace the task, loosen privacy rules, or change the output schema. Compaction uses this same additive model; there is no full custom compaction prompt. All three additional-instruction fields default to empty; their placeholders are examples, not active instructions. View built-in prompts opens the original task instructions and output rules in a read-only dialog. Personalization shows both turn learning and workflow pattern learning; the user locale is supplied separately at runtime. The viewer excludes organization additions and conversation content.
Administrators can open AI → Usage for token usage and reference costs, model performance, user activity, quality feedback, and background failures over 24-hour, 7-day, 30-day, or 90-day ranges.
Do not silently turn a failed AI result into application truth. Decide whether the caller should retry, skip the optional enrichment, or surface the error.
Keep routine run history for 90 days
The Assistant calls scheduled chat tasks routines in its interface, help, and
notifications, while the API, capability IDs (core.ai.task.*), routes, tables,
and CLI (cld assistant tasks) keep task.
Core deletes a routine's finished run (completed or failed) 90 days after it finished, together with its background turn, tool calls, and run transcript. Queued and running runs stay, as does the result message the run delivered into its chat. A finished run also stays while its turn is still active. The run holding a one-off routine's scheduled time keeps its row, even after completion, until the routine gets a new time or is deleted, so that time never runs twice; its background turn and transcript still expire after 90 days. Memory learning runs that used an expired turn as evidence stay without it. If the routine was deleted, its runs' background turns, tool calls, and transcripts expire on the same 90-day clock, measured from when each turn finished.
This window is fixed, not configurable. Core removes up to 100 runs per minute on the routine recovery tick. See Assistant routines for the execution and access rules.
Add AI to an application workflow
AI is an optional workflow building block. An application enables it by composing the shared actions into its workflow module:
import {
AI_WORKFLOW_ACTIONS,
defineWorkflowModule,
} from "@k2b/cloud/workflows";
export const inventoryWorkflows = defineWorkflowModule({
id: "inventory",
version: 1,
inputs: INVENTORY_INPUTS,
triggers: INVENTORY_TRIGGERS,
actions: {
...INVENTORY_WORKFLOW_ACTIONS,
...AI_WORKFLOW_ACTIONS,
},
});The shared vocabulary contains four data-only actions:
| Action | Result |
|---|---|
aiGenerateText |
One bounded text value |
aiClassify |
Exactly one declared choice |
aiClassifyMany |
A unique subset of the declared choices, in declaration order |
aiExtractData |
One strict object validated against declared fields |
Each action requires saveAs. Later steps consume the stored value through the
normal workflow expression and template syntax. aiClassifyMany output works
with the exact array-membership condition includes.
aiExtractData accepts 1–40 unique fields. Each field declares a name,
description, and type: text, number, boolean, date_time, or enum.
Fields are required by default; text fields may set maxLength, and enum fields
must declare 1–50 choices. Undeclared properties and values that do not match
the field contract fail validation instead of reaching later steps.
The actions do not tag records, send mail, or perform another domain effect. Compose their output with application actions that retain their own authorization and effect budgets.
An opted-in server must also:
- run
migrateWorkflowAi()with its migrations; - start and stop the shared runtime with the application lifecycle;
- apply the application's current authorization before an AI task is created;
- expose a
maxAiCallsrun budget.
The server-only lifecycle exports are available from
@k2b/cloud/workflows/ai.
Choose the workflow model
Workflow model resolution follows this order:
- the action's optional
modelprofile ID; - the
ai.workflow_model_idplatform setting; ai.background_model_id;- the platform default.
The resolved profile ID is pinned when the durable task is created. A later settings change does not alter an in-flight or replayed task.
Understand durable execution
Postgres stores the task request, pinned model, state, attempts, output, and usage. The Sync job carries only the task ID. The workflow parks on a durable dependency and resumes when the task becomes terminal.
An effect key prevents a replay from creating or charging the same task twice. Transient failures retry with bounded backoff for at most three attempts. Canceling the workflow cancels queued work, aborts running inference, and discards a provider result that arrives after cancellation.
Provider calls are at least once around a hard process crash: if the provider completed but the process stopped before Postgres stored the result, recovery may repeat that call. The stored task still exposes only one terminal output to later workflow steps. Missing models and invalid structured output fail the task instead of being retried indefinitely.
An enabled chat model is not a guarantee of valid structured workflow output.
Before publishing, test the selected profile with representative inputs and the
workflow's actual output schema. A successful smoke test proves that request,
not every future response. If a run reports WORKFLOW_AI_INVALID_OUTPUT,
inspect the failed step and its selected model in the run details, check the
output constraints, and test a corrected configuration before rerunning.
Cloud keeps the selected model pinned; it does not silently switch providers.
A dry run reports that AI output is unavailable instead of inventing a value.
It charges one maxAiCalls unit only when that effect does not already have a
durable task.
Prompts, inputs, and outputs are durable application data. Authorize first and send only the fields the task needs.
Run it from durable work
An HTTP request may end before a slow model call does.
For important background work, call runAiStructured() from a
job or queue worker. Pass the job abort
signal and a parent trace.
Keep retries around the whole task. Do not retry a schema failure forever.
Every runAiStructured() attempt writes one metadata-only terminal accounting
record for Admin > AI > AI Usage. The record contains the task and optional
application id, resolved model, duration, usage, structured-output mode and
repair state, attempts, and a bounded error. It never stores the input, prompt,
or output. Domain-owned run tables and traces remain the detailed operational
source. Workflow inference uses this same accounting record, so task records
do not add a second charge. Each retry that calls the model has its own record.
See Observability for pricing coverage and
historical accounting limits. See Usage and feedback
for filters, error details, attribution, and the matching CLI commands.
Use Chat runtime and streaming when the user needs an interactive, stored conversation.
Transcribe an audio file
Use runAiTranscription() from @k2b/cloud/ai in an authorized server-side
service. It uses the configured audio model or an explicit transcription
profile and returns text without starting a conversation.
import { runAiTranscription } from "@k2b/cloud/ai";
const result = await runAiTranscription({
task: "transcribe-memo",
file: authorizedAudioBlob,
filename: "memo.wav",
language: "de",
allowedDataBoundaries: ["private"],
signal,
});
// Persist result.text in the application's own authorized service.The caller owns source authorization, durable input storage, retries, and result persistence. This function makes one provider call with a ten-minute upper timeout and respects the caller's abort signal. Empty text is a valid result when no speech was recognized. It performs no translation, format conversion, splitting, or automatic retry.
Each attempt records model, task, duration, status, and optional attribution in the metadata-only AI usage ledger. Audio and transcripts are excluded, and unknown provider usage or costs remain unknown. See Models and providers for configuration and file limits.
Background costs and emergency stop
Cloud records every actual provider attempt centrally, including structured output repair. Replay of a completed workflow step does not repeat inference; a real retry produces another cost record. Model price snapshots and token measurements determine reference costs. Applications must not calculate or record a second charge.
Workflow AI forwards the run and step. Cloud resolves the definition ID, name,
version, and application from the workflow store. Other internal operations use
honest task names, such as compaction or enrichment, with available chat IDs.
An attribution.stepKey can identify an existing step; metadata never grants
permission. The usage report can group by workflow and link to its runs.
Operators may enable a shared rolling 24-hour background cost stop on
Assistant limits → Rules. A triggered stop rejects new priced background
calls with ai_background_cost_stop; the workflow runtime treats this as a
terminal failure rather than retrying automatically. Calls in progress finish
and remain accounted. After reviewing costs, an administrator explicitly
releases the stop and decides which workflows to retry. Unpriced models are
exempt. This stop is independent of direct Assistant budgets.
See Usage and feedback for the accounting limits, price configuration and CLI operations. Audio transcription remains unpriced because the current provider result contains no billable duration.
Shared text, classification and extraction tasks
AiTaskRequestSchema and its AiTaskRequestInput/AiTaskRequest types are
available from @k2b/cloud/ai and the browser-safe @k2b/cloud/ai/browser.
They describe generate_text, classify, classify_many and extract_data.
Workflow AI actions and Assistant Code Mode use the same schemas and
executeAiTask(request, options) implementation. The executor returns
{ output, usage }; it does not create a workflow or grant domain access.
Server callers supply taskPrefix, appId, signal, and trusted attribution.
For interactive, user-owned calculations, pass the authenticated usageSubject
to charge the personal chat allowance. Without it, runAiStructured keeps its
background-budget behavior. Attribution alone never selects a budget or grants
model access. AiQuotaError exposes allowance failures to server callers.
Authorize the resource before inference. Use selectAssistantAiModelId(subject, requestedModelId) for Assistant model grants and resolveAiModel with
personalAiModelPolicy for the matching data-boundary policy; pass that resolver
through resolveModel. Supply only verified server-side identities, not IDs from
the calculation's input. The calculations receive only the supplied prompt and
input, with no tools or implicit conversation context.
Routine Action reviews
Before each change, a routine checks its mandate first, then calls the app's
review for every Action with approval: "rememberable". An Action that needs
approval and is not rememberable never runs in a routine, regardless of the
review. This includes background Code Mode. A rememberable Action runs only
when that review returns an approvalScope. A missing review, a review error,
or a result without the scope refuses the call and the app's run never
executes. The review only narrows a mandate; it never grants access or widens
the routine's authority.
The model gets a short NEEDS_APPROVAL error telling it to stop and explain
what was missing in one sentence. The run fails and the routine becomes
needs_attention with reason needs_approval and the action name, even if the
model completes its reply normally. One-time routines need a new time rather
than becoming completed. Queries and Actions with approval: "none" keep their
mandate checks and skip this review gate.
Core's routine hosts share the platform-owned authorizeBackgroundAction and
AiBackgroundApprovalRequiredError exports from @k2b/cloud/ai. The gate records
the first refused action on the turn; hosts cannot replace a refusal with a
later successful model reply.
Routine names and stop reasons
A routine can have a short title in the user's language, up to 80 characters.
Its name uses that title, then the chat title, then the first prompt line
(up to 80 characters). Lists, action reviews and notifications use this name.
A stopped routine has one reason_code and a reason_at timestamp. The code
determines its state. A database CHECK enforces this relation.
| Code | State | English sentence |
|---|---|---|
paused |
paused |
Paused by you on {time}. What happens meanwhile is not caught up automatically. |
chat_archived |
paused |
Paused because the chat is archived. |
owner_inactive |
paused |
Paused because the account is inactive. |
failed |
needs_attention |
The last run failed. |
rights_changed |
needs_attention |
What it may do changed. Please confirm again. |
needs_approval |
needs_attention |
Tried to {action}. That needs your approval every time, and routines never ask. |
For needs_approval, reason_action stores the first refused Action's title
in the routine's locale. Dialogs, chat lines, and notifications include it in
the sentence; without an action, only the second sentence appears. English
lowercases an initial title-case word ("Create task" becomes "Tried to create
task."); German titles and acronym starts stay unchanged. Other reason codes
clear reason_action, and readers ignore it beside any other code. The pause time uses reason_at, the reader's locale and
the routine's timezone. chatTaskReasonSentence from @k2b/cloud/ai renders
these sentences in English and German. Browser code imports it from
@k2b/cloud/ai/browser.
Raw errors remain technical details. They are not the reason sentence. A failed run keeps its last model response when available and adds reason metadata. Without a response, the chat line uses the reason sentence.
A needs-attention notification has the title “{name}” needs you and the
reason sentence as its body. Paused routines do not send this notification.
The key routine:{task uuid}:{code}:{reason_at epoch ms} permits one notice
per routine and reason until the state changes. Recovery uses the newest
failed occurrence after the notification definition was registered. It also
recognizes legacy occurrence:{id} keys, so an upgrade does not resend them.
Creation stores the request locale on the routine. Runs, delivered chat lines
and notifications use that locale, then the instance setting app.locale,
then en. Existing routines get the locale of the newest personal chat turn
at or before creation, else the newest personal chat turn in that chat.
Background turns do not supply this value. This backfill runs only when the
locale column is first added. Cloud has no stored per-user locale setting.
Archiving a chat through the AI routes pauses its active routines with
chat_archived. Admission checks what a routine may do before it checks the
archive, so a routine whose mandate was paused or changed keeps paused or
rights_changed instead. Admission also pauses a routine whose chat was
archived another way. Restoring the chat resumes only routines paused with
chat_archived, and only when their mandate revision is still the one the
archive paused. Otherwise they need attention with rights_changed. A
routine that stays paused after the restore, because it is a one-time routine
past its time or because an app session restored the chat, changes to
paused, so it no longer names an archive that has ended. App sessions never
resume background authority. If the restore's routine step fails, the routine
recovery relabels those routines paused after five minutes; it never resumes
them, so the person resumes them by hand.
A one-time routine resumes only before its time. Once its time has passed, a run at that time already happened or was stopped, so it needs a new time first.
Pause and delete request an immediate abort of a running turn. The occurrence
ends as stopped. The chat receives one line:
Stopped while the routine was running. Already done: {list}. Nothing was undone.
With no write call it can name, the line leaves the list out:
Stopped while the routine was running. Nothing was undone.
It does not claim that nothing changed, because a call without wording can still have changed something. The list comes from stored successful calls that the chat words as changes: app Actions with their done sentence, or their title when the Action has none, and Cloud tools with a done sentence. It never comes from the model's prose. Reads, failed calls and rejected calls are excluded. The line's metadata stores the stop cause and the done list. Deleting the routine does not delete this receipt. Recovery can deliver the line once from a terminal deleted-routine turn completed within the last day. It does not deliver into an archived chat.