On this page

Grimoire — the mind

A deep dive into the server half of familiar: a single Go binary (stackend) that turns your LAN box into the familiar’s brain. It hears with whisper.cpp, thinks with any OpenAI-compatible LLM, and speaks with Kokoro TTS.

If you just want it running, the Quickstart covers that in four commands. This page is for understanding — and tuning — what’s inside.

The shape of it

              WebSocket /grimoire/            HTTP
 poppet ◄──────────────────────────► stackend ─────► llama-swap / vLLM (LLM)
 (device)     Opus audio + JSON          │
                                         ├──── in-process (cgo): whisper.cpp (ASR)
                                         └──── HTTP: Kokoro sidecar (TTS, 24 kHz PCM)

One process, one WebSocket per device, and the connection is the session — protocol v2 has no session IDs. Everything ships as a single container plus a Kokoro sidecar (compose.yaml), listening on port 9099.

Package tour

PathWhat it is
cmd/stackend/the server binary — flags, HTTP mux, graceful shutdown
cmd/v2client/reference protocol client; smoke-test a full turn without a robot
internal/session/the heart: per-connection state machine and voice loop
internal/protov2/v2 wire format — message types, decoder, fuzz tests
internal/audio/Opus encode/decode (libopus via cgo), framing, 24→16 kHz resample
internal/asr/whisper.cpp via cgo + the whisper→JSON-log bridge
internal/llm/streaming OpenAI-compatible client (SSE, tools, images)
internal/tts/Kokoro client + sentence chunking
internal/mcptools/external MCP adapter — plugs standard MCP servers into the LLM
internal/vision//vision endpoint: camera captures → multimodal LLM
internal/ota//discover and OTA discovery endpoints

Anatomy of a turn

What actually happens between “Hey Artemis, what time is it?” and the answer:

  1. Wake. The device detects the wake word on-device and opens the audio window, including a short pre-roll — so words spoken right on the heels of the wake phrase aren’t lost.
  2. Listening. The device streams 16 kHz Opus mic audio continuously. In the default auto-stop mode the server decides when you stopped talking: an adaptive energy VAD (internal/session/vad.go) tracks the noise floor and fires after ~240 ms of speech followed by ~800 ms of silence. (Frames louder than the beep ceiling are ignored, so the wake chime can’t trigger it.)
  3. Beep trim + ASR. The wake-ack chime bleeds into the mic (its echo isn’t cancelled), so the first ~600 ms is scanned and the beep stripped — otherwise whisper hears “(gasp)”. Then whisper.cpp transcribes in-process. Whisper’s stage directions ([BLANK_AUDIO], (wind), ) are filtered out.
  4. Transcript to screen. The final transcript goes to the device as a caption; with -asr-streaming on, incremental partials appear while you speak (each partial is a real whisper re-inference — opt-in for that reason).
  5. Think. Your words join the dialogue history (capped at 48 messages) and stream through the LLM. Tool calls the model makes are dispatched — up to 6 rounds of call → result → continue.
  6. Speak. Each complete sentence of the reply goes to Kokoro as it arrives, comes back as 24 kHz PCM, is resampled to 16 kHz server-side, and streams to the device as Opus — with cumulative captions so the text tracks the voice. The whole reply is one speaking session, so multi-sentence answers never get clipped between sentences.

Flow control: credits, not clocks

The ESP32 has a fixed 40-packet audio buffer. Rather than pacing TTS by wall-clock, every Opus frame the server sends spends a credit; the device grants credits back as it drains its buffer. When credits hit zero the server blocks. The result: the device buffer physically cannot overrun, and there’s no drift math anywhere. (Protocol details: Protocol v2 §5.)

Half-duplex, for now

The shipped release is half-duplex: wait for the reply to finish before speaking again. Barge-in (interrupting mid-reply) is designed into the protocol — abort handling, audio_cancel, hello renegotiation — but needs server-side echo cancellation, and is deferred to v2.1.

Tools: three tiers

When the LLM decides to do something instead of just talking, the tool call is routed by where the tool lives:

Configuration

Deployment config flows .envcompose.yaml → flags. The interesting knobs:

FlagDefaultWhat it does
-ws-url(required)the WebSocket URL advertised to devices via /discover
-whisper-modelpath to a ggml model; empty disables ASR
-llm-url / -llm-model / -llm-api-keyany OpenAI-compatible endpoint
-llm-max-tokens200caps runaway replies (and therefore runaway TTS)
-system-prompt-filepersona.txt — your familiar’s personality
-kokoro-voice / -kokoro-speedaf_heart / 0.85the voice
-tts-tail-pad-ms400trailing silence so the last word never clips
-asr-streamingofflive partial transcripts while you speak
-vad-*see --helpendpointing: silence window, speech threshold, beep ceiling
-wake-gateoffsecond-stage wake-word check (below)
-mcp-configexternal MCP servers for the LLM
-timezoneUTCfor get_current_time
-vision-urlenables the camera→multimodal-LLM path

persona.txt deserves a special mention: it’s the system prompt, and it’s where your familiar’s character lives. The shipped one keeps replies to a couple of short sentences (this is a spoken companion — nobody wants a read-aloud essay), bans markdown, and teaches it when to use its tools.

Observability

Everything logs as structured JSON on stderr (slog), one line per event, tagged by component — sessions, turns, tool calls, per-frame VAD state at debug level. Two pieces are worth knowing about:

HTTP surface: /discover (device bootstrap), /grimoire/ (the WebSocket), /healthz (liveness), /vision (camera callback — unauthenticated by design, keep it LAN-only), plus legacy OTA-shaped discovery at /grimoire/ota/.

Running it well