The resident dispatch's turn now runs under a steer select: the messages route, after its ingest, signals a per-key interrupt registry, the drop lands at the turn's current await (an in-flight shell command dies with it), the dangling calls close, and the dispatch loops on the same lease — the steered message runs as the next round, same session, same stream. The interrupted turn closes on the stream with turn_done carrying outcome "steered", and the shim's whole and streamed answers both wait through steered end markers for the round that actually replies. The messages route answers 202 the moment the message is accepted; the drain pass runs detached, so a turn's minutes never hold the request open. The web composer leans on that: it never closes for a running turn, the placeholder and the send button say that sending steers, a steered outcome gets its own tone, and the sent-row echo dedup pops a pending list instead of comparing the last item — a steered chain's extra end marker can no longer double the row.
rhema claw web
A small, hand-rolled web client for the claw (rhema-claw): a live
transcript of one session, the session inventory, and a settings area
(model providers, about). Nuxt 4 + Vue 3 + TypeScript, no component
library, no store. It is strictly one client of the claw's public
/v1 HTTP surface.
Run it against a locally running claw
rhema-claw # the daemon, on 127.0.0.1:7799
cd web/claw
npm install
npm run dev # http://localhost:3000
In dev, the server/middleware/claw-proxy.ts middleware forwards
/v1 and /healthz to http://127.0.0.1:7799 (NUXT_CLAW_UPSTREAM
overrides), so the browser sees a same-origin API and CORS never enters
the picture. If the claw is not running, the page still loads: the
down/401/stream banners tell you what is wrong.
Token
When the claw config sets token:, every /v1 route wants
Authorization: Bearer <token>. Give the UI a token either way:
- the environment:
NUXT_PUBLIC_CLAW_TOKEN=... npm run dev(runtime configclawToken), or - the inline field in the 401 banner; it is stored in
localStorage.
/healthz is open either way, which is how the header dot stays honest
when the token is wrong.
Build
npm run build # nuxt generate -> .output/public, fully static
npm run typecheck # vue-tsc over the whole app
npm run build produces a static SPA in .output/public, the artifact
an ingress serves. The ingress owns routing: same-origin /v1 and
/healthz to the claw. runtimeConfig.public.clawBase can point the
client at another base, but the claw sends no CORS headers, so
cross-origin use needs a proxy, not a different origin.
The one-consumer principle
The claw's /v1 API is the contract (crates/rhema-claw/src/http.rs):
sessions, messages, events, providers. This client uses only public
routes, speaks the wire shapes the Rust side serializes, and adds
nothing server-side. UI needs never justify private endpoints; they
justify claw API changes, argued in the Rust tree.
Two consequences worth knowing before changing this code:
- The daemon owns the conversation.
POST .../messagesis fire-and-forget (202); the reply streams back onGET /v1/sessions/{key}/events. The transcript here is a mirror of the stream, and the stream opens with the claw's replay of the session's recorded turns before the live feed — a reload and every reconnect reset the mirror and rebuild from the replay, so the transcript survives the browser, not just the claw. - SSE goes over fetch, not
EventSource. The claw's auth reads only theAuthorization: Bearerheader (http.rsrequire_auth); it has no query-param fallback, andEventSourcecannot set headers. SouseClaw().subscribeEventsholds aGETwith the auth header, reads the body as aReadableStream, and parsesdata:frames by hand (composables/useClaw.ts). Drops retry with backoff; the hub'slaggedcomments surface as notices.
What the transcript does with the stream
- Long pastes travel as chips. A paste over three lines (the TUI's
PASTE_INLINE_LINES) never lands in the composer; it stages as a droppable chip (▣1 42L) and embeds into the sent text as U+FFFC-payload-U+FFFC — the same recordrhema-core'spaste::embedkeeps — so the transcript renders each embed back as a chip in place of the payload. An unpaired mark is literal text. - The agent can ask. A
consent_requestevent renders as an inline card: kindsecretgets a password input plus submit, kindphonegets Approve/Deny, and both carry a deny link (DELETE /v1/consents/{id}). The card flips when the answer lands and again on the stream'sconsent_resolvedecho; a reconnect's replay rebuilds the cards, so nothing persists client-side. - Attachments render inline. An
attachmentevent becomes a card — media glyph, file name, B/KB/MB size, caption. Images fetch the bearer-guarded bytes (GET /v1/attachments/{id}, blob → object URL, like the identity images) and render capped at 240px; code and file cards download on click. Object URLs cache per id, so a replay never refetches. - Your images ride along. The composer's
+— and an image on the clipboard — stage as removable chips (▣1 photo.png 1.2 MB), the claw's own budget enforced client-side (8 a message, 5 MiB decoded each; the draft stays required, the route 422s an empty text). The sent row renders the local previews at once; the stream'suser_messageecho carries the stored attachment ids and dedupes against that row, so a replayed message renders its own thumbnails through the same bearer-guarded fetch as an attachment card. - Sessions are created in place. The sidebar's
+form postsPOST /v1/sessionswith a client-validated name ([A-Za-z0-9._-]{1,64}), switches the chat to the new key, and lets 400/409 land in the error banner. The phone client's status keys (phone:…) stay out of the inventory; the full list stays on the wire.
Layout
The shell is an icon rail plus two views, switched by a tiny hash
router (#/chat, #/settings/agent/general, #/settings/agent/ accounts, #/settings/providers, #/settings/about), no
vue-router. The chat view is the session inventory plus the live
transcript; the settings view is a searchable nav plus pages. The
agent's identity (name, mark, avatar) loads
once and renders everywhere — rail, sidebar, chat, window title —
through AgentAvatar, which owns the precedence: avatar image, then
mark or initials. Settings pages stage edits into a local draft and
apply them with an explicit Save (PUT /v1/providers/active answers
the new pair only; the client carries the provider list forward).
The providers page also manages the roster itself: the add form renders
from the addable kinds the claw serves; each kind's fields carry
{name, required}, required marking what the daemon refuses the login
without (llama.cpp needs neither of its fields, it serves keyless on a
default url); the form demands only those, marks the rest optional, and
takes an optional instance name; unnamed, the id is the kind. Model
options carry a ◉ vision mark when the live listing knows the model
takes images (supports_vision: true); the key's absence is unknown,
never a no, so it renders nothing. A login
is PUT /v1/providers/{id}/credentials: apply immediately,
credentials persist; roster rows are backend instances (id, the kind
unless the login named it, plus the credential's source
badge, env-backed kinds return on restart after a removal), and the
remove button (DELETE /v1/providers/{id}) refuses the last backend
claw-side.
app/
app.vue the shell: rail, banners, view switch
assets/css/main.css the Jadeite tokens (matches the TUI's
palette.rs DARK) + card/badge primitives
components/
AppRail.vue the icon rail: views, claw pulse
AppBadge.vue the status/tag pill
AppCallout.vue the toned inline note (info/ok/warn/error)
AppMarkdown.vue agent replies as markdown (markdown-it,
raw HTML escaped, styles in main.css .md)
ClawBanner.vue the one error/notice banner surface
ChatPanel.vue transcript: sent lines (paste chips),
streamed turns as bash shell lines and
glyph rows, consent + attachment
cards; the growing composer; agent
replies render through AppMarkdown
SessionsSidebar.vue conversable dispatch keys (phone status
keys hidden), interactive first; the
+ form creates a session
PageHeader.vue title + subtitle + page actions
SettingCard.vue one titled card of rows
SettingRow.vue name + description + control
SettingsNav.vue searchable settings nav
StatusDot.vue one honest state dot
settings/
ProvidersPage.vue the backend switch: draft → Save/Discard
AboutPage.vue identity, stack, the routes spoken
composables/
useClaw.ts the typed API client + fetch-based SSE
useTranscript.ts folds TurnEvents into renderable turn
blocks; owns the paste-embed text form
server/
middleware/claw-proxy.ts dev-only same-origin proxy to a running claw