Skip to main content
Cameras and microphones are peripheral sensors the agent samples as tools — not a single device layer bolted to i5. The live 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-snapshot already 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.
This is a design doc, read-only against the live subsystem, with one safe additive build staged and offline-validated (a 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.
NodeRole / labelCameraMic (capture-capable)Verdict
i5worker, kind=computeBuilt-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.
i3worker, kind=storageUSB 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).
pentiumcontrol-plane, NoScheduleNonelsusb 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).
Blunt takeaways:
  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.
  2. 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.
  3. pentium is a non-starter for sensors on two independent grounds: no hardware, and the NoSchedule control-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.
  4. The i5 mic and i3 mic are both capture-capable (verified via the capture column), so a mic-listen tool has real targets on both worker nodes once STT exists.

1. Per-node senses pods

Goal: turn the single i5 senses 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 live senses Deployment (cluster/apps/peripherals/senses/deployment.yaml) is already 90% of the template. Everything that must change per node is small and mechanical:
Knobi5 (senses, live)i3 (senses-i3, staged §6)Notes
Deployment/Service namesensessenses-i3i5 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/video0Both enumerate the primary capture node at video0; verify per node with /dev/v4l/by-id/.
device volumesvideo0, video1, /dev/sndvideo0, video1, /dev/sndUVC 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.privilegedtruetrueRequired — see the cgroup-v2 lesson below.
imageghcr.io/amarcin/senses:latestsameOne image serves all nodes; only args/mounts differ.
Naming + addressing. The convention is 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.
So: i5 = 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:
tolerations:
  - key: node-role.kubernetes.io/control-plane
    effect: NoSchedule
    operator: Exists
Recommendation: do not. pentium has no sensor hardware (§0), and it is deliberately kept workload-free as the light bastion/ingress door (AGENTS.md invariant). If a sensor is ever wanted “near pentium,” plug it into i3 or i5. The toleration is documented so the decision is explicit, not so it’s taken.

Device contention (the one real per-node caveat)

A V4L2 capture device is single-open for streaming. On i5, the senses 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/snd capture 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 with i5.
  • An optional dynamic table read per-call from config/cameras.json on the durable PVC (CONFIG_DIR = sibling of PROMPTS_DIR; on the live mastra pod that resolves to /app/data/config/cameras.json, on the mastra-data PVC).
  • 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:
type SensorEntry = {
  kind: "camera" | "mic";     // what the sensor is → which tool reads it, which path (image vs transcript)
  node: string;                // which box it's physically on: "i5" | "i3" | …  (documentation + human addressing)
  endpoint: string;            // in-cluster URL of the senses-pod endpoint:
                               //   camera → …/snapshot   (GET → one JPEG, the model SEES it)
                               //   mic    → …/capture?seconds=N  (GET → audio bytes, tool STT-transcribes)
  note?: string;
  source?: "static" | "dynamic";
};
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)

{
  "i5":     { "kind": "camera", "node": "i5", "endpoint": "http://senses.peripherals.svc.cluster.local:8080/snapshot",     "note": "i5 AIO built-in UVC webcam (Integrated_Webcam_FHD). 1280x720." },
  "i3":     { "kind": "camera", "node": "i3", "endpoint": "http://senses-i3.peripherals.svc.cluster.local:8080/snapshot",  "note": "i3 USB Logitech Brio 101 webcam." },
  "i5-mic": { "kind": "mic",    "node": "i5", "endpoint": "http://senses.peripherals.svc.cluster.local:8080/capture",      "note": "i5 AIO built-in mic array (ALC274 analog). Needs shared STT." },
  "i3-mic": { "kind": "mic",    "node": "i3", "endpoint": "http://senses-i3.peripherals.svc.cluster.local:8080/capture",   "note": "i3 Brio 101 USB mic (or onboard ALC3220). Needs shared STT." }
}

Alignment path — do NOT break the live camera tool

The camera tool ships today with a CameraEntry (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/endpoint as the canonical fields; keep reading snapshotUrl as an alias for a kind:"camera" endpoint so existing config/cameras.json entries and the i5 static seed keep working untouched. The camera tool filters the registry to kind === "camera" (or entries with a snapshotUrl); the mic tool filters to kind === "mic". One registry file, two consumers, zero migration.
  • File name: keep config/cameras.json or promote to config/sensors.json with cameras.json read as a legacy fallback. Recommend sensors.json as canonical + cameras.json fallback so the concept name matches the fabric, without orphaning any runtime-registered camera. This is a mastra-src change (owned by the other agent) — spec’d here, not done.
Net: a new USB sensor on any node = deploy its 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 /snapshot is a read of the camera.
  • Single owner of /dev/snd on 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)

  1. A tiny purpose-built HTTP audio-capture service in the senses image (recommended). Add a small binary/daemon to images/senses/ (alongside ustreamer) that, on GET /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 ALSA libasound directly. No arecord shell-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.
  2. arecord-over-HTTP shim (rejected as the primary). A shim that shells arecord -d N -f cd file.wav then 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.
  3. 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.
Decision: option 1 — a small in-image ALSA→WAV HTTP endpoint. It’s the shell-free, sample- shaped, single-owner design that matches how the camera path already works. It’s a change to 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 in images/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

listen({ mic: "i3-mic", seconds: 8 })

   ├─ resolve "i3-mic" in the unified registry → { kind:"mic", node:"i3",
   │     endpoint:"http://senses-i3.peripherals.svc.cluster.local:8080/capture" }
   │     (reject if kind !== "mic"; reject if endpoint not in-cluster — SSRF bound)

   ├─ GET {endpoint}?seconds=8  → 16kHz mono WAV blob from the i3 senses pod (§3)

   ├─ POST that WAV to the SHARED STT endpoint  (the voice pipeline's STT — §5 dependency)
   │     e.g. POST http://voice.peripherals.svc.cluster.local:<port>/stt  (multipart audio/wav)
   │     → { text, ... }

   └─ return { ok, mic, node, seconds, transcript, ... }   ← TEXT, the model reads it

Tool interface (drop-in shape, mirrors camera-snapshot)

export const micListen = createTool({
  id: "listen",
  description:
    "Capture a few seconds of audio from an in-fleet MICROPHONE and return a TRANSCRIPT of what " +
    "was said/heard (speech-to-text). Use it to hear a physical space on demand: is someone " +
    "talking near a node, what did they say, is there noise. Mics are named; known: " +
    "i5-mic, i3-mic (plus any registered in config/sensors.json). NOTE: this returns TEXT, not " +
    "audio — the audio is transcribed by the shared STT service first (the model cannot process " +
    "raw audio). Read-only, shell-free, in-cluster only. An unknown/unreachable mic, or STT " +
    "being unavailable, returns a structured {ok:false,detail} — it never throws.",
  inputSchema: z.object({
    mic: z.string().default("i5-mic")
      .describe("mic name, e.g. 'i5-mic' or 'i3-mic'. Defaults to 'i5-mic'."),
    seconds: z.number().int().min(1).max(30).default(8)
      .describe("how many seconds of audio to capture and transcribe (1–30)."),
  }),
  outputSchema: z.object({
    ok: z.boolean(),
    mic: z.string(),
    node: z.string().optional(),
    seconds: z.number().optional(),
    transcript: z.string().optional().describe("STT text of the captured audio — the model reads THIS"),
    detail: z.string(),
  }),
  // NO toModelOutput image path — unlike camera-snapshot, there is nothing for the model to SEE.
  // The transcript is a plain string field the model reads directly. (If STT returns word/segment
  // timings or a confidence score, carry them in `detail` or extra fields, still as text.)
  execute: async ({ mic, seconds }) => { /* resolve registry → GET /capture → POST STT → return transcript */ },
});

Design points

  • Returns a transcript, not audio. There is no toModelOutput media part (the camera tool’s whole trick). The transcript string 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. /capture on 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 /capture endpoint and the STT endpoint must be in-cluster. The mic tool reuses the camera tool’s isInClusterHost bound verbatim.
  • Same registry, filtered to kind:"mic". No second registry, no second config file.
  • Default mic = i5-mic (mirrors camera default i5), 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 drivesThe agent, on demand, mid-reasoningA human, walking up / talking
ShapeOne-shot sample → data back → tool returnsContinuous conversational loop (VAD → STT → agent → TTS → repeat)
CameraGrab one frame, model sees it(n/a — voice is audio)
MicCapture N sec → STT → return transcript to the tool loopStream mic → VAD-gated STT → feed the agent → speak back
LatencyBest-effort; a tool call in a turnInteractive; barge-in, turn-taking
Lives inmastra toolsthe voice pod + the kiosk terminal SPA
What they share (and must NOT duplicate): the STT service. The voice pipeline (kiosk-voice-visual-architecture §4) stands up a self-hosted STT (Parakeet on the 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-i3 Deployment + Service (pinned kind=storage, privileged, /dev/video0+/dev/snd mounts) alongside the untouched senses i5 pod — the bare senses name is grandfathered so the live camera tool + kiosk are undisturbed. Wire senses-i3 into cluster/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, node i3, senses-i3.../snapshot) to config/sensors.json on the mastra PVC → camera-snapshot can 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). Add kind/node/endpoint as the canonical superset, keep snapshotUrl as a back-compat alias, promote sensors.json (fallback cameras.json). Backward-compatible; no migration. Spec’d §2; not editable here.

Waits on the shared STT (mic path)

  • P4 — Add the /capture audio 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 voice pod exposes POST /stt (the coordination point, §5) — owned by the voice-pipeline agent. This is the gate for the whole mic path.
  • P6 — The listen mic tool (mastra src, 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-i3 manifest set was authored and offline-validated (kustomize build | kubeconform -strict2 resources, 0 errors) but staged in /tmp for 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 into cluster/apps/peripherals/senses-i3/ and adding - senses-i3 to 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 /capture endpoint on the senses image, the listen mic tool (mastra src, other agent), and the POST /stt coordination 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 /capture endpoint uses the ALSA API in-process (no arecord shell-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-forcedprivileged: true on 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.