# Deployment: the boxes, the models, the daemons *Last verified: 2026-08-09 @ a9b480a* What runs where, and why each choice was made. `CLAUDE.md` carries only the rules. This file carries the reasoning. ## The two boxes **homesrv** is a Ryzen 5 5600U laptop and the deploy target. It offloads to the Vega iGPU over Vulkan (`n_gpu_layers: 99`). Compose passes `/dev/dri` and the render gid (993), and without both Vulkan enumerates zero devices and llama-server falls back to CPU silently. **workpc** is the workstation, 16GB of VRAM, reached as `kami@workpc` at 192.168.1.105. Model work moved there on 2026-08-02 by the owner's call, because homesrv cannot grow a GPU. Three rules govern the seam: - **The workstation is never assumed up.** - **Fall back silently** when it would only do the job better. - **Name the gap** when the resident model cannot do the job at all. A world question goes through `LLMPhraser.PhraseWorld` and returns `worldGap` (`cmd/mavend/worldmodel.go`) rather than an invented answer. A box with no `workstation` block behaves exactly as it did before the seam. `docs/offload.md` says which caller is which. ## The resident model **Qwen3-1.7B** (`UD-Q4_K_XL`), stock, not yet the CPT'd one. It is a Thinking variant, so `n_ctx` is 4096. Reasoning tokens need the room, and 4096 is what every score was measured at. The target is the locally CPT'd Qwen3-1.7B (V-122, training in flight). Stock already speaks good Russian. What it gets wrong is the persona. It writes `я рад` where Maven needs `рада`. **Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and both are unusable in Russian (`docs/evals/2026-07-31-model-bakeoff.md`). Their published IFEval and BFCL numbers are English-only. Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm`. That **shadows** the repo's `models/llm/`, so a gguf sitting there is not loaded by anything. Swapping the resident model is a one-line change to `phraser.model_path` in `deploy/mavend.json`. The workstation model is gemma-4-E4B as of 2026-08-09, replacing the 12B by the owner's call. Keep the 12B gguf. It is the better teacher for label runs, at 72.7% destination against E4B's 57.6%. ## The embedder **It stays on homesrv permanently**, because it backs the floor. It is multilingual-e5-small, quantized and asymmetric. `EmbedQuery` and `EmbedPassage` apply the `query:` and `passage:` prefixes it was trained with. Calling plain `Embed` on a note is a bug. See `docs/evals/2026-08-04-recall-e5-small.md`. The vendored onnxruntime under `deps/` has two copies, and the stale one is 1.17.1. The live runtime is 1.26.0, and the Go binding asks for API 26. Anything shipped to another box needs `deps/onnxruntime-linux-x64-1.26.0`. ## Speech-to-text `sttSeam` in `cmd/mavend/voicewire.go` builds an `stt.Pair` beside `modelSeam`. It prefers CrisperWhisper 2.0 turbo on workpc with mavsttd as the floor. It takes only the silent half of the rule, because a worse transcript is still a turn. So `stt.Pair` has no `TranscribeRemote` and the fallback is never spoken. CW2 turbo scores 10.4% WER in Russian against 27.5% for the `ggml-small.bin` mavsttd loads, over 200 Golos clips (`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`). **whisper.cpp cannot load CW2 at all.** It reads its language count off the vocabulary size. CW2's 51897 tokens shift seven special token ids. So CW2 is its own transformers service on port 8081 (`deploy/cw2/serve.py`). `stt.HTTPTranscriber` posts raw PCM to it with a bearer token, because audio is the most sensitive thing that crosses this seam. The switch is `workstation.stt` in `deploy/mavend.json`. A LAN URL with an empty token fails config validation; `workstation.stt.disabled: true` is the STT-only dark state; `workstation.disabled: true` disables both remote arms. The model has its own `model_disabled` switch because CW2 can remain live without the model proxy. **mavgpud runs that service as a second child.** This is not an optimisation. CW2 is a ROCm process on the same card, so it registers on the KFD like any contender. Under its own systemd unit it made mavgpud evict llama-server every few seconds. That took the model arm down for eight minutes on 2026-08-09. The card needs one owner. CW2 is on the yield clock and not the idle one. At 1.6GB it denies the card to nobody. Text-to-speech has not moved. piper on homesrv is the only synthesizer. ## The daemons | Binary | Role | |---|---| | `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB and IPC socket. | | `mavweb` | HTTP UI and PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`), WebAuthn auth. | | `mavsttd` | Speech-to-text (whisper.cpp, CGO). | | `mavttsd` | Text-to-speech (piper subprocess). | | `mavwaked` | Wake-word and VAD gate. Runs on workpc. | | `mavenclient` | Voice loop client (mic, stt, core, tts). Not deployed. | | `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. | | `mavcaldav` | CalDAV calendar sync. | | `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. | | `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. Its LAN port needs a bearer token in the file named by `token_file`, matching `MAVEN_GPU_TOKEN` on homesrv, or it refuses to start (V-673). | | `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. | Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts a live tmpfs working copy back to the ciphertext file when mavend was killed before `defer st.Close()` sealed it. `labelgen` runs the stage 0 grammars over utterances and prints JSONL, the training data for the routing heads. Daemons are wired socket-to-socket, not linked. `internal/ipc` is the wire protocol. `deploy/mavend.json` sets socket paths, model paths and the phraser and embedder blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`. ### Who is in compose, and who is not **`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`. Count against compose, not against the table above. `mavmaild` and `mavcaldav` are commented out, each with the reason beside it. The first needs a mail account and the second a CalDAV account, and this box has neither. Two things ride on the CalDAV absence (V-644). Agenda questions route to `IntentQuery` at stage 0, and the `calendar` query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the same facts, so the gate's "do not nag mid-meeting" is permanently false. `mavenclient` is still absent. `mavwaked` moved to workpc on 2026-08-09 (V-515). ### The voice wire `internal/voice` is plaintext with no auth. Its own server doc says production binds inside the wg tunnel, because the wg layer is the L0 floor. workpc is not a wg peer, it sits on wlan0. So the tunnel is ssh instead. mavend publishes the voice port to homesrv loopback only, `127.0.0.1:9110`. Host 9100 is Vikunja's MCP, hence 9110. The container side stays 9100 so mavweb keeps reaching `mavend:9100` by name. `deploy/maven-voice-tunnel.service` on workpc forwards it over his key. **Do not replace this with a LAN bind.** `SurfaceVoice` caps acts at L0, so an unauthorized speaker could not run a destructive tool. L0 does not cap reading, so they would still hear his facts, notes and calendar read back. Both `mavwaked` and `mavenclient` speak `voice.Dial`, not `ipc.Dial`. The `netaddr` token guards the daemon-to-daemon IPC seam and never touches this one. `ipc.Dial` does take `tcp://host:port?token=...`, which is why V-515 was filed as a config change. That premise was wrong, and the ssh leg is the correction. The voice loop belongs on a client machine where the owner is standing, and that machine is workpc (V-463, `docs/plans/17-where-the-voice-loop-runs.md`). homesrv has a microphone, because it is a laptop, but it is in the wrong room. ### mavwaked on workpc `deploy/mavwaked.service`, a user unit beside `mavgpud.service`. Two flags are deliberate. `-vad-model` is passed. Silero answers "is this frame speech" where the energy floor answers "is this frame loud". It declines white noise at the same RMS, 0 frames against 68 to 99, and still hears all four spoken fixtures (`docs/evals/2026-08-09-silero-vad.md`). It costs 509µs a frame and never touches the GPU. A model that will not load is logged and not fatal. `-barge-in` is not passed. The threshold is room-specific and this room has no number yet. Read the "suppressed while speaking" means out of the journal first. The device is `plughw:CARD=Gen,DEV=0`, the Scarlett Solo. It is `plughw` and not `hw` because mavwaked asks arecord for 16kHz mono. No microphone here offers that, so bare `hw` dies on "Channels count non available" before a frame is read. It is named `CARD=Gen` and not card 4 because a USB card number moves when something else is replugged. It used to be the fifine on card 0, and that cost three days. mavwaked logged zero completed utterances across them, before the wake word existed and after. The fifine returns RMS 0.00004 with its capture switch on and its ALSA volume at the full 496 of 496. That silence is in the hardware and no flag reaches it. Over the same eight seconds of speech the onboard ALC897 read 0.142 and the camera 0.289, both clipping at peak 1.0. The Scarlett read 0.003 clean. Check the level before blaming the gate. Stop the unit, run `arecord` against the device for five seconds, and measure. A live room floor reads near 0.001. The three `-wake-` flags require the keyword "Мэйвен" (V-487 stage two). Drop them and the loop runs open, which is what it did before. The threshold is the binary's default of 0.999 and is not passed. Over 65 minutes of held-out Russian speech it woke her 0.9 times an hour against 2.8 at 0.99. That cost one lost render out of 126 (`docs/evals/2026-08-09-wake-word.md`). The three sessions are pinned to one thread each. onnxruntime otherwise sizes its pool to every core and spins between runs, which took mavwaked from 68% of one core to 335%. With the cap it sits at 81%, so the gate costs about 13%. A keyword shorter than 1.32s can be heard too late to be used. The head scores 1.28s of audio, and the VAD has closed the utterance by then. "Мэйвен, " is unaffected. A bare "Мэйвен" is the case that fails. mavwaked connects at startup and holds the conn, so a nudge routed to voice reaches the speaker before he has said anything (V-671). It used to connect lazily, which made the failure silent rather than absent: after one utterance the session existed, `PushToMostRecent` succeeded, the dispatcher stopped rerouting to telegram and ntfy, and mavwaked discarded the audio. **Passwords are read from files, never taken as flag values.** `mavcaldav` uses `-pass-file` and `-render-pass-file`. `mavpoll` and `mavmaild` follow the same rule. ## Web UI conventions Every HTTP response carries a server-generated `X-Request-ID`. Error responses use `application/problem+json` with three fields: a sanitized `error`, a stable low-cardinality `code`, and that `request_id`. `writeProblem` is the only error writer: it records the full wrapped error beside the same ID in the mavweb log, while implementation paths, transport messages and credentials never reach the browser. A page that can usefully degrade instead of failing keeps HTTP 200 and renders the same code/request reference beside its sanitized panel error. The `/ecosystem` panel uses its HTTP request ID as `X-Correlation-ID` on its direct Nexus, Praxis and Hexis reads. Calls routed through mavend retain mavend's action-scoped correlation contract instead. Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell partial in `cmd/mavweb/shell.html`. A page opens with `{{template "shellTop" ""}}` and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link. Every page is its own embedded `.html` file next to `main.go`. No page markup lives in Go, and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No per-page `