Cloud Chat is the aux4/platform app for direct messages and group channels between aux4 users. The interface contains only people, direct messages, and channels. Agent participation as regular users is planned for a later integration; the old agent screens have been removed. Existing agent conversation data is retained.
Cloud Chat itself is a hosted Cloud package. Deploying it creates a dedicated, package-configured VM named chat; it cannot be downloaded locally or added to an existing VM.
The following agent transport and storage documentation describes retained backend compatibility commands, not the current chat UI. Regular user messages use the membership-protected chat storage described below.
There is exactly one canonical transcript and model-context store per conversation, and it is not on this machine: the agent VM owns it (kb-agent-vm's ask --history <file>, via aux4/ai-agent). Cloud Chat never writes that file directly — it calls the agent's own return-history / destroy-history / import-history commands over aux4 cloud.
CHAT_DB stores navigation/workflow metadata plus a rebuildable, sanitized historyCache display snapshot. The cache lets the browser render existing messages immediately without cold-starting the agent VM; it is not fed back to the model and is replaced after a successful canonical history read.
Cloud Chat also provides additive shared rooms independent of agent transcripts. Authenticated aux4 users join the available-user directory on first sign-in to this chat. Add user records a pending exact-email addition; it becomes active when the matching aux4 account signs in. This does not create an aux4 account or send an email. No global user directory is exposed.
Choose a person to open a private direct message, or use New channel to select multiple users. A pair of users always opens the same DM. Channel creators can add and remove members from Members; DMs remain two-person conversations. The conversation list becomes a drawer on mobile. Open conversations refresh automatically every three seconds, and messages persist across reloads.
All identities come from the authenticated principal's stable sub/id. Browser sender fields are ignored, membership is enforced on each read and send, and a retry with the same client message id cannot overwrite or duplicate a message. Messages are separate append-only rows; concurrent sends do not replace a shared transcript. Participants carry participantType: user, leaving room for future agent identities; sending to agents is still handled by the existing agent UI.
Shared chat uses an additive chat_entry table in CHAT_DB for user, room, message and pending-invitation records. It does not alter the existing agent conversation table or agent transcripts. The table is created on first use; production rollout therefore requires schema approval. Local SQL uses aux4/db-sqlite; hosted SQL uses the existing parameterized Hrana transport. Only isolated test databases have been initialized during this change.
The following example describes a legacy agent conversation's metadata:
{
"id": "712d9986-…",
"owner": "you@example.com",
"agent": "kb-agent",
"title": "What is aux4?",
"createdAt": "2026-09-14T21:45:49Z",
"updatedAt": "2026-09-14T21:46:04Z",
"pending": null
}
While a turn is in flight, pending holds the agent's label, a submission timestamp, and the submitted question — transient coordination state, never the answer or a message array. It is set only after Cloud returns an explicit asynchronous Lambda acceptance receipt, and cleared the moment a poll confirms the agent's own transcript shows it resolved. Conversation lists still show title and date, not a duplicated message preview.
Existing conversations from before this cutover. Old transcripts lived in a local file on the co-located machine (SFA-178). On first read of such a conversation, this package makes a one-time, idempotent, best-effort attempt to seed the agent VM's own store from whichever legacy source still exists — a raw messages array still embedded in the Turso row (always available), or the old local agent-host history file (only if agent-host happens to still be installed; it is no longer a dependency of this package). This is a real, known gap, not a silent one: a conversation never opened again before agent-host is actually removed loses its pre-cutover history permanently — there is no bulk/offline migration.
ask posts the turn to Cloud's run endpoint with Prefer: respond-async.Cloud invokes the agent Lambda as an AWS Event and returns 202 only after AWS accepts the command. AGENT_CHAT_ASK_TIMEOUT_MS (default 5000ms) bounds this handoff, not the model turn.
202 moves the bubble from Sending… to Thinking… andwrites chat-owned pending metadata. A timeout or rejected handoff stays out of pending and becomes immediately retryable, so the UI cannot wait forever for a turn that the agent never received.
projection and a random, one-use turn capability. Only the capability digest is stored in Turso, allowing the VM principal to complete the browser user's exact pending turn without weakening owner checks. The app atomically refreshes the Turso display snapshot, consumes the capability, and clears the exact pending message; the browser's POST poll-answer is a bounded long poll that returns as soon as the webhook changes that cache. While a turn is fresh, completion checks never call the stateful agent VM and therefore cannot contend with the ask invocation. If the bounded request expires without a callback, the browser reconnects while the turn remains pending. An older agent or a lost callback falls back to one canonical history read after a bounded grace period. The conversation page does not open a long-lived event stream through the shared command worker, because that would serialize every other chat action behind the stream until the gateway timeout.
user message in metadata and renders a bounded error explanation. It never writes to the agent transcript because that VM did not accept the turn.
The chat generates an id for every message it sends (messageId) and forwards it in the turn's stdin envelope. The agent VM stores it on the transcript's user message with a status (pending, answered, failed) and never starts a second turn for an id it already has — so a retry of the same message reuses the id and never produces a duplicate bubble or a duplicate answer. The message appears in the thread the moment it is sent, marked Sending… until the agent lists it; a message the agent reports as failed (or a turn that died) shows Not answered · Retry.
When a long conversation is compacted, the agent keeps the full earlier history as an archive. The summary itself is never shown as a message: the thread shows a quiet Earlier messages were summarized divider where it applies, and scrolling to the top (or pressing Load earlier messages) loads the earlier messages chunk by chunk until there is nothing older. Conversations from before either feature render as they always did.
There is no second transcript writer. Agent completion callbacks copy only a bounded, sanitized display projection into Turso; the agent VM remains the sole canonical model-context writer. A bounded polling fallback repairs missed callbacks. Private, non-writing tombstones retain those retired command names because older aux4 indexes may keep removed commands until they are rebuilt; either name now returns a retired-action error and cannot mutate metadata or history.
agents queries the control plane's deployments table directly — GET /v1/{scope}/deployments, filtered to status=ACTIVE AND product=agent — instead of introspecting a co-located command tree (which could only ever see the one machine it ran on). Auth is aux4 aux4 token, the same mechanism aux4/cloud itself already uses for every authenticated request. A deployed agent's id is its machine name. Commands run directly against that machine: aux4 cloud <machine> --scope <scope> <command> ....
One legacy id is mapped explicitly: the co-located plugin's old id kb resolves to the real deployed machine kb-agent, so conversation rows written before this cutover keep working without their own data migration.
All request-derived values enter the Node handler through aux4 value()/values(). The handler invokes aux4, aux4 cloud (for ask / return-history / destroy-history / import-history / discovery), and repository with explicit argv arrays and piped stdin — never a composed shell command. The package ships no .sh files. Owner checks happen before history access, submission, rename, or deletion. Raw diagnostics stay on stderr; the browser receives stable, short error messages.
Assistant transcript messages may include a typed artifacts array alongside their Markdown content:
{
"id": "local/interactive-list-grocery-1",
"kind": "aux4.app",
"version": 1,
"presentation": "inline",
"ref": "builder://local/interactive-list-grocery-1",
"revision": 1,
"title": "Groceries",
"schema": { "type": "List" },
"state": { "items": [] }
}
Cloud Chat preserves supported v1 envelopes on the canonical messages value, so pending-to-final replacement and every history reload feed the same artifact identity and state to form-engine Chat. No separate artifact store or UI field exists in Cloud Chat. When Chat reports an isolated state change, the artifact-update action verifies conversation ownership and forwards the full state plus expected revision to the owning agent VM. That VM updates its canonical user artifact and transcript snapshot, so checkbox, text, add/remove, and reorder edits survive refresh. Markdown-only transcripts remain unchanged.
When an artifact declares a scoped runtime action, Chat sends only that artifact's id, ref, revision, action suffix, state, and conversation id to artifact-action. Cloud Chat verifies ownership and forwards the request to the owning agent VM. The returned state fields are merged into the isolated artifact; unrelated action namespaces and arbitrary commands fail closed.
The backend accepts only kind: "aux4.app", version: 1, and presentation: "inline"; limits each message to eight artifacts; bounds ids, references, titles, schemas, and state; and strips envelopes containing raw executable HTML/JavaScript. Unknown envelope fields are not forwarded. The form-engine renderer is a second boundary and scopes artifact actions to their explicit artifact namespace.
Artifact ids may use slash-delimited namespaces such as local/interactive-list-grocery-1. They are limited to 128 bytes and ASCII letters, numbers, ., _, -, and /; leading or trailing slashes, //, and .. are rejected.
When the builder cannot wire a generated artifact's action deterministically, the turn's reply carries a builder field with status: "needs-decision" and a ranked list of candidate commands. Cloud Chat flattens the latest turn's first open decision into pendingDecision — a list of options, each already carrying its own decision id — and renders them as a row of buttons above the composer while that reply is the latest one. Picking an option calls decide, which re-invokes the exact same question that raised the decision with the caller's pick attached; the builder resumes deterministically (it never auto-picks on its own) and the regenerated, wired artifact arrives as the conversation's next turn, same as any other reply. A stale or unknown decision/value pair — the conversation moved on since the buttons were rendered — is rejected rather than silently answered. pendingDecision never shows while a turn is in flight, so it cannot overlap the "Thinking…" state.
/ and /chat open the shared chat workspace. The sidebar provides searchable conversations with last-message previews, recent-activity ordering, and the selected chat./people starts a direct message from the searchable aux4 user directory./chat/:id keeps the conversation header and composer fixed, with a Chats drawer on phones./invite adds users by email without leaving the workspace.creator, direct-message links, and a polling room view for messages from other members. Room reads and sends remain membership-protected server actions.
/new-channel creates a room from available users, and /chat/:id/membersmanages membership for authorized channel owners.
The conversation page uses the form-engine Chat, SplitView, and Poll components. The message feed is the scrolling region, while the header and composer remain fixed.
Deploy Cloud Chat from aux4 Hub. The package creates its own chat VM with a fixed MD size, 1 GB runtime disk, and API runtime. The package controls the VM's package list and webhook configuration, while the scope owner may delete the VM.
The deployment includes pinned aux4/platform, aux4/cloud, and aux4/repository dependencies. Its conversation metadata is stateful and is preserved across package upgrades. The trusted package policy declares aux4/app-agent-chat as its predecessor so an existing VM can migrate without moving or deleting its state.
The pinned platform loads form-engine 0.7.11, including first-class state-backed Checklist and GroupedChecklist components. Flat and grouped inline checklist edits use the same owner-checked, revisioned artifact persistence path as other supported embedded controls.
| Command | Purpose | |---|---| | meta | Return app name and icon | | ui | Return form-engine routes | | agents / agents-status | Discover deployed agents and expose a resilient UI state | | agent-get | Resolve an agent from the discovery list | | agent-open | Open a URL-addressed agent's latest conversation without rediscovery | | conversations | List the newest owner/agent-scoped metadata rows directly from Turso | | conversation-get | Load one conversation's header metadata without contacting the agent VM | | conversation-new | Create an empty metadata record | | history | Render the cached Turso transcript projection immediately, or mark it for deferred loading | | history-sync | Fetch canonical agent-VM history in a retrying background Poll and refresh Turso's display cache | | history-older | Read an older (summarized) chunk by the before cursor, for scroll-up | | ask | Submit a turn to the canonical agent transcript and mark the display cache for refresh | | poll-answer | Read completion state for a turn still running on the agent VM | | conversation-rename | Patch title metadata | | conversation-delete | Delete the agent's transcript and this metadata row, then select/create the next thread | | transcribe | Stateless PHRASE-mode transcription for the Chat composer's mic button (aux4 whisper transcribe, provider openai) |
The private poll, poll-ack, and receive-answer compatibility names are inert.
Hosted conversation lists use Turso's HTTP pipeline directly, filter by owner and agent in SQL, and are capped by AGENT_CHAT_CONVERSATION_LIMIT (100 by default). The first hosted list request creates a best-effort expression index over owner, agent, and update time. The workspace mounts its sidebar list, metadata header, and agent transcript as independent loaders. A cache miss returns the conversation shell immediately; history-sync retries the remote read independently and warms Turso, so a slow or cold agent can no longer fail the route loader.
The Chat composer's mic button (voice: true, voiceAction: "transcribe" on the Chat component in app.json) is a stateless, one-shot action, not a long-lived session — the cloud api machine gives every request its own short-lived instance, so there is nowhere for a streaming transcription process to persist between an SSE subscriber and the process writing to it. The browser records one short phrase, base64-encodes a 16kHz mono 16-bit WAV, and sends it through the same action mechanism as onSend: {audio, format, sampleRate, language} in, {text} out. The transcribe command decodes the audio to a temp file, runs aux4 whisper transcribe once (provider openai, model whisper-1), deletes the temp file, and returns the plain transcript. A recording over ~1MB decoded is rejected with a clear error, and so is a whisper failure — both surface as {"error": "..."}. The OPENAI_API_KEY is read from the host environment and never appears on a command line, in a log, or in this repository.
The action also declares rateLimit: { max: 20, timeWindow: 60000 } in app.json, so the platform host caps calls to this (billed) endpoint at 20/min instead of leaving it unbounded. Note what that limit actually is: it is one counter shared by every caller of the route, not a per-user quota, and it lives in the host process's memory, so it resets on a cold start or a second instance — see aux4/platform's README for the exact shape.
The optional language field carries the browser's own locale (the first subtag of navigator.language, e.g. en, pt) so Whisper does not have to guess the spoken language on a short phrase. It is validated against a strict ^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$ pattern before being forwarded as aux4 whisper transcribe --language; anything malformed is silently ignored and falls back to auto-detect, never an error.
A transcript that exactly matches a known silence/noise hallucination (Whisper "transcribing" quiet or noisy audio as closed-caption boilerplate it saw in training — "thank you", "thanks for watching", "bye", Korean/Japanese end-of-broadcast captions, and a few others) is dropped and returned as {"text": ""} instead, but only when the decoded clip is a canonical WAV shorter than ~2 seconds (read from the WAV header). The same words spoken for real in a longer clip are returned as-is.
The audio never travels on this command's own argv. A base64-encoded WAV crosses Linux's single-argv-string ceiling (128 KiB, MAX_ARG_STRLEN) after only a few seconds of recording — far under the 1MB decoded cap above, and a perfectly normal phrase length. Carrying it as a command-line argument would crash the invocation outright instead of returning a clean error. The transcribe command's execute entry instead uses the stdin: executor: aux4/api already writes the full request event to the invoked CLI process's stdin on every call, and stdin: forwards that straight through, so the transcription script reads {audio, format, sampleRate} off that stream rather than off a flag.
| Variable | Default | Purpose | |---|---|---| | CHAT_DB | ~/.aux4.config/apps/agent-chat/chat.db | Metadata repository; the legacy path is retained so existing conversations survive the rename | | AGENT_CHAT_CONVERSATION_LIMIT | 100 | Maximum owner/agent-scoped metadata rows returned to the conversation sidebar | | AUX4_CLOUD_SCOPE | aux4 | Scope passed to aux4 cloud for both discovery and per-agent calls | | AUX4_CLOUD_API_URL | https://api.aux4.cloud | Control-plane base URL | | AGENT_CHAT_ASK_TIMEOUT_MS | 5000 | Maximum time to wait for Cloud's explicit asynchronous Lambda acceptance receipt | | AGENT_CHAT_EVENT_CALLBACK_URL | empty | Trusted chat completion endpoint configured on the agent VM; request payloads carry an enable bit plus a random one-use capability, never a destination URL | | AGENT_CHAT_REMOTE_POLL_FALLBACK_MS | 60000 | Grace period for webhook completion before polling falls back to a canonical agent-VM history read; long enough to cover an agent cold start without competing stateful reads | | AGENT_CHAT_TURN_STALE_MS | 330000 | Retry threshold for an uncompleted turn; exceeds the agent VM's 300-second execution budget so valid builder turns can still deliver their callback | | AGENT_CHAT_POLL_WAIT_SECONDS | 12 | Server-side long-poll wait, hard-capped at 15 seconds | | AGENT_CHAT_POLL_INTERVAL_SECONDS | 0.5 | Delay between metadata/cache checks while a long poll is open |
The completion endpoint is the only public Cloud Chat API route. It does not trust the caller identity: every active turn gets a random 256-bit capability, Turso keeps only its SHA-256 digest, and receive-event accepts it once for the exact pending message. Conversation/history/actions remain protected by the platform OAuth session.
The package suite is deterministic and keyless. Agent discovery, ask, return-history, destroy-history, and import-history all use JSON fixtures for the aux4 cloud transport (AGENT_CHAT_DEPLOYMENTS_FIXTURE, AGENT_CHAT_VM_ASK_FIXTURE, AGENT_CHAT_VM_HISTORY_FIXTURE, AGENT_CHAT_VM_DESTROY_FIXTURE, AGENT_CHAT_VM_IMPORT_FIXTURE, and their _EXIT_CODE/_STDERR/_TIMED_OUT companions). transcribe is covered the same way via AGENT_CHAT_WHISPER_FIXTURE (set to the fixture transcript text, or to fail to exercise the error path) — no real aux4 whisper process or OPENAI_API_KEY is used. The suite never invokes a model, Bedrock, or any other real network call.
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u GEMINI_API_KEY \
-u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \
aux4 test run
When the same platform registry contains Files and its files-attachments custom component, Chat uses the Files-owned FilesChat composer. Without Files, the regular text composer remains unchanged. Attach up to 10 files, 6 MiB per file; attachment-only messages are supported.
Each DM/channel maps to a shared folder in Files. Membership is checked on upload, send, folder listing, and download. File references are resolved from server-owned metadata; a reference cannot be substituted into another channel or sent by another uploader. Files lists only published attachments. Channel membership changes immediately affect subsequent access. Legacy agent upload/history HTTP endpoints are no longer exposed; backend compatibility commands and stored data remain.
Cross-package validation: set FILES_PACKAGE_DIR to the Files package directory and run node package/test/fixtures/attachments-check.mjs from this repository. This uses isolated local storage and real command/SQL paths.