Chat runtime and streaming
Create personal conversations, save composer drafts, and stream agent work.
Core mounts one authenticated conversation API at /api/ai. Conversations do
not belong to an application and have no primary resource. Assistant renders
the standard GUI; another application may create a conversation and redirect
the user there.
Interactive submissions and retries enforce the caller's Assistant model access. The model list and status return only permitted models. A denied explicit model selection returns HTTP 403 before a retry changes the conversation.
Queue follow-up messages
Add a client-generated UUID queueId to POST /api/ai/conversations/:id/turns
together with the saved draftRevision to queue a follow-up. Acceptance consumes
that draft revision and preserves its text, resource references, model selection,
and exact attachment bytes. Repeating the same queueId is idempotent, including
after the message has started. A later composer draft remains independent.
Each conversation accepts up to 32 pending or failed messages. Queued attachment snapshots share a 250 MiB budget. The existing turn worker processes messages in acceptance order, one active turn per conversation. Queues survive navigation, reloads and worker restarts. Completion, failure or cancellation of a turn allows the next queued message to start; a dispatch failure keeps the failed entry at the head until the user retries or removes it. Approval waits keep the current turn active and do not advance the queue.
The owner-authorized queue API provides:
GET /conversations/:id/queue: ordered pending and failed entries.PATCH /conversations/:id/queue/:messageIdwith{ text }: edit queued text while retaining attachments and resource references.DELETE /conversations/:id/queue/:messageId: remove an entry that has not started.POST /conversations/:id/queue/:messageId/retry: retry a failed dispatch.
All paths above use the /api/ai prefix. Editing or deleting races safely with
promotion: once an entry becomes a turn, the queue endpoint returns not found.
Normal submissions cannot overtake the queue. Chats with pending entries cannot
be archived or marked done and do not become done through inactivity.
Observe work across conversations
Sidebar snapshots include a compact activity projection: completed and total
task counts, the current task, and the last tool label. Cancelled tasks do not
count toward progress. Tool arguments, outputs and conversation history are not
included in this projection. The same owner checks apply to initial and live
reads, and live updates do not change activity-based ordering.
AI live updates invalidate this snapshot on task and tool changes. Text tokens alone do not invalidate the sidebar. A reconnect replays the missed updates, and the browser reloads authorized state before it moves its cursor; no per-conversation socket is required.
Diagnose runtime failures
Use the existing Core log and trace views. Filter logs by ai:runtime,
ai:message-queue, ai:executor, ai:files, or ai:transcription. AI live
updates log under events:live with the application ID core.
Queue dispatch errors carry conversationId, messageId and
queue_dispatch_failed. Heartbeat, recovery and completion-publication warnings
carry conversation and turn IDs. A heartbeat warning is emitted once per failure
streak; recovery uses the existing durable turn state.
Upload storage failures emit file_upload_failed with the conversation ID and
error type. Tool-audit write failures are warnings with turn and call IDs. Expected audio
cancellation is informational; retryable dictation failures are warnings and
terminal failures are errors. Provider/transcription traces retain their existing
status, duration and accounting. Diagnostic metadata does not add prompt,
attachment or audio contents.
Gateway registry notifications are supplemented by reconciliation every five seconds. This repairs stale routing after a missed application restart event. A failed refresh retains the last usable route table and logs its failure.
Create a conversation draft
import { launchAssistant } from "@k2b/cloud/ai/browser";
const launch = await launchAssistant({
launchedByAppId: "mail",
draft: {
content: [
{ type: "text", text: "Help me finish this email." },
{ type: "resource", ref: { type: "mail.draft", id: draftId } },
],
},
preloadTools: [
{ name: "text_editor" },
{ appId: "mail", kind: "query", id: "draft.read" },
{ appId: "mail", kind: "action", id: "draft.update" },
],
files: selectedFiles,
});
window.location.assign(launch.href);The request may preload at most eight tools. A { name } entry selects a Cloud
built-in; an { appId, kind, id } entry selects a live app Query or Action.
Core validates both and stores their exact resolved names so the first turn can
use them without discovery. Preloading is a prompt-budget optimization, not
authorization. Every app invocation still runs as the current user against the
owning application.
Set launchedByAppId to the launching application's stable id when another
application explicitly opens Assistant. Core stores that attribution on the
new conversation for AI usage accounting. Omit it for chats created directly
inside Assistant.
The structured composer draft contains text, exact stored-file versions, and
zero or more Cloud resource refs. Save it with PUT /api/ai/conversations/:id/draft and an expectedRevision. Identical autosaves
are idempotent; stale writes return a conflict. Submit a turn with the returned
draftRevision. Local clients may also send client (kind cli or acp,
optional name, workspace cwd, OS, shell and local tools mode) and
localContext (AGENTS.md instructions, local Skill catalog and explicitly
selected local Skills). localContext without client returns HTTP 400.
Both are stored in run_config, alongside clientToolIds, and preserved by
queued messages. A message retry uses only the client, localContext and
clientToolIds its request carries, so a retry from the web gets no local
client context or local tools; it keeps the original non-local clientToolIds
when the request sends none, and the original selected local Skills when a
local client retries. Background and mandate runs do not
receive local client context. The transaction consumes and clears exactly that revision, so
text and attachments cannot drift between save and send.
Pass skills: ["cloud-grids"] to attach enabled, readable Skills by exact name
when launching a chat. Core resolves them into ordinary core.ai.skill resource
chips with stable IDs and names; no instructions are copied into the draft.
The user can remove a chip before sending. Unavailable or disabled Skills fail
the launch, and linked Skills count toward the normal attachment limit.
For a scoped chat, include load_skill in allowedTools. Assistant loads a
selected Skill through that tool using its ID, with the same permission and
revision checks as loading by name. Linking never widens the tool scope.
For other resource chips, supply title and icon alongside ref to show a
useful name instead of the ID fallback. Do not repeat resource IDs as draft text.
Pass allowedTools: string[] to restrict a launched chat to exact built-in names
or qualified capability IDs such as grids.gql.execute. Omission preserves normal
discovery; an empty array permits no task tools. Preloads must belong to this set.
The ceiling is stored on the conversation, cannot be widened through updates, and
is preserved by forks. Each turn filters static tools, capability discovery and
loaded tools. Search/load/list metadata tools cannot expose excluded operations.
This restricts tools; it does not grant permissions or bypass Action approval.
Preloading alone is not a restriction.
Grids Query with AI uses this for discovery, Help, queries and approved View creation without record or schema changes. Its launch creates an editable draft, not an automatically submitted query.
launchAssistant() uploads browser File values after creating the private
conversation and then stores their returned versions in the same draft. The
JSON create endpoint itself accepts text and resource refs, not unuploaded file
paths.
The default tool source keeps discovery, Help, fetch_file, read_file,
calculate, and view_image available. Configured web_search and
web_extract are also always available together. fetch_file imports one
exact public HTTPS source into the conversation, with a readable Markdown copy
for web pages; it does not browse repositories, authenticate to a website, or
reach private network targets.
calculate stays loaded because, whenever a turn offers it, the system
prompt requires it for every amount, sum, or other number the model derives
itself. A conversation with a fixed tool scope that excludes it, or a model
without tool support, gets neither the tool nor that rule. Surveys, the
long-form text editor, charts, file writes and presentation, and
Markdown-to-PDF load on demand. Built-in usage hints remain in the system prompt even while their
schemas are deferred. These built-ins provide no arbitrary code execution,
host access, or network access beyond the explicit web tools. See
Tools and approvals.
The default tool source also offers the server-run Code Mode tools code_run,
code_action, code_inspect, code_stop, code_export, code_present, and code_check
in every turn, whichever client submits it, including API
clients, cld assistant --detach, and routines (scheduled tasks). They load on demand
and run JavaScript in an isolated Assistant-owned host. That code can call
Cloud capabilities, databases, and external HTTPS endpoints with the user's
permissions; each call keeps its normal approvals or, in a routine, its
routine grants. See
Scheduled Code Mode
and Advertise connected client tools.
The text_editor frontend interaction lets the model provide one complete
plain-text or Markdown draft for the user to revise. The browser presents both
formats in one Markdown editor. Unsubmitted edits are browser-local and may be
lost on reload; only accepted text or submitted revision feedback becomes a
durable turn action. Feedback asks the model for a replacement draft instead of
accepting the current source. Neither result saves a domain resource or
approves a later Capability Action such as updating or sending mail.
An interactive local client may additionally request the built-in local file,
Skill and Bash tools. They are offered only when the turn declares their
names in clientToolIds; browser clients do not opt in. Cloud persists and
streams the calls and validates their results, while the local client owns
execution and path rules. See the local CLI boundary in
Tools and approvals.
Personal conversations expose the compact tool discovery and loading tools.
Live app Queries and Actions additionally require appTools: true, a model
profile with tools support, and a current direct user actor; service-backed
agent identities are not part of this contract.
The shared platform prompt separates platform rules, a short execution loop,
conditional tool guidance, and labeled application context. It tells agents to
use required tools, inspect their results, and continue until the request is
complete or genuinely blocked. Retrieved emails, webpages, user files, Help,
capability results, ordinary tool output, Project context, and memories remain
data rather than instructions. Project instructions and the Project context
manifest are copied into the durable turn configuration. Current Project access
is checked again before execution; edits affect the next turn. The runtime normally
treats a provider stop as a completed turn (see Failed turns
for an empty response after a failed tool call). It does not infer unfinished
work from model text or trigger language-dependent automatic retries.
Conversation titles
A title given at creation, such as the localized "New chat" of the web chat,
stays a default title. The first message's text replaces it, and enrichment may
rename it. Renaming through PATCH /api/ai/conversations/:id marks the title as
the user's (titleSource: "user"). Neither the first message nor enrichment
replaces that title, for web and API clients alike. Renaming to the current title
changes nothing.
The CLI is one consumer of this rule. --title on cld assistant -p and
cld assistant chats create creates the chat and then renames it through this
endpoint. The CLI trims the title and checks the 1 to 120 character limit before
creating anything. The exact title New chat keeps automatic naming.
Chat route groups
| Group | Purpose |
|---|---|
/status, /models |
Read sanitized runtime and model state |
/prefs |
Read or update personalization enablement and learning settings |
/memories, /memories/:id |
Search and manage structured personal facts, preferences, and workflow defaults |
/conversations |
List and create conversations |
/conversations/:id |
Read or manage one conversation |
/conversations/:id/draft |
Optimistically save text, files, and Cloud resources |
/conversations/:id/project |
Choose, change, or clear the current Project between turns |
/conversations/:id/messages/search |
Search visible text inside one owned conversation |
/conversations/:id/messages/:messageId/feedback |
Set or remove private owner feedback for one Assistant message |
/conversations/:id/resources |
List or filter structured Cloud refs observed in one conversation |
/resources |
List or filter structured Cloud refs across the user's active conversations |
/resources/recent |
Up to 8 recently sent Cloud refs of the user, each re-read with its app's reader for this user before any title is returned |
/conversations/:id/turns |
Start, steer, or stop work |
/conversations/:id/stream |
Receive the conversation event feed over SSE |
/conversations/:id/files |
Manage conversation files |
/live |
Receive the user's AI live updates over Core's live socket, channel user |
The router also supports message retry, forks, compaction, pending tool actions, conversation enrichment, and paged history.
Automatic compaction accounts for the configured output-token reserve. When a single user request grows too large, it can summarize completed model rounds inside that first loop. The newest two model rounds stay intact, and a tool call is never archived separately from its pending result. Archived messages remain available in the conversation history.
Compaction streams the summary from the model provider, so the provider's
first-byte and idle timeouts of 60 seconds each apply: the model must start
answering within 60 seconds and must not pause for longer than 60 seconds. A
slow model that keeps answering is not cut off. A timed-out /compact fails
with reason model_unavailable and leaves the context as it was. When the
automatic compaction before a model call times out, the turn continues with the
full context. When the provider already rejected the context as too large, a
failed compaction ends the turn.
For a compact diagnostic of one owned chat, including its ordered tool calls, arguments, results, model profiles, errors, usage, and timing without the duplicated loop transcript, run:
cld assistant chats diagnose <chat-id> --jsonSearch applies ownership, Project, archive, status, and pagination filters before returning visible conversation text. Tool results and model thinking are not user-visible message search results. Structured Cloud resource discovery indexes only schema-valid refs observed in trusted structured values; it does not infer resource identity from prose.
Runtime ownership
Core mounts /api/ai, migrates the AI schema, and exclusively owns the
conversation workers, maintenance, routines, and durable
continuations. Applications must not start a second AI conversation runtime.
They publish domain Capabilities, launch a personal conversation through
launchAssistant(), or use runAiStructured() for bounded server/workflow AI.
Interrupted work resumes from saved state. User approvals and frontend-tool responses remain pending until answered; they are not failed attempts. Repeated execution failures end the turn as failed. Set concurrency for the deployment, not per request.
Stream state
In text output, cld assistant -p and cld assistant turns watch separate
non-blank assistant text blocks with a blank line. Reconnects and interactive
approvals print only new text and do not repeat a tool's unchanged status. JSON
result text uses the same separators. JSONL text_delta events carry each
block's new text once under the block ID that first carried it, without
separators. turn_finished carries the complete text, also when the turn ended
during a reconnect. Tool progress and approval matching use canonical capability
IDs such as spaces.task.create. Persisted provider tool names stay unchanged;
hashed names that cannot be decoded retain their stored name.
Assistant renders capability table presentation metadata in both web chat and text-mode CLI output. The CLI shows up to 100 returned rows, shortens long cells, and prints supplied result links. Additional rows or pages are marked; displaying a table does not fetch more data. JSON and JSONL output retain their structured format and do not include terminal tables. The Assistant summarizes findings and completeness limits instead of repeating rows, unless the user requests them.
The conversation protocol is transport-neutral. Every subscription receives a
full authorized state snapshot and then ordered updates for messages, text,
tools, approvals, provider retries, and turn completion. The runtime captures the
retained-topic cursor before loading the snapshot, so work that arrives while the
snapshot is loading remains in the ordered tail. Attempt and sequence numbers
make replay idempotent. Only turn_finished ends a turn; a low-level client
ignores event types it does not know.
Each execution attempt starts with one atomic, server-ordered block baseline.
Resuming after an approval or frontend-tool response therefore keeps every
existing item in its persisted timeline position while new output is appended.
The conversation stream uses SSE. Use parseAiSse()
from @k2b/cloud/ai/browser for a low-level or CLI client. This
client entry point also exports attachment limits, guessAiMediaType(),
isAiImageMediaType(), the card, chart, survey, text-editor, and local-bash
input schemas, CLOUD_AI_TEXT_EDITOR_MAX_CHARS, the chart limits
CLOUD_AI_CHART_MAX_SERIES and CLOUD_AI_CHART_MAX_VALUES,
parseCloudAiChartInput(), which reads a chart call as one typed chart, and
cloudAiChartTable(), which turns that chart into the rows its data table
shows. These helpers do not initialize
Cloud server services. Import AI types with import type from
@k2b/cloud/ai. Solid applications should use
createAiChatController() from @k2b/cloud/ai/solid; it subscribes to the
visible conversation over SSE. Changing chats closes the previous stream and
opens one for the new chat; every connection, including a reconnect, starts
from a fresh authorized state snapshot. Assistant, the CLI, and low-level
consumers all use /conversations/:id/stream. Over HTTP/1.1 each open
Assistant tab holds one of the browser's six connections to the host; over
HTTP/2 the streams share one connection. A proxy in front of Cloud must not
buffer text/event-stream responses.
An open SSE stream re-checks its credential and the conversation every 5
seconds. A revoked session or API key, an expired account, or a conversation
that is archived or no longer the caller's ends the stream. The next
connection attempt then receives 401, 403, or 404. The controller's default
SSE transport does not retry these statuses. It stops the stream:
streamStatus() becomes idle and error() explains, in the page's language,
that the session ended, that access to the chat ended, or that the chat is no
longer available. The transport reports the status as an AiStreamError code;
the controller chooses the text. Reopening or refreshing the chat, or acting in
it, subscribes again. Other failures keep reconnecting with backoff.
Each attempt numbers its events without holes and starts with
turn_started, and a turn ends with turn_finished before the next one
starts. The worker saves the turn's live state within one second of every
event. The stream checks each event against this order. When an event is
missing, it sends a fresh state as soon as the saved state holds that event
and continues after it, so a reader never applies updates over a hole. An event
can go missing because the snapshot a subscription starts from lags the live
events, because its publish failed, or because it is too large for the live
topic. Events over 257 KiB, such as a tool block with a large web page, travel
only as their position, and the stream delivers them in full through the saved
state. Such a state carries the conversation as it is now, with its current
draft revision and run status; the stream ends once the conversation is
archived or deleted. A turn's end always reaches readers, also when a stop or
the sweep numbers it from a saved state that lags the live events, and also
when the turn ended while the stream waited for its saved state. Then the
state already shows the turn as finished, its turn_finished follows, and
replayed events of that turn are dropped. If the saved state does not catch up
within five seconds, the stream continues with what it has and logs the
warning AI conversation stream continues without an event the saved state does not hold under ai:stream; readers then miss part of the turn until
it ends.
The server closes a stream once its reader has left about 4 MiB unread. The
event that crosses that limit is still queued, and a state snapshot or a
turn_finished event with its stored messages can be larger than one stream
event. The client then reconnects and continues from a fresh state snapshot.
The controller folds the stream into one projection and exposes the active conversation's history, send, steer, abort, retry, fork, compaction, approval, and frontend-tool actions. Do not put conversation and Project lists, metadata, Sources, files, routines, Project context, or access changes into turn events. Those are durable server projections and refresh through Realtime UI.
For the local client tools (local_bash, local_list, local_read,
local_upload, local_download, local_write, local_move, and
load_local_skill), the executor publishes and flushes the request
immediately, then polls for a result every 10 ms for up to half a heartbeat
interval (1.5 seconds in production). A result within that window continues in
the same lease, without another claim or rebuilding the turn, and does not
count as a resumed attempt. Otherwise the turn suspends and enqueues a
continuation as before, including when the result arrives as the wait ends.
Other client tools such as code_open and code_secret, human interactions
such as survey and text_editor, and all approvals suspend immediately.
POST /conversations/:id/turns/:turnId/actions/:callId validates results of
the local client tools against their declared output schema. Invalid output
returns HTTP 400 naming the tool and first issue; the action remains pending.
The route limits the JSON body to two fully escaped 512 KiB Bash streams plus
a 64 KiB envelope. Other client tools keep their existing result contract.
Action responses are idempotent. Retrying the same response is safe and
re-enqueues its continuation; a conflicting response for an already resolved
call is rejected. On reconnect, the state snapshot reconciles durable action
responses before rendering, so resolved approval controls do not reappear and
plain browser tools are not executed again merely because the page reloaded.
A call the user approved keeps approved: true on its tool block, and its
stored result records the decision, as a rejection does, so history can show
the decision after a reload. A call whose approval still waited when its turn
ended carries approved: false in history, so it shows as not run instead of
disappearing.
Do not maintain a second client-side chat state machine.
AI live updates
Core serves AI live updates on the shared
live layer at /api/ai/live, with one
channel, user, and an empty scope. Its data is an AiInvalidation from
@k2b/cloud/ai/live-events: which of the nine AI_INVALIDATION_DOMAINS
changed, and for which conversation or Project. Database triggers on the AI
tables write each update in the transaction of the change, keyed
u:<user ID>, so a rolled-back write publishes nothing. A Project change is
written for every user who can read the Project at that moment, including
nested groups and grants to all signed-in people; a grant change reaches the
readers before and after it. A conversation change reaches only its owner.
A socket follows only its own user's key, and its credential is checked again
every 10 seconds; an ended session closes it with 1008. A third-party UI
subscribes like Assistant and reads the cursor before its snapshot:
// Server, before loading the page's state
import { aiLive } from "@k2b/cloud/ai/live";
const cursor = await aiLive.cursor();
// Browser
import { AiInvalidationSchema } from "@k2b/cloud/ai/live-events";
import { liveConnection } from "@k2b/cloud/browser/live";
liveConnection("/api/ai/live").subscribe("user", {}, {
cursor,
parse: (data) => AiInvalidationSchema.parse(data),
apply: async (events) => reloadViewsFor(events.map((event) => event.data)),
resync: reloadEveryView,
unavailable: showUpdatesPaused,
});The browser reloads each affected view through its authorized HTTP query
before it moves the cursor. A reconnect replays what the tab missed; a cursor
outside the replay window receives resync, which reloads every view. Core's
window holds the latest updates of all users together, so a tab that was away
during much activity reloads instead of replaying.
Project context can be shared through normal Cloud access grants, but each
conversation remains owned by its creator and only appears in that user's
updates and queries.
List the person's open questions
Use listAiOpenQuestions from @k2b/cloud/ai on the server to seed a personal
chat overview. Resolve ownerUserId from the authenticated user; the read
includes only that person's pending approvals in chats they own.
import { listAiOpenQuestions } from "@k2b/cloud/ai";
import { expectUserBackedActor, getLocale, getTimeZone } from "@k2b/cloud/server";
const user = expectUserBackedActor(c);
const questions = await listAiOpenQuestions({
ownerUserId: user.id,
locale: getLocale(c),
timeZone: getTimeZone(c),
});AiOpenQuestions contains at most 20 items, newest first, and a count of
all matching questions. Each AiOpenQuestion carries the approval ID, chat
short ID and current title, tool-block anchor, short plain text, app
presentation, and ISO creation time. The short text uses the review's short
template when provided, otherwise its action sentence, rendered like the card's
chips: person names only, object and place titles without quotes, and formatted
time labels. Legacy reviews and calls without a review keep the card's literal
question text. Missing or unreadable tool blocks fall back to the first line of
the request sentence and a tool anchor; one damaged question does not prevent
the others from loading.
short is limited to 160 Unicode code points and conversationTitle to 200.
Truncated values end with …, included in the limit, without splitting a code
point. These bounds also apply to legacy questions and fallback text.
Assistant carries this read in its sidebar snapshot for both server rendering
and subsequent reloads. Opening, resolving, or removing a question invalidates
conversation-list, including when another question in the same turn remains
open; browser tool calls do not. Reuse that invalidation to reload the
snapshot; no separate polling or question endpoint is needed.
In tests, @k2b/cloud/ai/testing provides demo questions:
aiQuestionFixtures(locale) returns tool blocks with reviews, and
aiOpenQuestionFixture presents one of them as this read would. The demo data
may change in any release, so assert on what your host renders from it.
Treat turns as asynchronous
Starting a turn does not mean it completed. The API returns the persisted turn, then the stream reports progress.
Use the final turn status for completion. Handle failed and aborted
explicitly.
For the UI layer, see Chat interface.
Dictation and draft revisions
Assistant records a complete mono WAV before uploading it. There is no live audio stream, automatic prompt submission, or meeting transcription. Microphone permission and AudioWorklet support are required. Recording stops at the 25,000,000-byte transcription limit; its duration depends on the device sample rate. Before upload confirmation, reloading or closing the tab can lose the recording. Navigation during recording stops and uploads it to its bound chat.
Core owns the Assistant dictation API and worker. These conversation endpoints use the same user-backed authorization as drafts:
Endpoint suffix below /api/ai/conversations/:conversationId |
Operation |
|---|---|
POST /dictations |
Multipart file, client UUID operationId, optional two-letter language |
POST /dictations/discard |
Client UUID operationId; discard even before the start response |
GET /dictations |
Pending items; optional after cursor and limit up to 50 |
GET /dictations/:dictationId |
Status and complete transcript |
POST /dictations/:dictationId/apply |
Local draft content and expectedRevision; append once atomically |
POST /dictations/:dictationId/action |
action: "retry" or action: "discard" |
The start transaction writes one normal conversation file and a private input snapshot. Both count toward the conversation storage quota.
File metadata includes dictationRecordedAt, separate from the
user origin used for write protection. Renames, conversation copies, forks,
and turn snapshots preserve it. Replacing file contents clears this provenance.
Ordinary audio uploads never receive it. The turn manifest and list_files
identify these recordings as prompt dictation, while keeping the audio readable.
conversationFileSource().listFiles() exposes the original file metadata for
application-owned grouping without changing file paths.
The operation ID is bound to user, chat, bytes, and options. Repeating it with different input returns a conflict; repeating identical input returns the same dictation even if the configured default model changed. One pending dictation per chat is allowed. Results use public short IDs; operation IDs are client correlation.
The worker uses the pinned profile ID and rechecks model and Project access. Two concurrent jobs share the Core runtime lifecycle. Postgres leases fence stale workers; a sweep every 15 seconds recovers missing queue delivery and expired claims. Transient provider failures have at most three automatic attempts. Input snapshots remain available for explicit retries after failure and are released after success or discard. A crash around provider completion can repeat the provider request and its charge.
AI live updates carry conversation-dictations invalidations, not audio or
transcript content. A database trigger creates the outbox entry
in the same transaction as each status change. Initial loading, reconnect, and
returning to a chat reload pending dictations through HTTP. A completed upload
also refreshes that query to cover completion before subscription.
Each local composer tracks the revision of the draft actually displayed and a
separate edit generation. Controller saveDraft() and send() accept explicit
conversationId and expectedDraftRevision for that binding. A remote refresh
cannot advance the base of dirty local text. Safe unchanged sessions can append
a finished dictation automatically; changes, navigation, or reload require an
explicit Insert. Apply checks the revision and marks the dictation applied
in the same transaction. Typing during apply preserves local text and exposes
the saved draft for an explicit decision. Conflicts never retry with an unseen
newer revision.
This worker is specific to Assistant conversations. Other applications call
runAiTranscription() from their own authorized service or durable worker and
own their own inputs and results.
Dictation clients can discard an upload before receiving its job ID with
POST /api/ai/conversations/:conversationId/dictations/discard and
{ operationId }. Core serializes this with start and apply, retaining a
conversation-owned cancellation marker so a delayed upload cannot recreate
the job. Repeated discard is safe. An already applied draft is preserved;
the response reports applied or discarded. Normal chat files are retained.
Transcription attempts use AI traces and the structured-run ledger. Failed attempts
record a safe error message, a stable error code, and the trace ID. Provider HTTP
failures include the status and a diagnostic hint without retaining response bodies,
audio, or transcripts in logs. Filter Logs by ai:transcription or ai:dictations;
dictation worker entries also include the dictation ID, model profile, attempt,
and retry decision. Dictations are not workflow runs.
Assistant routines
A routine belongs to one chat and runs as that chat's owner. Creating
it stores a confirmed mandate whose
grants list names every capability the routine may call. In a background run,
every app capability needs a matching grant, Queries included, such as the
reader Query behind a Project reference. A routine without grants can use only
built-in tools such as chat files. Fixed input values must
match exactly, and the owner's current access still applies to each call.
A background run cannot ask for approval. Remembered approvals from the chat
do not apply, and an operation that needs an approval or a browser fails the
run, which moves the routine to needs_attention. A display-only clientView()
tool such as chart needs neither: Cloud answers it, and the run's transcript
shows it. Action grants are accepted only
for Actions whose manifest declares approval: "rememberable" or "none";
Actions that ask for every call cannot be scheduled. Before each run, Core
checks that the owner is active, the chat is not archived, and the mandate is
confirmed and unchanged.
Finished routine runs and their run evidence expire after 90 days; the result messages delivered into the chat stay.
Scheduled Code Mode
Scheduled turns can run Code Mode without a user tab. Core binds each call and capability callback to the persisted turn and its confirmed routine mandate. Foreground activity in the same chat does not share the scheduled host. Routine revocation, expiry, revision changes and cancellation deny subsequent requests. The normal capability dispatcher still checks current user access, fixed inputs and action approval policy; remembered foreground approvals never authorize a scheduled action. Always-approval actions remain unavailable.
Scheduled code supports computation, chat files, AI helpers, presentations and
exports, shared app storage, HTTP and RSQL. A task has one reviewed grants list:
capability query/action entries, {kind:"http",fixedInput:{origin,url,method}}
and {kind:"database",fixedInput:{resourceId,operation,table}}. All fixed fields
are optional; omitted fields remain unrestricted. HTTP origins match exactly,
not by suffix; URL restrictions match the full URL. Database operations match
exactly. Flat cloud.db operations list, get, insert, update, and delete match grants rows.list, rows.get, rows.insert, rows.update, and rows.delete; query matches query.
Runtime cloud.db calls need no connect grant; code_database
tables.create provisions the database under its tables.create grant. Resource
permissions and the HTTP service's public-HTTPS/secret-binding checks still apply.
HTTP authority is rechecked against the stored request immediately before sending.
The same access review and task-detail presentation show all three grant types.
Tasks cannot open interactive dialogs, collect new secrets or use capability binary streams. Missing grants return an error for repair in the normal chat. AI helper usage is charged as background work and attributed to the scheduled turn.
Background Assistant tabs
Hiding the browser tab does not close the visible conversation's SSE stream, so a turn keeps streaming and its frontend tools keep running. The live socket closes while the tab is hidden and replays the missed updates when it returns. This does not override browser suspension, operating-system sleep, reload, or closing the tab. Agent Code Mode execution uses an Assistant-owned isolated Chromium host, so changing or closing the user tab does not pause code or simulated UI actions. The interactive Studio preview remains browser-owned. Opening an app, selecting local files, entering secrets and answering approval prompts still need a user client.
When code in that host calls a capability Action or sends an external HTTP request, the chat approval names the app and Action, repeats the owning app's review in the reader's language, and lists each reviewed value as its own row. An Action without a review lists its input fields. The approval never shows the raw request object, and secret references appear only by name.
Code Mode source and runtime tools do not require confirmation. Source Actions
retain permission checks and idempotency. Browser execution claims resolve a
public short turn ID to the authorized active turn's UUID before persistence.
A duplicate claim cannot repeat an interaction. A call that does not complete
returns CodeToolFailure ({failed: true, error, guidance?}), and the
server-run code tools report it to the agent as a tool error. Host failures
carry guidance to report them rather than rewrite otherwise valid application
source or repeat unchanged calls.
The execution host and its approval waits belong to one Assistant process. Keep one Assistant replica for now: a poll or an approval that reaches another replica marks the call lost, and temporary JavaScript state is not transferred between replicas or recovered after a process restart. This limit belongs to the Code Mode host work in #47. Durable call records prevent an uncertain operation from being replayed. A new run is required after host loss. Startup and health checks are bounded independently of the user's browser timers.
Complete active-turn snapshots replace the observed block baseline when their attempt and sequence are current. Older snapshots cannot restore obsolete streaming block IDs. Pending local steering remains visible until acknowledged. In-attempt continuations publish a complete turn baseline before incremental updates, so persisted and streamed representations do not appear together.
Conversation multipart uploads accept an optional directory form field
(default /). The normal path validation and reserved Project namespace apply.
Collisions receive a suffix; use the returned file.path rather than deriving
a path from the original filename.
Chat timing uses the durable user turn's wall-clock interval. Each model request
records its generation interval before tool execution; these measurements survive
client actions and executor resumption. Tool execution and approval/browser waits
use their durable audit timestamps: an approval or an answer waits until the user
gives it, a secret prompt included, and a tool the browser runs by itself waits
only until it starts. Overlapping phases are counted once. An older or
incomplete trace has no aggregate timing or token-rate estimate; the chat can
still display elapsed time from persisted messages. The chat shows a turn's work
time: wall time minus time spent waiting for approvals and other user actions.
While a turn runs, its state snapshot carries actionWaitMs, the time already
spent on answered waits, and waitingSince, the start of the wait that is still
open, by the same rules, so a client that reconnects continues the same clock
and the finished turn shows the same value. Older servers omit both fields.
A turn that is finished without a loop end of its own, such as a stop while an
approval waits or a turn the sweep finalizes, records aborted after a stop,
or error when it failed or its wait expired, as loopDoneReason on its last
assistant message. A failure also replaces the aborted that a loop cut off by
its run time limit records, so the limit never looks like a user stop. History
then shows the turn as stopped or interrupted, and a call that never ran as not
run. A call the user approved that never returned keeps its decision as
toolOutcomes on the message that holds the call, so history still shows its
receipt; a call whose approval nobody answered records expired there. A
custom approval, which code requests while its call runs, counts for that call,
and one approved request outweighs a later one that expired. Live
message_saved events carry usage after each model response, before its tools
finish, without rendering a second copy of the active response.
Failed turns
When a model sends a tool call that Cloud cannot use, for example arguments
that are not valid JSON, the chat keeps it as a failed tool step. Its error
result asks the model to re-send the call with its exact tool name and complete,
valid, properly escaped JSON arguments. The step appears when Cloud saves the
response, after the other steps of that response, and stays in the same place
after reload and in history. A provider failure keeps a partial response that
has content, and its failed step, in history. A response that is never saved,
for example an attempt that exceeded the context window and that compaction
retries, or an empty response after a provider failure, shows no such step.
Cloud never runs a malformed call, also
when a run stopped before it saved the error result. Cloud allows at most two
repairs per turn, also across approvals, resumes, and compaction. If the model
sends another malformed call, the turn fails with reason failed and keeps the
completed steps. Repairs do not count as tool rounds, do not acknowledge user
steering, and do not reset its loop checks.
Truncated streams and provider failures end with loop_end reason error.
A provider that stops an answer itself ends the turn as failed with
provider_stopped: a gateway error, malformed call, content filter or refusal
can cause this, and Cloud cannot tell them apart. Its tool calls are never
executed, including on a later resume, so the chat offers Continue.
A loop that ends normally but whose final response has no text and no tool call
right after a round with a failed tool call fails with tool_failed;
rejected approvals do not count as failures. Worker recovery restores this
state from the latest stored assistant round, including repaired malformed
calls. A later text answer still completes the turn.
Each model turn_start has one turn_end, including errors and cancellation.
turn_end carries the partial assistant message with stopReason: "error" or
"interrupted" when generation failed or was stopped. It does not mean that
an answer succeeded or its tool calls ran. Partial content stays in history,
except for a context-overflow attempt that compaction may retry. A failed or
interrupted compaction response never replaces the existing context. The chat shows
failed or interrupted content accordingly and keeps calls that never ran as
not run. Leaving the loop iteration early aborts the loop.
A failed turn records why as meta.turnError on the last message of its loop,
which is the user's own message when the model never answered. The code is
stable and never translated; clients word it in their reader's language:
code |
Meaning | A new message can continue |
|---|---|---|
provider_stopped |
The provider ended the answer early, for example a gateway error, malformed tool call, content filter or refusal | yes |
tool_failed |
A tool call failed and the model's next response ended without text or a tool call | yes |
model_unavailable |
The model service failed or timed out, also after its retries | yes |
quota_exhausted |
The user's AI usage limit for the period is used up, or its usage could not be measured | no |
context_full |
The chat no longer fits the model, also after compaction | no |
time_limit |
The turn's run time limit ended it; limitMinutes names the limit |
yes |
step_limit |
The model profile's maxToolRounds is used up, or the model kept calling tools after it was asked to answer |
yes |
wait_expired |
An approval or an answer did not arrive before the wait's deadline | yes |
interrupted |
Workers lost the turn repeatedly and it used up its recovery attempts | yes |
not_allowed |
The chat may no longer use its model, project, apps, or background mandate, AI settings offer no usable model, or the background AI budget stopped the run | no |
failed |
Any other cause | yes |
AiTurn.error, AiConversation.runError, and the error of turn_finished
hold the worded reason for readers without the chat view, such as cld: in the
turn's language, or in English when the sweep finalized the turn. When a new
message can continue, it adds that the results so far are kept and that a new
message continues the task; a failed compaction holds the reason only. A
background run that its mandate or the background AI budget blocked keeps
Cloud's own explanation there. AiConversation.runTurnId is the public ID of
the latest turn, which runStatus and runError describe; the messages of that
turn carry it as loopId. The raw cause, such as the provider's message,
goes only to the error log entry AI turn failed under ai:executor, beside
the code, and a provider call's message also stays on its
usage record. A stop records
no reason.
Provider history preserves each complete assistant message, including its
producing provider and reasoning blocks' signature, redacted, and details.
Those fields remain unchanged through tool-result repair and in the recent
context retained by compaction. Display and enrichment projections do not
rewrite that history.
This fidelity does not make every replay valid: Cloud rebuilds the system prompt (date, time, plan and memory) and tool list for each turn, and compaction replaces older turns with a summary. A provider that binds replayed reasoning to an unchanged conversation prefix, such as current Anthropic models on newer accounts, can reject a later request.
The next turn of the chat sees the failed turn's finished steps in its context. A turn that ended while a call ran or waited for an approval leaves that call without a result. Each model request answers such a call with an error result saying it did not return and may or may not have run, right after the message that made it; stored history keeps the call as not run. A result stored after a scheduled result that arrived while its call ran moves up next to its call in the request. Providers reject a history with an unanswered call or a result apart from its call, so before, the chat's next turn failed.
Run time budget
Administrators configure ai.turn_timeout_minutes in AI settings. The default
is 30 minutes; zero or clearing the field disables this run time limit. Values
must be nonnegative whole minutes. The worker snapshots the budget when it
first claims a turn. Later setting changes do not alter a claimed turn, including
lease recovery. Resuming after a human interaction uses that same configured
budget for its new running phase. Individual provider and tool timeouts and
worker leases remain independent, even with an unlimited turn budget.
The last tenth of the run time is kept for the answer: a model call that starts in it gets no tools and is asked to answer with what the turn has done, to say what is still open, and to tell the user that a new message continues the task. With the default of 30 minutes, tool use ends after 27 minutes. The turn then completes normally with that answer.
An expired execution deadline still ends the turn as failed with the reason
time_limit, for example when a single model or tool call outlasts the
reserve; see Failed turns. It is distinct from a user's
Stop action. Continuing does not automatically replay uncertain external calls.
Loops within a turn
A chat turn, including one that runs scheduled or in the background, watches for two patterns that rarely lead anywhere:
- the same tool called with the same input fails twice;
- six calls in a row to
search_tools,list_apps, orload_toolswithout a working step that completed.
The first time a pattern appears, the next model call gets a short hint to change its approach or tell the user what blocks it. When either pattern appears again after its hint, also with another tool or input, the next model call gets no tools and is asked to answer with what the turn has, say what blocked it, and what the user can do. The turn then completes with that answer. A rejected approval is the user's decision and does not count as a failure. After the search hint, loading a tool that a search found is still allowed; another search, or a load that adds no tool, continues the pattern.
The counts belong to the turn since its start or its latest steering message.
When a turn resumes after an approval or on another worker, its finished calls
count again, including rounds that compaction archived, but a hint given
before is not remembered, so the pattern gets one more hint. A steering
message starts these checks over: the turn continues with tools, also after a
loop's final answer, unless it is in the last tenth of its run time or has
used up the model profile's maxToolRounds.
The model profile's maxToolRounds ends tool use the same way; see
Models and providers. If the model still
calls a tool in a model call without tools, the turn ends as failed with the
reason step_limit instead of continuing without them. Each hint logs the warning AI turn got a loop hint
and each switch to the final answer logs AI turn answers without further tools, both under ai:executor with the conversation and turn IDs.
Transient provider failures
A chat turn, including one that runs scheduled or in the background, repeats a
model call that failed before the model produced any output because of HTTP
408, 409, 425, 429 or 5xx, a lost connection, or a provider first-byte or idle
timeout. It retries at most twice. The wait follows the provider's
retry-after-ms or Retry-After header, otherwise 1 and then 4 seconds. A
requested wait over 60 seconds, or one that would end after the turn's run time
limit, is not started; the turn then fails as model_unavailable. Stop ends a
wait at once.
A failure after the call streamed text, reasoning, or a tool call still ends the turn, because streamed output cannot be taken back. Context overflow goes to compaction, and other errors fail the turn without a retry. Each attempt is a separate provider call with its own admission, quota check and usage record.
While a call waits, the stream sends provider_retry. The controller marks the
active turn with providerRetry: true until its next event. A state snapshot
never carries it; the controller keeps the mark through a snapshot at the
retry's own position, so subscribing again during a long wait does not end it.
Meanwhile the work line of the live turn in the chat timeline
of @k2b/cloud/ai/ui reads Reconnecting and its clock stands; a turn
without a work line yet shows a Reconnecting row at its end. cld assistant
prints model: reconnecting
to standard error or writes a provider_retry JSONL line. An assistant CLI
plugin from an earlier release stops following the turn at this event; see
Conversation streams announce provider retries.
Each retry logs the warning AI provider call retried under ai:executor with
the retry number, the wait, and the failure kind and message.
Conversation completion
AiConversation.done is a nullable override: true means finished, false
keeps the chat active, and null uses automatic completion after seven days
without use. isDone exposes the effective result; lastUsedAt tracks opening,
reading, new turns, and delivered messages independently from metadata updates.
Queued, running, and waiting turns prevent automatic completion.
PUT /api/ai/conversations/:id/done accepts { "done": true },
{ "done": false }, or { "done": null }. Only the owner can change it;
explicit completion while a turn is active returns 409. New turns clear an
explicit finished choice to automatic, but preserve explicit active choices.
Completion preserves history, files, access, and pinning; it is separate from
archiving. The migration preserves previously finished chats as true.
List and page filters done=true|false use the effective state. Omitting the
filter includes both states. The sidebar returns all active chats without a
per-project or pinned cap; finished chats remain paginated. Time-based state is
recomputed on reads without a background job or destructive archival.
aiConversations.getLatestTurn reads the newest turn for an already-authorized
conversation, including its model profile; it does not authorize access itself.
Pinned chats always remain active, overriding explicit or automatic completion. Unpinning reveals the underlying completion choice again. The sidebar hides the Done action for pinned chats; pinning and unpinning are available directly in the chat preview.
Optional Assistant quotas
Direct interactive Assistant submissions check optional cost allowances before
consuming the draft or promoting a queued message. Each subsequent direct model
round checks again. API rejection uses HTTP 429 with quota_exhausted or
quota_usage_unknown; model access is still checked independently. A blocked
queue head retains its content and attachments for later dispatch. Already
running provider calls may finish and exceed the allowance.
The feature is disabled by default and does not limit generic application AI, workflow or background inference. See Assistant limits for accounting, wildcard precedence and reset behavior.
HTML app self-tests
code_check is a server-managed tool available to web, CLI, API and background
turns. Write main-flow steps in the app's steps.json, check, inspect every
screenshot with view_image, and fix before showing or publishing. The agent's
open/present/publish operations require a passing check for the current source,
steps and table definitions in that user/conversation. Human Studio publication
and scripts have no check gate. The self-test is not a security mechanism.
Desktop and phone run a saved app on separate disposable copies of its data; one-off apps have no storage, as in their chat card. AI runs for real; HTTP and capability effects are unavailable, and background checks reject all capabilities. See HTML app checks for step matching, copy/output budgets, cancellation and the report contract.