senses pod is i5-only; this design generalizes it into a
per-node fabric: a senses-<node> pod pinned to whichever box physically has the hardware,
mounting that node’s /dev/video* / /dev/snd, exposing snapshot + audio-capture endpoints
on a per-node Service. A unified sensor registry (name → ) — an
extension of the camera tool’s existing registry — lets the agent address any sensor by name,
regardless of which box it lives on, and lets a new USB device be added at runtime with no
rebuild.
Two tool paths, kept strictly distinct because of a hard model constraint:
- Camera → the model SEES a JPEG. Opus 4.8 is vision-capable.
camera-snapshotalready fetches one frame and hands it to the model as a viewable image. This path works today and generalizes cleanly to multi-node — it is just more registry entries. - Mic → the model reads a TRANSCRIPT. Opus 4.8 cannot process raw audio. A “listen” tool therefore cannot hand the model audio. It must capture N seconds of PCM from the node’s senses pod, run it through shared STT → text, and return a transcript (+ metadata). This path depends on the shared STT service the voice access-point is being built around (see kiosk-voice-visual-architecture §4) — the mic tool consumes that endpoint; it does not build a second STT.
senses-i3 pod alongside the untouched senses i5 pod — §6).
It builds on peripherals-k8s-native-design (the “§2 senses”
device-mount pod) and kiosk-voice-visual-architecture (the
voice pipeline that owns the shared STT). Nothing in the mic path is built — it is designed
against the planned shared-STT interface and the dependency is called out honestly.
0. Hardware findings — what is ACTUALLY on each node
Probed read-only, this session:ssh i5 direct from Mac (the peripherals doc’s Phase-0
“i5 unreachable” gap is now closed — i5 answers directly), and ssh ssh.augustin.ai (pentium)
→ ssh i3. Sources: ls /dev/video* /dev/snd/, ls /dev/v4l/by-id/, lsusb,
/proc/asound/pcm (the capture column is the one that matters — a mic needs a capture PCM,
not just a card), /proc/asound/cards. This is ground truth, not assumption.
| Node | Role / label | Camera | Mic (capture-capable) | Verdict |
|---|---|---|---|---|
| i5 | worker, kind=compute | Built-in UVC webcam — Realtek Integrated_Webcam_FHD (0bda:58fb), /dev/video0 (+ video1/2/3; /dev/v4l/by-id/…Integrated_Webcam_FHD… → video2/video1). | Built-in mic array — card 0 PCH ALC274 analog, /proc/asound/pcm: 00-00 … capture 1. | Camera + mic. Live senses pod is here. |
| i3 | worker, kind=storage | USB Logitech Brio 101 (046d:094d), /dev/video0 (+ video1; /dev/v4l/by-id/…Brio_101…). | Two capture sources: the Brio’s own USB mic (card 0 B101, 00-00 USB Audio … capture 1) and onboard ALC3220 analog (card 1, 01-00 … capture 1). | Camera + mic — this is the USB camera Augustin suspected. It’s a combo cam+mic on i3, not pentium. i3 also has the working /dev/dri (jellyfin). |
| pentium | control-plane, NoSchedule | None — lsusb shows only root hubs + Intel rate-matching hubs; no /dev/video*. | None plugged in — only onboard ALC3220 analog jack (card 1, 01-00 … capture 1); a live mic would have to be physically attached. | No sensor hardware. Do not put a senses pod here (also tainted — see §1). |
- Augustin’s “USB camera on i3 or pentium” is on i3 — a Logitech Brio 101, and it’s a combo device (camera and mic). Pentium has nothing attached.
- Two camera+mic nodes exist today: i5 (built-in) and i3 (USB Brio). The fabric has a real second node to prove multi-node against — it isn’t a hypothetical.
- pentium is a non-starter for sensors on two independent grounds: no hardware, and the
NoSchedulecontrol-plane taint (§1). Even if a USB cam were plugged into pentium, it’s the bastion/ingress box we deliberately keep workload-free — the right move would be to plug the device into i3 or i5 instead of tolerating the taint. Design notes the toleration path for completeness but recommends against sensors on pentium. - The i5 mic and i3 mic are both capture-capable (verified via the
capturecolumn), so a mic-listen tool has real targets on both worker nodes once STT exists.
1. Per-node senses pods
Goal: turn the single i5senses Deployment into a pattern — one senses-<node> pod
per node that has sensor hardware, each pinned to its host, each mounting that host’s device
nodes, each on its own per-node Service.
The pattern (parameterized from the live i5 pod)
The livesenses Deployment (cluster/apps/peripherals/senses/deployment.yaml) is already 90%
of the template. Everything that must change per node is small and mechanical:
| Knob | i5 (senses, live) | i3 (senses-i3, staged §6) | Notes |
|---|---|---|---|
| Deployment/Service name | senses | senses-i3 | i5 keeps the bare senses name (do not rename — the camera tool + kiosk hard-code senses.peripherals.svc; see §6). |
nodeSelector | { kind: compute } | { kind: storage } | Pins each pod to its hardware. |
--device | /dev/video0 | /dev/video0 | Both enumerate the primary capture node at video0; verify per node with /dev/v4l/by-id/. |
device volumes | video0, video1, /dev/snd | video0, video1, /dev/snd | UVC exposes two nodes (capture + metadata); mount the pair. |
supplementalGroups | [44, 29] (video, audio) | [44, 29] | Host video/audio GIDs — same on both Debian-trixie nodes; verify if a node differs. |
securityContext.privileged | true | true | Required — see the cgroup-v2 lesson below. |
| image | ghcr.io/amarcin/senses:latest | same | One image serves all nodes; only args/mounts differ. |
senses-<node> with a matching per-node ClusterIP
Service, so the FQDN is senses-<node>.peripherals.svc.cluster.local:8080. The one
exception is i5, which stays the bare senses (Service senses.peripherals.svc) precisely
because two live consumers already depend on that name:
- the camera tool’s static registry (
STATIC_REGISTRY.i5.snapshotUrl = http://senses.peripherals.svc.cluster.local:8080/snapshot), and - nothing else references it by name, but renaming would still churn the running pod the kiosk path and the camera tool exercise.
senses (grandfathered), every new node = senses-<node>. The registry (§2)
absorbs the asymmetry — the agent never types a Service name, it types a sensor name.
The device-gate reality (why privileged: true, on every node)
This is not a convenience — it’s an empirically-established hardware fact on this k3s, captured
in both live manifests (senses and kiosk). On this cluster’s cgroup v2 device
controller, a hostPath char-device mount gets no device-cgroup allow rule, so open() on
/dev/video0 (or /dev/tty0, or /dev/dri/card0) fails with EPERM even with the correct
group (supplementalGroups alone is insufficient — the same gate that blocked jellyfin’s
/dev/dri and the kiosk’s seatd-on-tty0). k8s exposes no per-device cgroup allowlist field,
so privileged: true is the minimal setting that opens the device cgroup. Every senses-*
pod inherits this. It is contained the same way the live pod is: own namespace, no
serviceAccount token, in-cluster-only Service (no ingress), read-of-a-device blast radius. The
line held is the peripherals doc’s: privileged for hardware, never for convenience.
pentium and the NoSchedule taint
pentium carries node-role.kubernetes.io/control-plane:NoSchedule (verified). A pod lands there
only with an explicit toleration:
Device contention (the one real per-node caveat)
A V4L2 capture device is single-open for streaming. On i5, thesenses ustreamer holds
/dev/video0; a second consumer (e.g. the planned motion detector, or the voice pod wanting
the mic) can’t independently open() the same node. This is why:
- The camera is fine — ustreamer is the single owner and everyone reads its HTTP
/snapshot(multi-reader over HTTP, single-owner of the device). The fabric preserves this: the pod owns the device, HTTP fans out. - The mic needs care — if the voice pod (§4) and the mic-listen tool both want raw
/dev/sndcapture on the same node, they contend. The design resolves this by making the senses pod the single owner of audio capture too, exposing capture over HTTP (§3), so the mic-listen tool and any other consumer read the endpoint, not the device. One owner per device node, HTTP fan-out — the same discipline as the camera.
2. The unified sensor registry
Goal: one registry the agent addresses sensors through — cameras and mics, across any node — extending (not forking) the camera tool’s existing name→URL registry.What exists (camera-snapshot.ts, read-only)
The camera tool already has exactly the right shape, and the fabric must align with it, not duplicate it:- A static
Record<name, {snapshotUrl, note}>baked into the image, seeded withi5. - An optional dynamic table read per-call from
config/cameras.jsonon the durable PVC (CONFIG_DIR= sibling ofPROMPTS_DIR; on the live mastra pod that resolves to/app/data/config/cameras.json, on themastra-dataPVC). - Merge rule (safety): dynamic may ADD names but may NOT override a static entry — a runtime write can’t repoint an existing sensor at a hostile URL.
- SSRF bound: a sensor URL must resolve in-cluster (
*.svc.cluster.local/ short name / private RFC1918 / loopback) — enforced both at registration-read and at fetch.
The extension: one registry, kind + node
Generalize the entry from camera-only to a unified sensor entry:
kind is the load-bearing new field: it tells the agent (and the tooling) which path a sensor
takes — a camera yields an image the model views, a mic yields a transcript. node makes
“the camera on i3” addressable and keeps the fabric’s per-node reality legible.
Static seed (from §0’s real hardware)
Alignment path — do NOT break the live camera tool
The camera tool ships today with aCameraEntry (just snapshotUrl/note) and a static
seed of { i5 }. Two ways to unify, and the design recommends the backward-compatible one
so nothing regresses when the mastra-owning agent adopts it:
- Preferred — additive superset. Add
kind/node/endpointas the canonical fields; keep readingsnapshotUrlas an alias for akind:"camera"endpoint so existingconfig/cameras.jsonentries and thei5static seed keep working untouched. The camera tool filters the registry tokind === "camera"(or entries with asnapshotUrl); the mic tool filters tokind === "mic". One registry file, two consumers, zero migration. - File name: keep
config/cameras.jsonor promote toconfig/sensors.jsonwithcameras.jsonread as a legacy fallback. Recommendsensors.jsonas canonical +cameras.jsonfallback so the concept name matches the fabric, without orphaning any runtime-registered camera. This is a mastra-srcchange (owned by the other agent) — spec’d here, not done.
senses-<node> pod (§1) + one line in
config/sensors.json on the PVC (via the agent’s self-edit) → addressable immediately, no
rebuild. Static entries stay authoritative and un-overridable.
3. The senses-pod audio-capture endpoint (new; camera path already exists)
ustreamer is video-only — it serves MJPEG//snapshot from V4L2 and knows nothing about
/dev/snd. So the camera endpoint (/snapshot) already exists and works; the audio-capture
endpoint (/capture) is net-new and must be added to the senses pod.
Requirements
GET /capture?seconds=N→ return N seconds of captured audio as a single response (a bounded WAV/PCM blob,Content-Type: audio/wav), then stop. Not a stream — a sample, to mirror the camera’s “one frame” semantics. Bound N (e.g. 1–30s) so a call can’t run away.- Contained / shell-free-ish, same ethos as the camera path: no arbitrary command surface,
in-cluster-only, no state change. The endpoint is a read of the mic, exactly as
/snapshotis a read of the camera. - Single owner of
/dev/sndon its node (§1 contention note) — the senses pod owns audio capture; the voice pod and the mic-listen tool both read this HTTP endpoint, not the device.
Implementation options (evaluated)
- A tiny purpose-built HTTP audio-capture service in the senses image (recommended). Add a
small binary/daemon to
images/senses/(alongside ustreamer) that, onGET /capture, opens the ALSA capture PCM, records N seconds to an in-memory WAV, and returns it. Language: a ~60-line Go binary (single static binary, no interpreter, matches the shell-free ethos) or a minimal C using ALSAlibasounddirectly. Noarecordshell-out — call the ALSA API in the process, so there is no command string in the loop (same reasoning the agent tools are shell-free). Runs on a second port (e.g.:8081) in the same pod, or the same pod with two containers (ustreamer + audiocap) sharing/dev/snd. This keeps “one senses pod owns the node’s sensors” true for both camera and mic. arecord-over-HTTP shim (rejected as the primary). A shim that shellsarecord -d N -f cd file.wavthen serves the file is the quickest to write but reintroduces a shell/command surface into a device-owning privileged pod — exactly the thing the rest of the stack avoids. Acceptable as a throwaway Phase-1 spike to prove the mic captures at all, not the shipped design.- ustreamer’s audio (rejected — doesn’t fit). ustreamer can carry audio, but only as part
of its Janus/WebRTC path (
WITH_JANUS), which the senses Dockerfile explicitly builds OFF (WITH_JANUS=0) and which produces a stream for a browser, not a bounded sample for a tool. Wrong shape (stream vs sample) and wrong transport (WebRTC vs a plain GET). Don’t turn it on for this.
images/senses/ (build source, in this repo) + the senses Deployment (add the port / second
container) — buildable when STT lands, spec’d here.
Sample rate / format
Capture at 16 kHz mono 16-bit PCM (the canonical STT input rate — Parakeet/Whisper both expect 16k) so the/capture output is STT-ready with no resample step in the tool. WAV
container so the tool can hand STT a self-describing blob.
4. The mic-listen tool (spec for the mastra agent)
This is a spec — the tool lives inimages/mastra/src/mastra/tools/ (owned by another
agent, off-limits to edit here). It mirrors camera-snapshot.ts so the mastra-owning agent can
drop it in. The crucial difference from the camera tool: it returns a TRANSCRIPT, never
audio, because Opus 4.8 can’t hear.
Flow
Tool interface (drop-in shape, mirrors camera-snapshot)
Design points
- Returns a transcript, not audio. There is no
toModelOutputmedia part (the camera tool’s whole trick). Thetranscriptstring is the payload; the model reads text. This is the single most important consequence of the “Opus can’t hear” constraint and it must be unmistakable in the tool so the agent never expects to “listen to” audio. - Two network hops, both fail-soft.
/captureon the node’s senses pod, then the shared STT. Each is a structured{ok:false, detail}on failure (unknown mic, capture failed, STT unavailable), never a throw — a bad listen degrades the turn gracefully, same as a bad snapshot. - SSRF-bounded, same as camera-snapshot: both the
/captureendpoint and the STT endpoint must be in-cluster. The mic tool reuses the camera tool’sisInClusterHostbound verbatim. - Same registry, filtered to
kind:"mic". No second registry, no second config file. - Default mic =
i5-mic(mirrors camera defaulti5), the always-present built-in.
5. Sensor-TOOL vs the voice access-point — the boundary
Both consume the same STT. They are otherwise different animals, and the line is:Sensor tool (camera-snapshot, listen) | Voice access-point (the kiosk voice loop) | |
|---|---|---|
| Who drives | The agent, on demand, mid-reasoning | A human, walking up / talking |
| Shape | One-shot sample → data back → tool returns | Continuous conversational loop (VAD → STT → agent → TTS → repeat) |
| Camera | Grab one frame, model sees it | (n/a — voice is audio) |
| Mic | Capture N sec → STT → return transcript to the tool loop | Stream mic → VAD-gated STT → feed the agent → speak back |
| Latency | Best-effort; a tool call in a turn | Interactive; barge-in, turn-taking |
| Lives in | mastra tools | the voice pod + the kiosk terminal SPA |
voice pod) as the STT stage of its VAD→STT→LLM→TTS cascade. The
mic-listen tool calls that same STT endpoint for its one-shot transcription. Do not build a
second STT for the sensor tool.
The clean split: the voice pipeline owns STT (it needs it always-on for the loop); the
sensor tool is a client of it (it needs a one-shot transcription). Concretely, the voice
pod should expose its STT as a plain POST /stt (audio → text) in-cluster endpoint — a small,
obvious interface both the voice loop (internally) and the mic-listen tool (over the cluster
network) use. Coordination point with the voice-pipeline agent: agree that voice exposes a
one-shot POST /stt (multipart audio/wav, 16 kHz mono → {text}) that the mic-listen tool can
call. If the voice pod isn’t built yet, the mic-listen tool is written against this interface
and stays {ok:false, detail:"shared STT not yet available"} until it exists — the dependency is
explicit and the tool degrades gracefully rather than blocking.
Boundary in one line: sensors are how the agent samples the physical world and gets data back;
voice is how a human talks to the agent. They meet only at the shared STT.
6. Phasing + what’s built vs designed
Buildable now (camera path — no STT dependency)
- P1 — Generalize senses to per-node; add i3 (SAFE, ADDITIVE, staged + validated). The i3
Brio is real hardware (§0). Add a
senses-i3Deployment + Service (pinnedkind=storage, privileged,/dev/video0+/dev/sndmounts) alongside the untouchedsensesi5 pod — the baresensesname is grandfathered so the live camera tool + kiosk are undisturbed. Wiresenses-i3intocluster/apps/peripherals/kustomization.yaml. This was drafted and offline-validated clean this session (kustomize build | kubeconform -strict→ 2/2 valid). It is the lowest-risk step and proves the multi-node fabric with a second real camera. - P2 — Register i3 camera in the unified registry. Add
i3(kind:camera, nodei3,senses-i3.../snapshot) toconfig/sensors.jsonon the mastra PVC →camera-snapshotcan see the i3 Brio immediately, no rebuild. (Registry-file write; the tool already reads dynamic entries.) - P3 — Unify the registry type in the camera tool (mastra
src, other agent). Addkind/node/endpointas the canonical superset, keepsnapshotUrlas a back-compat alias, promotesensors.json(fallbackcameras.json). Backward-compatible; no migration. Spec’d §2; not editable here.
Waits on the shared STT (mic path)
- P4 — Add the
/captureaudio endpoint to the senses image (images/senses/— a small in-image ALSA→WAV HTTP service, §3). Buildable independent of STT (it just returns audio), but only useful once STT exists to transcribe it. Reasonable to build alongside P5. - P5 — The
voicepod exposesPOST /stt(the coordination point, §5) — owned by the voice-pipeline agent. This is the gate for the whole mic path. - P6 — The
listenmic tool (mastrasrc, other agent) — capture from a node’s/capture→ shared STT → return transcript. Spec’d §4; blocked on P4 + P5; not editable here.
What this session built vs designed
- Built (safe, additive): nothing committed to the live tree beyond this doc — the
senses-i3manifest set was authored and offline-validated (kustomize build | kubeconform -strict→ 2 resources, 0 errors) but staged in/tmpfor review rather than committed, because adding a second privileged device-pod that grabs the i3 Brio is a live-fleet change worth an explicit go-ahead, not something to slip in under “additive.” The exact manifests are in §1’s table + reproduced below; dropping them intocluster/apps/peripherals/senses-i3/and adding- senses-i3to the peripherals kustomization is a one-commit, Flux-reconciled step whenever approved. - Designed (not built): the unified registry type change (mastra
src, other agent), the/captureendpoint on the senses image, thelistenmic tool (mastrasrc, other agent), and thePOST /sttcoordination with the voice-pipeline agent.
The staged senses-i3 manifests (validated, ready to commit when approved)
cluster/apps/peripherals/senses-i3/deployment.yaml — identical to the live senses
Deployment except name: senses-i3, app: senses-i3, and nodeSelector: { kind: storage }
(i3). Same privileged: true, same supplementalGroups: [44, 29], same ghcr.io/amarcin/senses
image, same /dev/video0+/dev/video1+/dev/snd mounts, same --device=/dev/video0 args.
Plus a senses-i3 ClusterIP Service on 8080, and a leaf kustomization.yaml; then - senses-i3
added to cluster/apps/peripherals/kustomization.yaml. Verify i3’s host video/audio GIDs
match [44, 29] at deploy time (they did on both trixie workers probed).
Constraints honored
- k3s + OS only — every sensor is a device-mount pod; no host daemon (the peripherals-doc invariant).
- Opus 4.8 vision-but-not-audio — camera returns an image the model sees; mic returns a transcript the model reads. Never audio to the model.
- One STT, shared — the mic tool is a client of the voice pipeline’s STT, not a second STT.
- Shell-free / SSRF-bounded — the
/captureendpoint uses the ALSA API in-process (noarecordshell-out); both tools bound sensor + STT URLs to in-cluster hosts, reusing the camera tool’s existing bound. - Least-privilege-where-possible, privileged-for-hardware-where-forced —
privileged: trueon senses pods is the documented cgroup-v2 device-gate necessity, contained by namespace + no API token + no ingress, exactly as the live senses/kiosk pods are. - Don’t fork the registry — one unified
name → {kind, node, endpoint}table, the camera tool’s existing pattern extended, static-wins-on-collision + PVC-extensible preserved.