Skip to main content
The i5 screen, camera, mic, and the “show me X on the screen” reflex, designed as k3s workloads with device passthrough — not host processes. This is the turn-79 peripherals plan (host cage+Chromium kiosk, host ustreamer sense-daemon, SSE→MQTT push, headless Home Assistant) re-drawn so the fleet’s hard invariant holds: every box = Debian + k3s only; k3s is the sole workload runtime. Design only — nothing here is built. The prerequisites in the phasing section gate any build, and one of them (verifying i5’s DRM and video devices) could not be checked read-only from where this was written; that gap is called out honestly rather than papered over.

Why this document exists

Turn 79 sketched the peripherals subsystem as host-native processes on i5 — a cage (Wayland kiosk compositor) running Chromium against the physical display, and a ustreamer daemon owning the camera/mic — explicitly “NOT a pod.” That was a deliberate escape hatch: owning a physical display and a /dev/dri seat from inside a container is genuinely harder than running the same binary on the host. But it directly violates the rule the whole rewrite converged on — OS + k3s, nothing else — and a single host daemon on i5 is exactly the “homeless layer” the k3s migration existed to kill. Augustin chose to redesign it k8s-native. This is that design, plus an honest account of where the host-native path was actually easier, so the exception decision can be reopened with eyes open rather than by drift. The precedents already in the repo say this is plausible: jellyfin passes /dev/dri into a pod for QuickSync; the downloads stack runs a NET_ADMIN + /dev/net/tun pod for VPN; beszel’s DaemonSet takes SYS_ADMIN/SYS_RAWIO and hostPath /proc+/sys. Device-passthrough pods are a solved, blessed pattern here. What’s new is DRM master (owning a display output, not just decoding on the render node) and a Wayland seat inside a container. That’s the whole ballgame, and section Risks does not pretend otherwise.

The GPU reality (read this before anything else)

The two GPUs are not interchangeable, and the display is on the wrong one for the existing precedent:
  • i3 (kind=storage, 192.168.1.100) has the working /dev/dri — Intel HD 4400, card0 + renderD128, used by jellyfin for QuickSync. It has no attached screen that matters.
  • i5 (kind=compute, 192.168.1.25) has the physical screen — the Dell Inspiron 5490 All-in-One (model W24C). It has its own iGPU (10th-gen-class, QuickSync-capable per the turn-13 spec recon), but no one has verified its /dev/dri / /dev/video* / /dev/input device layout in the k3s era. The jellyfin passthrough is on i3 and tells us nothing about i5’s device nodes.
The kiosk must render to i5’s physical panel, so it must pin to i5 and use i5’s DRM device and seat. You cannot borrow i3’s GPU for this — DRM scanout is local to the card wired to the panel. Every kiosk manifest below therefore carries nodeSelector: { kind: compute } (i5) and mounts i5’s device nodes, whose exact paths are a hard prerequisite to verify (see Phasing). One piece of good news for free: the fleet’s OS is headless (Debian + k3s, no desktop, no display manager). That is exactly the condition a DRM-master container needs — nothing else on i5 is holding the display. The “OS + k3s only” rule and “a pod owns the screen” are, on this specific point, aligned: because there’s no host gdm/Xorg/cage competing for /dev/dri/card*, a pod can become DRM master cleanly. The invariant that makes this hard elsewhere makes it possible here.

Architecture at a glance

Five components, one new namespace peripherals, all on the GitOps tree:
ComponentKindNodeThe hard part
kioskDeployment, privilegedi5 (kind=compute)owns the physical display (DRM master + Wayland seat in-pod)
sensesDeployment, device-mountedi5 (kind=compute)/dev/video* + /dev/snd passthrough; MJPEG/audio out
mosquittoDeployment + PVCi3 (kind=storage)in-cluster MQTT broker; the push bus
home-assistantDeployment + PVCi3 (kind=storage)headless / API-only state hub
(no new agent tooling)the agent drives the kiosk via existing deploy-web-app + call-service
Data flow for “show me X on the screen”:
agent  --deploy-web-app-->  apps.augustin.ai/<x>   (Caddy, existing webapps box)
agent  --call-service (MQTT-bridge / HA) -->  mosquitto  topic: kiosk/show  {url:".../<x>"}
kiosk  <--subscribe--  mosquitto        ->  Chromium navigates to the URL on i5's panel
senses --MJPEG/PCM-->  (HA / agent)     ->  camera + mic as a passive feed and a callable input
The design principle, carried from the agent’s own evolution (turns 91–95): do not invent new agent tooling. The kiosk shows a web page; the agent already publishes web pages (deploy-web-appapps.augustin.ai/<x>) and already speaks HTTP to any in-cluster service (call-service). The display subsystem is therefore almost entirely a consumer of capabilities that already exist. The only genuinely new infrastructure is the push bus (so the screen learns what to show without polling) and the two device-owning pods.

1. Display / kiosk as a pod

Goal: a k3s pod renders Chromium full-screen to i5’s physical panel, and switches what it shows on command. Shape: a single-container (or compositor+browser two-container) Deployment, pinned to i5, running a Wayland kiosk compositor inside the pod (cage, or weston --shell=kiosk) that takes DRM master on i5’s card and launches Chromium as its only client in kiosk mode. This is the host-native turn-79 design (cage + Chromium) moved inside a container — same binaries, same idea, different runtime boundary. Why the compositor lives in the pod and not on the host: putting cage on the host is precisely the host-process exception we’re removing. The compositor is the workload; the workload runs in k3s. The container image is cage + chromium + mesa/libva + seatd/libseat on a minimal Debian base (built under images/kiosk/, pushed to ghcr.io/amarcin/kiosk, same CI matrix as the other custom images).

The three hard problems and the proposed solutions

(a) DRM master — only one process can own the display. KMS allows exactly one DRM master per card. If anything else holds it, the compositor’s drmSetMaster fails and you get a black screen. Solution, in order of preference:
  1. Rely on the headless OS. i5 ships with no display manager (OS + k3s only), so on boot nothing owns /dev/dri/card*. The kiosk pod’s cage becomes master unopposed. This is the clean path and it’s a consequence of the k3s-only rule — verify at bring-up that i5 has no getty on the console VT grabbing DRM and no plymouth/fbcon splash holding it.
  2. VT/seat handoff. Run the compositor on a dedicated VT and mount /dev/tty0 + /dev/tty2 so cage can VT_SETMODE/KDSETMODE to take the console. seatd (a tiny seat manager) in the pod brokers device access without a full systemd-logind; mount /run/seatd.sock from a seatd sidecar or run seatd as the container’s init. cage supports libseat with the seatd backend, which is the lightest option that still does proper master arbitration.
(b) Input devices. Touch (the W24C is likely a touch SKU — unverified) and any keyboard/ mouse come in via /dev/input/event*. cage/libinput needs these mounted and needs seatd to grant them. Mount /dev/input (hostPath) and let seatd own the seat. If touch calibration is off, that’s an evdev/libinput config problem inside the pod, not a k8s problem. (c) The GPU render + KMS device. Mount i5’s /dev/dri (both card* for KMS/scanout and renderD* for GL/VA) exactly like jellyfin mounts i3’s — but the device paths and the LIBVA_DRIVER_NAME (i5 is 10th-gen → iHD, not jellyfin’s i965 for the 4400) are i5-specific and must be verified, not copied from jellyfin.

Pod spec (described)

  • nodeSelector: { kind: compute } — pin to i5 (the panel is there).
  • strategy: { type: Recreate } — a rolling second pod would fight for DRM master; only one kiosk may exist. Same reasoning as the hostPort/Recreate rule for ingress.
  • securityContext — the honest part. This pod is privileged or runs with a specific cap set (SYS_ADMIN for DRM ioctls/mode-setting, plus SYS_TTY_CONFIG for VT switching) and seLinuxOptions/seccompProfile: Unconfined if the compositor trips the default seccomp filter (mode-setting ioctls sometimes do — the same class of exception firefox needed seccomp:unconfined for). Start from the minimal set — SYS_ADMIN + SYS_TTY_CONFIG + the device mounts + seccomp: Unconfined — and only fall back to privileged: true if seatd-brokered access can’t get DRM master. Document whichever lands; do not leave it at blanket privileged if a cap set works.
  • Device mounts (hostPath, all on i5, all VERIFY-FIRST):
    • /dev/dri → the GPU (KMS card* + render renderD*).
    • /dev/input → keyboard/mouse/touch (event*).
    • /dev/tty0 (+ a dedicated VT like /dev/tty2) → console/VT for master handoff.
    • /run/udev (readOnly) → libinput device discovery, if seatd alone isn’t enough.
  • /dev/shm sizing — Chromium crashes on the default 64 MB shm (the exact trap firefox documents). Mount an emptyDir { medium: Memory, sizeLimit: 1Gi } at /dev/shm.
  • No ingress, no Service. The kiosk is an output device, not a network service — nothing connects to it. It only makes outbound connections (subscribe to MQTT, fetch the app URL).
  • Resources: request ~512Mi/250m, limit ~2Gi (a full Chromium + compositor; comparable to the browserless limit).
  • Config: subscribe target (MQTT broker Service DNS), a default/idle URL to show when no event is pending (e.g. a clock/dashboard app on apps.augustin.ai), and screen resolution/rotation for the W24C panel.

The tradeoff vs. the shell-free / minimal-privilege ethos

The agent stack fought hard to be shell-free and least-privilege (turn 73; the mastra pod runs runAsUser: 1000 with a narrow RBAC role and no node shell). This kiosk pod is the opposite corner of that ethos: privileged-ish, hostPath device mounts, likely seccomp: Unconfined. That is not a contradiction to resolve away — it’s a real cost. The mitigations: it runs in its own namespace with no serviceAccount privileges (it never touches the k8s API — it only owns hardware), it exposes no listening port (no inbound attack surface), and its blast radius is “the screen and the local input devices on one AIO,” not the cluster. It is a device driver wearing a pod, and we accept that a device driver needs device access. The line we hold: privileged for hardware, never privileged for convenience.

2. Camera / mic sense layer as a pod

Goal: replace the host ustreamer daemon with a pod that owns i5’s camera and mic and publishes them as a passive feed plus a callable input. Shape: a Deployment pinned to i5, mounting /dev/video* and /dev/snd, running ustreamer (MJPEG over HTTP) for the camera and, if audio is wanted, a small ALSA/PulseAudio capture into the same pod or a PCM/RTP stream. Direct analog of jellyfin’s /dev/dri mount — this is the device-mount precedent applied to V4L2 + ALSA instead of DRM.

Pod spec (described)

  • nodeSelector: { kind: compute } — the camera/mic are physically on the i5 AIO.
  • securityContext: far lighter than the kiosk. V4L2 + ALSA generally need only the device mounts and a matching group (video, audio), not privileged and not SYS_ADMIN. Use supplementalGroups for the host video/audio GIDs (the pattern jellyfin uses with supplementalGroups: [992] for render). No net caps.
  • Device mounts (hostPath, on i5, VERIFY-FIRST):
    • /dev/video0 (and any /dev/video1..N — UVC cameras expose two nodes, capture + metadata; mount the directory or enumerate) via type: CharDevice.
    • /dev/snd → the sound devices (directory).
  • Service + no public ingress: expose the MJPEG stream on a ClusterIP Service (senses.peripherals.svc) so HA and the agent can read it in-cluster only. If a camera view ever needs to be shown, it’s shown through the kiosk (which fetches an apps.augustin.ai page embedding the stream) — the camera feed does not get its own public hostname.
  • Device-plugin note: the “proper” k8s way to hand out /dev/video*//dev/dri is a device plugin (e.g. intel-gpu-plugin, or a generic k8s-device-plugin for hostPath devices) advertising intel.com/gpu / a video resource so pods request the device instead of hostPath-mounting it. That’s cleaner and schedules honestly, but it’s more moving parts for a single-node, single-device situation. Decision: start with hostPath (matches jellyfin, zero new infra) and only adopt a device plugin if a second consumer or a second GPU ever makes contention real. Don’t gold-plate a one-camera problem.

3. The push channel

Goal: the agent (and any service) can push a “show X” event to the screen without the kiosk polling. Decision — reconsider SSE→MQTT, land on MQTT. Turn 79 said “SSE→MQTT push channel,” which is really two things: MQTT as the bus, SSE as one possible edge for a browser client. For a kiosk that is a native Chromium+compositor (not a thin browser tab hitting an SSE endpoint), a plain MQTT subscription is simpler and more robust than SSE — the kiosk holds one long MQTT connection and reacts to kiosk/show messages. Keep it fully in-cluster and k8s-native: an in-cluster Mosquitto broker as a pod.

mosquitto (broker)

  • Deployment + PVC, nodeSelector: { kind: storage } (i3 — it’s stateful-ish: retained messages + a persistence file; pin it with the rest of the stateful data), Recreate.
  • ClusterIP Service on 1883, in-cluster only (no ingress, no LoadBalancer — unlike the stalwart mail-port exception, nothing external needs MQTT).
  • Auth: a username/password from a kubectl-created secret (secretKeyRef), never in git.
  • runAsUser: 1000, minimal config ConfigMap (listener 1883, persistence on, one ACL: publishers can write kiosk/#, the kiosk subscribes kiosk/#).

The bridge — reuse call-service, don’t build a tool

The agent already has call-service (turn 93): a shell-free authenticated HTTP primitive that reaches any in-cluster service via a registry. MQTT is a TCP protocol, not HTTP, so the agent can’t publish to Mosquitto directly with call-service. Two ways to keep it k8s-native without new agent code:
  1. HA as the bridge (preferred). Home Assistant (§4) speaks both MQTT and a REST API. The agent calls HA’s REST/webhook API via call-service (register HA in the service registry), and an HA automation republishes to the kiosk/show MQTT topic. One config entry, zero new agent tooling, and it makes “show X” a first-class HA action alongside every other device action.
  2. A tiny MQTT-HTTP bridge pod (mqtt/http shim, e.g. a 20-line service exposing POST /publish {topic,payload} → MQTT) registered in call-service. Only build this if HA turns out to be the wrong hub. It’s the fallback, not the default.
Either way the interface the agent uses is the one it already has: an HTTP call to an in-cluster service. The push channel stays k8s-native (broker is a pod, bridge is a pod or an HA automation) and adds no new agent surface.

4. Home Assistant headless / API-only

Goal: HA as the device/state hub (the thing that knows about the camera, the screen, and any future smart-home devices) — its API and MQTT, not its frontend. Build our own UI (a web app on apps.augustin.ai); don’t adopt HA’s Lovelace as the surface. This matches the turn-36/38 senses framing: the agent is the UI; the services are senses.

Pod spec (described)

  • Deployment + PVC (/config — HA’s SQLite/state store), nodeSelector: { kind: storage } (i3, with the stateful data), Recreate, replicas: 1.
  • Image: ghcr.io/home-assistant/home-assistant:stable.
  • Network — the one wrinkle. HA’s device auto-discovery (mDNS/SSDP for smart-home gear) wants host networking or at least broadcast reach on the LAN. For an API/MQTT-only hub driving the screen, that discovery is not needed — so run it as a normal ClusterIP Deployment (no hostNetwork), talking to Mosquitto over the cluster network and to the agent over REST. If and when a real broadcast-discovered device is added, revisit hostNetwork: true (pinned to i3) as a scoped exception — but don’t take that networking hit speculatively. Document it as a known future edge.
  • Config: the MQTT integration pointed at mosquitto.peripherals.svc, a long-lived access token (secret) for the agent’s call-service calls, and the kiosk/show automation from §3.
  • No public ingress by default (agent reaches it in-cluster). If a debugging view of HA’s own UI is ever wanted, add an ingress behind the existing CF Access policy — but the product UI is the custom apps.augustin.ai app, per the framing.

5. How the agent drives it (no new tooling)

The whole point of tying into existing capabilities: the display subsystem is a client of what the agent can already do.
  1. Content: the agent publishes a page with deploy-web-app → served by Caddy at apps.augustin.ai/<x> (turn 72, LOCKED). “Show me images of X” = generate a tiny gallery app, deploy-web-app it, done. No new content pipeline.
  2. Command: the agent tells the screen what to show by call-service to HA (or the bridge), which publishes kiosk/show {url: "https://apps.augustin.ai/<x>"}. The kiosk pod, subscribed, navigates Chromium there.
  3. Proactive: the agent can already reach out via ntfy (the mastra pod has NTFY_URL wired). “I put it on the screen” can also ping the phone with the same link — the surface is the screen and the notification, reusing the existing escalation path.
  4. Camera as a tool: the senses pod’s in-cluster MJPEG/PCM endpoints are just more in-cluster services — reachable by call-service (HTTP) for a snapshot, or surfaced through HA as a camera entity. No bespoke “camera tool” needed; it collapses into the existing broad primitive, which is exactly the turn-91→95 direction (few broad primitives, not a per-device tool pile).
Net new agent code across the whole subsystem: effectively zero — a service-registry entry for HA and, optionally, one HA automation. That is the design working with the grain of the agent’s evolution instead of against it.

Phasing

Hard prerequisites first. Nothing below is buildable until these are answered; several require physical/console access to i5 that a read-only k8s session does not have. Phase 0 — verification & access (BLOCKS everything).
  • P0.1 — Re-establish i5 bring-up access. ssh i5 is unwired (verified: from pentium, ssh i5“Could not resolve hostname i5”). i5 is a live k3s node at 192.168.1.25 (verified: kubectl get nodes shows i5 Ready, kind=compute, Debian 13, kernel 6.12.94), but there is no working shell path to it from the bastion for device inspection or console work. Restore a reachable, authenticated path to i5 (fix the ~/.ssh/config host entry / mesh name resolution) before touching hardware. This document could not enumerate i5’s devices for exactly this reason — treat every device path below as unconfirmed.
  • P0.2 — Verify i5’s /dev/dri. Confirm card* (KMS/scanout) and renderD* exist and which VA driver the 10th-gen iGPU uses (iHD vs i965). The jellyfin manifest is on i3 and tells us nothing about i5. Without this, the kiosk manifest is a guess.
  • P0.3 — Verify i5’s /dev/video* and /dev/snd. Confirm the AIO webcam enumerates (UVC → /dev/video0+) and the mic/speakers appear under /dev/snd`.
  • P0.4 — Verify i5 holds no DRM master at boot. Confirm the headless OS truly leaves /dev/dri/card* free — no gdm/Xorg/getty-on-console/plymouth grabbing it. This is the assumption the whole kiosk rests on; the “OS + k3s only” rule should guarantee it, but verify, don’t assume.
  • P0.5 — W24C panel/touch/backlight verify (the standing turn-79 TODO). Confirm the Dell W24C’s touch SKU, panel native resolution/rotation, and that backlight control works headless (some AIO panels need a kernel video=/i915 quirk). Touch drives whether we mount /dev/input for a touchscreen and whether calibration is needed.
Phase 1 — the push bus (no hardware; safe, do first). Deploy mosquitto (i3) + a minimal ConfigMap + secret. This is a plain stateful pod with a PVC — the well-trodden path. Prove publish/subscribe in-cluster. Zero device risk; unblocks the rest. Phase 2 — Home Assistant (no hardware). Deploy home-assistant (i3, ClusterIP, PVC), wire the MQTT integration to mosquitto, mint the agent’s access token, add HA to the call-service registry, author the kiosk/show automation. Now “publish a show-event” works end-to-end before any screen exists — testable by watching the MQTT topic. Phase 3 — senses pod (needs P0.1/P0.3). Build/deploy the senses Deployment on i5 with /dev/video* + /dev/snd mounts and supplementalGroups. Lower-risk than the kiosk (no DRM master). Verify the MJPEG stream on the ClusterIP Service and, optionally, as an HA camera entity. This also proves i5 device passthrough works at all before betting the display on it. Phase 4 — the kiosk (needs ALL of P0). Build images/kiosk/ (cage + Chromium + mesa + seatd), deploy the privileged/carefully-capped Deployment on i5 with the DRM/input/tty mounts and the /dev/shm sizing. Iterate the securityContext down from a known-working state toward the minimal cap set. Wire it to subscribe kiosk/show. First success = an apps.augustin.ai page appears on the physical panel on command. This is last because it is the hardest and every earlier phase de-risks a piece of it. Phase 5 — glue & polish. Default/idle screen app (clock/dashboard), the agent’s “show me X” flow end-to-end (generate → deploy-web-appcall-service HA → screen), ntfy dual-surface, touch interactions if the W24C is a touch panel.

Risks — where k8s-native is genuinely harder

This is the section that doesn’t rubber-stamp the mandate.
  • DRM master in a container is the real risk. On a headless host it should work (nothing competes), but the failure modes are nasty and remote-debuggable only up to a point: if cage can’t take master you get a black screen with no network symptom, and diagnosing it means console access to i5 (which, per P0.1, isn’t even wired yet). A host process hitting the same problem is debuggable over plain SSH with journalctl; a pod adds a container/seat layer between you and the DRM error. This is strictly harder than host-native, and it’s the single most likely place the build stalls.
  • Seat management without systemd-logind. Containers don’t have the host’s logind seat. seatd is the answer and it works, but it’s one more in-pod daemon to get right, and libseat backend selection (seatd vs logind vs builtin) is fiddly. Host-native cage under the host’s seatd/logind is a documented, common setup; in-pod is comparatively uncharted.
  • Single-display arbitration. Only one DRM master, ever. The Recreate strategy enforces one pod, but a crash-looping kiosk that grabs-and-releases master can wedge the console; and if P0.4 is wrong (something does hold master at boot), the pod never starts cleanly. A host process has the same constraint but a simpler recovery (restart the unit).
  • securityContext cost. This pod is the least-privilege ethos’s worst citizen (privileged or SYS_ADMIN+SYS_TTY_CONFIG+seccomp: Unconfined+broad device hostPaths). We contain it (own namespace, no API access, no inbound port), but it’s a real expansion of the fleet’s privileged surface — from “beszel reads /sys” to “a pod owns the console.”
Does any component honestly want a host exception? Four of the five are cleanly and comfortably k8s-native — mosquitto, home-assistant, and the senses pod are ordinary device-mount/stateful pods squarely in the repo’s existing precedent, and driving them via deploy-web-app/call-service is a better fit than the host design. No exception wanted there. The kiosk compositor is the honest maybe. DRM-master-in-a-container is a known-hard thing, and the turn-79 host cage existed precisely to sidestep it. My recommendation: build it k8s-native as designed — the headless OS removes the usual blocker, the precedents exist, and a host cage daemon is exactly the homeless layer the k3s migration killed. But set a concrete tripwire: if, after Phase 0 verification, the in-pod cage cannot reliably take DRM master (crash-loops, black screen, or needs escalating hacks beyond a documented cap set), that is the signal to reopen the host-exception decision for the compositor only — as a narrow, documented carve-out (a single host cage unit that the k8s kiosk pod’s Chromium connects to as a Wayland client), not a reversal of the k3s-only rule. Everything else stays in k3s regardless. The k8s-native path is the primary; the tripwire keeps us honest instead of sinking unbounded effort into the one genuinely hard component. Augustin decides at the tripwire, with real evidence instead of a guess.