> ## Documentation Index
> Fetch the complete documentation index at: https://docs.augustin.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-node sensor fabric (cameras + mics on any node)

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 → {kind, node, endpoint}) — 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](/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](/peripherals-k8s-native-design) (the "§2 senses"
device-mount pod) and [kiosk-voice-visual-architecture](/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).                                                                         |

**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:

| 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.                                                                      |

**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:

```yaml theme={null}
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**:

```ts theme={null}
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)

```jsonc theme={null}
{
  "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)

```ts theme={null}
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 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                              |

**What they share (and must NOT duplicate):** the **STT service**. The voice pipeline
([kiosk-voice-visual-architecture](/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 -strict` → **2 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-forced** — `privileged: 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.
