docs/kiosk-display-runtime-eval.md, Option C; docs/kiosk-voice-visual-architecture.md, §5).
This doc is the other half: WHAT that one page shows — its modes, how it switches between
them client-side, where its data comes from, how it’s served, and a phased plan to build it.
It is a design, not an implementation; nothing here is built yet. The one hard recommendation:
ship a v1 that boots to a clock/ambient dashboard, killing today’s about:blank idle
screen, before touching voice or agent-push.
This builds on two decided facts and one just-shipped fix:
- Runtime = Chrome+cage, unchanged. The self-navigating SPA is the whole point of keeping it.
- Content pipeline =
deploy-web-app. The agent already ships static apps toapps.augustin.ai/<name>in one atomic git commit (ConfigMap → Caddy). No new tooling. - Touch now works (this session’s Part-1 fix): the kiosk is a real touchscreen again, and the compositor hides the cursor on touch / shows it on mouse. So the SPA is touch-first.
0. Ground truth (what actually exists today)
Read out ofcluster/apps/** + images/** this session:
| Piece | Reality | Consequence for the SPA |
|---|---|---|
| kiosk runtime | patched cage + Chrome on i5, boots IDLE_URL (about:blank today), output-only (no Service/ingress). Chrome runs inside the cluster network. | The SPA is loaded BY the kiosk browser and runs on i5 inside the cluster — it can hit *.svc.cluster.local directly. Boot URL is a one-line env change (IDLE_URL). |
deploy-web-app | images/mastra/src/mastra/tools/deploy-web-app.ts: takes {name, files:[{name,content}]}, flat files, must include index.html, writes apps-src/<name>/ + wires a ConfigMap + Caddy mount in ONE commit. ≤1 MiB per app (ConfigMap limit). NO build step — CDN or inline libs only. | The SPA must be a single index.html (plus maybe a couple flat assets) under 1 MiB, no bundler. This is the dominant framework constraint (see §3). |
| Caddy / apps.augustin.ai | cluster/apps/webapps/: Caddy serves /srv/<name>, SPA fallback try_files … /index.html, and sets X-Frame-Options: DENY on every app. | Apps on apps.augustin.ai cannot be iframed. This kills the naive “SPA iframes the pushed app” idea for AGENT_PUSH (see §2). |
| Home Assistant | home-assistant.peripherals.svc.cluster.local:8123, API/WS only, no Lovelace, no token wired in git yet. | Dashboard + light control data source. Needs a long-lived token as a k8s secret before the SPA can call it. |
| mosquitto | mosquitto.peripherals.svc.cluster.local:1883, raw TCP only — NO WebSocket listener (verified: no listener 9001 / protocol websockets in its ConfigMap). allow_anonymous true. | A browser SPA cannot speak MQTT (browsers do MQTT only over WebSocket). This is the single biggest plumbing gap — the feed transport must be solved (see §4). |
| senses | senses.peripherals.svc.cluster.local:8080 MJPEG from i5’s camera. | Presence/motion source + a possible ambient camera tile. |
| console (other agent) | images/console/: Next.js 15 + React 19 + Tailwind v4 + assistant-ui, dark OKLCH “Cortex” theme (near-black surfaces, green primary oklch(0.723 0.219 149.579), violet secondary). A full server app. NOT yet deployed. | Styling-share question (see §3): the kiosk SPA can share the tokens, not the toolchain. |
1. The modes and how ONE page switches between them
From the voice+visual arch doc, the SPA is a client-side state machine with five modes. The whole design principle: mode transitions are DOM/CSS changes in one long-lived page, never navigations. The page loads once, opens its feed, and swaps what it renders.| Mode | What’s on screen | Entered by |
|---|---|---|
| IDLE | Fade to black (or a very dim clock). Backlight low. | Inactivity timer; explicit sleep event. |
| AMBIENT | Smart-home dashboard: clock, weather, light/climate/media tiles from HA. The default “awake” screen. | Wake: touch (now works), camera-motion, or a pushed wake event. |
| VOICE_ACTIVE | Voice visualizer (orb/waveform) over a dimmed dashboard + live transcript. | Wake-word / tap-to-talk (voice pipeline is another agent’s design). |
| AGENT_PUSH | Arbitrary content the agent pushed (“show me X”). | kiosk/show {url} from the agent (remote path). |
| LOCAL_CONTROL | A focused device control (light group, thermostat) with big touch targets. | User taps a tile / drills in from AMBIENT. |
mode) drives a <main data-mode> element; each mode is a component/section shown or hidden
by CSS, and transitions are CSS opacity/transform cross-fades (300–500 ms). The feed
(§4) pushes {mode, payload} messages; the SPA reduces them into state. So “fade to black →
wake → cross-fade to dashboard → cross-fade to voice orb” is a sequence of state changes in one
page, exactly what the relaunch model could never do. The IDLE fade is literally a CSS
transition on the persistent DOM — a real cross-fade, not a page reload.
“Show me X” — the hard one, because iframes are blocked. The founding intent is “show me
images of X → pop up on the screen.” Three ways to realize AGENT_PUSH, given Caddy sends
X-Frame-Options: DENY so the SPA cannot iframe a sibling apps.augustin.ai/<x>:
- Render-in-a-pane (recommended default). The agent doesn’t push a URL to iframe; it
pushes structured content the SPA knows how to render —
{mode:"agent_push", kind:"gallery", items:[{img,caption}]}or{kind:"markdown", body:"…"}or{kind:"iframe_external", url:"…"}. The SPA has a small set of built-in renderers (image gallery, markdown/text card, a big headline, a chart). This covers the overwhelmingly common “show me images/a summary/a number” cases without any cross-origin iframe. It’s also the most touch/glanceable-friendly. - Hard navigate (escape hatch). For a genuinely arbitrary web app the agent built with
deploy-web-app, fall back to the existing relaunch/kiosk/show {url}path — the SPA (or the navigator) points Chrome atapps.augustin.ai/<x>directly. This is a real navigation (accepts the cold-start flash) reserved for the rare “I built you a whole interactive app” case. The self-nav SPA stays the default boot page; this is the deliberate exception. - Iframe an allowlisted internal origin. If a pushed app is served from an origin that does
not set
X-Frame-Options: DENY(e.g. a dedicated content host, or Caddy relaxed toSAMEORIGIN/frame-ancestors selffor a/embed/*prefix), the SPA can iframe it. Requires a Caddy config change — flagged as an option, not the v1 path.
kind discriminator; the SPA owns the renderers.
2. Data sources and control flow
Because the SPA runs in a browser inside the cluster, it talks to services directly over HTTP/WS — the same reach the agent’scall-service has.
- Smart-home dashboard ← Home Assistant. Two options:
- HA WebSocket API (
ws://home-assistant.peripherals.svc.cluster.local:8123/api/websocket) — authenticate with a long-lived token,subscribe_eventsforstate_changed, get push updates. Best for a live dashboard (no polling). - HA REST API (
GET /api/states) — simpler, poll every few seconds. Fine for v1. - Recommend: REST-poll for v1 (dead simple, single file), upgrade to WS once the
dashboard is real. Either needs a long-lived HA token minted and stored as a k8s secret;
the SPA reads it… — and here’s a wrinkle: a static SPA has no server to hide a token behind.
The token would be embedded in the page (in-cluster, output-only device, no inbound port, so
the blast radius is contained, but it IS a token in client JS). Cleaner: proxy HA through
the agent — the SPA calls the agent (which holds the token via
call-service) instead of HA directly. Trade-off: adds the agent to the hot path for dashboard reads. For v1 the in-page token is acceptable given the device is inbound-portless and in-cluster; revisit if it bothers.
- HA WebSocket API (
- Light / device controls → HA. Per the arch doc’s split:
- Touch → HA directly (REST/WS from the browser): a tap on a light tile calls
POST /api/services/light/turn_on. Sub-100 ms, no LLM. Right for direct manipulation. - Voice → agent →
call-service→ HA: “turn the lights warm” goes through the voice loop so the agent interprets intent. Same HA endpoint, two callers, chosen by modality.
- Touch → HA directly (REST/WS from the browser): a tap on a light tile calls
- Voice viz ← the voice pipeline (another agent’s design): the SPA connects to the voice
pod’s WebSocket for amplitude/partial-transcript/speaking events and drives the visualizer
from that signal. Adopt the visual components (openai-realtime-blocks / agent-starter-react
MIT widgets), re-driven from our WS. Out of scope to design here; the SPA just needs a
voicemode section and a WS client stub. - Presence / wake ← touch (now fixed) / camera-motion (senses) / feed events. Touch is a DOM event in-page (wakes IDLE→AMBIENT instantly, no round-trip). Motion comes from a senses-fed detector publishing a wake event onto the feed. The feed can also carry an explicit wake from the agent.
3. Framework choice — and should it share the console’s styling?
The dominant constraint isdeploy-web-app: single index.html, ≤1 MiB, no build step.
That rules out the console’s stack outright — you cannot ship Next.js/React-19/Tailwind-v4/
assistant-ui through a ConfigMap with no bundler. So the honest options are:
| Option | Fit for ≤1 MiB / no-build | Pros | Cons |
|---|---|---|---|
| Plain HTML + vanilla JS + CSS | Perfect (one file, zero deps) | Zero toolchain, tiny, fast, matches the existing apps-src/mileage-tracker pattern. Trivial for the agent to regenerate/patch. | You hand-write the state machine + DOM diffing. Fine for ~5 modes; gets painful if the UI grows a lot. |
| Lit / Preact / htm via CDN (ESM import) | Good (import from esm.sh, no build) | Component model + reactive rendering with no build step (import maps / ESM). ~5–15 KB. Keeps it a single file. | Slight CDN dependency; still lightweight. Best balance. |
| React (CDN UMD) + htm | OK (no JSX build, use htm) | Familiar model; large ecosystem. | Heavier; no JSX without a build; awkward vs Lit/Preact for this size. |
| Same stack as the console (Next/assistant-ui) | Impossible via deploy-web-app | Visual consistency with the console. | Needs a real build + its own container image + manifest — a different deployment path entirely (a console-style pod, not a ConfigMap app). Massive overkill for a glanceable touch screen. |
index.html using Preact + htm (or Lit) via ESM
CDN import, with inline CSS. It satisfies deploy-web-app natively, gives a real
component/reactive model for the mode machine, stays well under 1 MiB, and the agent can
regenerate it. Vanilla JS is the acceptable fallback if even the CDN dependency is unwanted.
Should it share the console’s styling? Partially — share the tokens, not the toolchain, and
diverge on layout. Arguments:
- For sharing: a consistent “Cortex” look across the desk console and the wall kiosk is nice;
the console already defined a considered OKLCH dark palette (near-black surfaces, green
primary, violet secondary). Copying those CSS custom-property values into the SPA’s inline
:root{}is cheap and gives visual kinship for free. - Against full sharing: the two surfaces have genuinely different jobs. The console is a lean-in, mouse+keyboard, dense chat/observability app (assistant-ui components, small text, thin scrollbars). The kiosk is a lean-back, glanceable, touch-first, across-the-room display: huge type, big tap targets (≥48 px), no hover-dependent UI, high-contrast tiles, and a dark ambient aesthetic that fades to black. Those layout/interaction needs are different enough that reusing the console’s components would fight the medium.
@theme values) for brand
kinship, but author kiosk-specific layout and components tuned for touch/glanceability. Same
palette, different clothes. This keeps them recognizably one family without forcing a
touch wall-display into a desktop chat app’s component set.
4. How it’s served + deployed — and the feed transport (the real gap)
Serving: ship the SPA viadeploy-web-app as terminal → apps.augustin.ai/terminal.
Then set the kiosk’s IDLE_URL env to https://apps.augustin.ai/terminal (one line in
cluster/apps/peripherals/kiosk/deployment.yaml). The kiosk boots straight into it and never
navigates again. Updating the UI = the agent re-runs deploy-web-app terminal (one commit,
Flux reconciles, ~1 min). Zero new agent tooling — exactly the “don’t invent a pipeline”
principle. A dedicated pod/image (console-style) is the alternative only if the SPA
outgrows the 1 MiB / no-build ceiling; it should not for a glanceable dashboard.
The feed transport is the one piece of missing plumbing, and the design must pick a lane:
The SPA needs a live push feed to switch modes. mosquitto today is raw TCP 1883 only — a
browser cannot connect to it. Options:
- Add MQTT-over-WebSocket to mosquitto — add
listener 9001+protocol websocketsto the mosquitto ConfigMap and expose 9001 on its Service; the SPA uses MQTT.js over WS. Keeps the existingkiosk/*topic design intact; the agent keeps publishing to MQTT as planned. This is the cleanest fit for the arch doc’s MQTT-centric mode machine. One small manifest change. - Use HA’s WebSocket as the bus — the SPA is already connecting to HA for the dashboard; HA
can carry custom events too, and the agent already reaches HA via
call-service. Fewer new listeners, but overloads HA with kiosk-control semantics and couples the feed to HA being up. - A tiny WS relay in the SPA’s own backend — not available, since
deploy-web-appapps are static (no server). Rejected.
kiosk/mode/kiosk/show/kiosk/wake topic
design from the arch doc unchanged, and it makes the SPA a first-class MQTT client. This is the
critical dependency to land before the mode machine can work end-to-end. (Note: it’s a
peripherals-namespace central change — coordinate; it’s the mosquitto owner’s manifest, not the
kiosk’s.)
5. Touch-first UX (the screen is a touchscreen again)
Now that touch works and the cursor hides on touch / shows on mouse (Part-1 fix), design for fingers, not a pointer:- Big tap targets — tiles and buttons ≥ 48×48 px (ideally larger for across-the-room use), generous spacing so fat fingers don’t misfire.
- No hover-dependence — every affordance must be visible/reachable without hover. No hover tooltips carrying essential info; no hover-to-reveal controls. Use explicit expand/tap.
- Glanceable hierarchy — huge clock/primary info readable across the room; secondary info smaller but high-contrast. Dark theme, minimal chrome, content-forward.
- Direct manipulation — light sliders, toggle tiles respond instantly (HA REST/WS, no LLM in the loop for touch). Optimistic UI (flip the tile immediately, reconcile on the state event).
- The cursor — hidden during touch by the compositor patch, so a finger UI shows no stray
arrow; a mouse (rare, for debugging) reveals it. The SPA can additionally set
cursor:noneand toggle onPointerEvent.pointerTypeas defense-in-depth, but the compositor already handles it page-independently, so this is optional belt-and-suspenders. - Idle → fade to black — a full-screen CSS opacity transition on inactivity; touch anywhere wakes. No jarring reloads.
6. Phased plan (v1 first — kill about:blank)
Ordered to deliver value early and defer the hardest parts, mirroring the arch doc’s phasing but scoped to the SPA content:- v1 — Boot to an ambient clock/dashboard (kills
about:blank). Singleindex.html(terminal) with just IDLE + AMBIENT: a big clock + weather + a few HA tiles (REST-poll), fade-to-black on idle, tap-to-wake.deploy-web-app terminal, flipIDLE_URLto it. This alone replaces the blank idle screen with something worth looking at, and proves the persistent-page + HA-data path. No feed/MQTT needed yet (touch wake is a local DOM event; HA is polled). Smallest useful thing. - v2 — LOCAL_CONTROL + live HA + the feed. Add device-control tiles (touch → HA directly), upgrade HA reads to the WebSocket subscription for live state, and stand up the MQTT-over-WS feed (§4) so the SPA can receive mode events. Now the walk-up local path is complete.
- v3 — AGENT_PUSH. Wire the agent’s
kiosk/show {url|payload, priority, ttl}into the feed; implement the render-in-a-pane renderers (gallery/markdown/headline) + the hard-navigate escape hatch; add the interrupt-vs-toast arbitration from the arch doc. Delivers the remote “show me X” path unified with the walk-up path. - v4 — VOICE_ACTIVE. Add the voice visualizer mode + transcript, driven from the voice pod’s WS (that pipeline is another agent’s design). Last because it depends on the voice pod and is the highest-risk piece.
deploy-web-app terminal re-ship — one commit, no new infra except the mosquitto
WS listener at v2.
7. Riskiest unknown (flagged)
The browser feed transport, and specifically the HA-token-in-a-static-page tension. The mode machine is only as good as the live feed, and there is no browser-reachable bus today — mosquitto is TCP-only, anddeploy-web-app apps are static (no server to broker a WS or hide a
token). v1 dodges this (poll HA, local touch wake), but v2+ requires solving it. The clean
answer (add MQTT-over-WS to mosquitto) is a central manifest change in someone else’s service,
and the HA long-lived token either lives in client JS (contained but not pretty) or forces every
dashboard read through the agent (adds latency + couples the dashboard to the agent). Neither is
hard, but both are decisions that touch other components — so the SPA’s mode machine can’t be
fully proven until the feed + token story is nailed down. Everything visual (modes, tiles,
transitions, touch UX) is low-risk single-file work; the plumbing to feed it live is where the
unknowns and cross-service coordination sit. Prove v1 (which needs none of it) first, then land
the mosquitto WS listener before committing to v2’s live feed.
Sources / inputs
docs/kiosk-display-runtime-eval.md— runtime decision (keep Chrome, self-nav SPA).docs/kiosk-voice-visual-architecture.md— the TERMINAL/CONTENT/AGENT split, modes, arbitration, voice.images/mastra/src/mastra/tools/deploy-web-app.ts+runtime/skills/deploy-web-app/SKILL.md— content pipeline (single index.html, ≤1 MiB ConfigMap, no build, one commit).images/mastra/src/mastra/tools/call-service.ts— agent’s in-cluster HTTP reach to HA (rawhome-assistant…:8123).cluster/apps/webapps/(Caddyfile ConfigMap, deployment, ingress) — apps.augustin.ai serving + theX-Frame-Options: DENYiframe constraint.cluster/apps/peripherals/{home-assistant,mosquitto,senses}/— data sources; mosquitto has NO WebSocket listener (verified).images/console/(package.json, app/globals.css) — the console’s Next/assistant-ui stack + OKLCH “Cortex” tokens (styling-share input).apps-src/mileage-tracker/index.html— reference single-file SPA pattern (inline CSS design system, CDN libs) to model theterminalapp on.- Part-1 fix (this session): touch enumerates in the compositor; patched cage hides cursor on touch / shows on mouse.