Mobile voice satellite for Home Assistant and AI agents, running on the Pollen Robotics Microduck.
Disclaimer: quacksat is an independent project. It is not affiliated with, endorsed by, or supported by Pollen Robotics or Hugging Face. "Microduck" is used only to identify the target hardware.
quacksat turns the Microduck into a roaming voice assistant. It captures
audio on the duck, detects a wake word, and hands the conversation to one of
three interchangeable backends selected in /etc/robot/quacksat.toml:
wyoming— the duck becomes a Home Assistant Assist satellite over the Wyoming protocol: STT, intent handling, and TTS run in your existing HA pipeline.agent— the duck streams audio and events over WebSocket to a bridge that runs STT → LLM (with tool calling) → TTS. The protocol is agent-neutral; a minimal reference bridge lives inbridge/, so you can bring your own agent.direct— self-contained: the duck itself calls three OpenAI-dialect endpoints (chat completions, transcriptions, speech) — any cloud key or local server, no bridge, no home server. It can also serve its own MCP endpoint so MCP-capable agents drive the robot directly.
In both modes quacksat is an unprivileged client of robotd, the Microduck
system daemon: it sends intents and RPCs (move, head, skills) over the
JSON-RPC socket and never touches the hardware bus directly. If quacksat
crashes or hangs, robotd's deadman keeps the robot safe.
quacksat/ the binary: config loading, capture, backend dispatch
quacksat-core/ mic capture, wake word, VAD, speaker, robot tools → robotd
backends/wyoming/ Home Assistant Assist satellite backend
backends/agent/ AI agent backend (WebSocket to a bridge)
backends/direct/ self-contained backend (OpenAI-dialect STT/LLM/TTS, no bridge)
bridge/ minimal reference bridge for the agent backend
systemd/ quacksat.service unit
scripts/ build, package and install for the duck (Radxa Zero 3)
docs/study/ study notes on the Microduck software stack
docs/adr/ architecture decision records
docs/todo.md open work
v0.1.0-rc1 (2026-10-03), the first release candidate: an install package for the duck on every release (Installing from a release); what is in it is in the CHANGELOG, its status and known limits in the release notes.
Working, validated on a development Mac against real services (a Home Assistant install, an agent platform, local LLMs) and with the MuJoCo twin of the Microduck. On-robot validation is pending — December 2026.
- Local wake word (openWakeWord models on the pure-Rust tract runtime;
custom phrases supported — see
docs/custom-wake-word.md), VAD turn segmentation, half-duplex playback, robotd client on the padd model, and a thinking cue: a slow head sway while the answer is computed, a low give-up tock on timeout or error ([thinking]config). wyoming: registers in Home Assistant and runs the full Assist round-trip (wake → STT → intent → TTS).agent: the neutral WebSocket protocol (docs/agent-protocol.md) plus the reference bridge inbridge/— STT/LLM/TTS as OpenAI-dialect url+key endpoints, robot tools behind an exhaustive allowlist, and an MCP server exposing them to MCP-native agents. Multi-duck: several satellites on one bridge, wake arbitration by score (the duck that heard you best answers), per-duck MCP tool addressing.direct: the self-contained satellite — it calls the three OpenAI-dialect endpoints itself, no bridge, and can serve its own MCP endpoint so agents drive the robot directly.- Navigation, since 2026-09-22, is a daemon of its own (ADR 0006):
quack-navd, in the quacknav repo, owns the map client, the cliff guard, the planner, the places registry, the explorer and the homecoming, and answers for them on/run/quack-nav/nav.sock(NDJSON JSON-RPC, robotd's own wire). quacksat probes that socket at startup: if a daemon answers, its tools are announced beside the satellite's own; if none does, the duck listens and answers but cannot be sent anywhere, and says so. The two repos share no code — only robotd's protocol — so neither has to be cloned to install the other. What the navigation does, and three weeks of measurements on the MuJoCo twin, are documented in that repo. - A browser page for the duck's daemons is a third repo, quack-control: the map, places, exploration and the camera today; quacksat shows there as "not available" until it exposes a control socket of its own (status, configuration, later the chat — see docs/todo.md).
- Places, mapping and journeys: the duck learns the name of where it
stands ("this is the kitchen"), answers "where are you", maps a house
on its own and walks to a place it knows — all of it through the
navigation daemon's tools (
robot.where_am_i,robot.remember_place,robot.map_status,robot.map_step,robot.map_explore,robot.go_to, the map library), spliced into the catalog the agent sees. Names are attached to map coordinates, never recognized by sight. - What the duck says on its own (
[announce], 2026-10-03): a journey answers at once and runs on in quack-navd, so the satellite followsrobot.map_status(every 2 s while something moves, every 10 s otherwise, on a lane of its own) and says how a job it started ended, in a short fixed phrase — "Sono arrivata in cucina", "Non trovo una strada per la cucina", "Mi sono fermata", "Ho esplorato per 10 minuti, la casa è mappata al 72 per cento" — and when quack-navd moves the duck on its own: the homecoming's search at boot ("Non sono sicura di dove sono: mi guardo intorno" … "Mi sono ritrovata"), the relocalization before a journey ("Prima di partire mi guardo intorno per ritrovarmi"). Italian and English. Said between turns, never over anyone, each once:directspeaks with its own TTS,agenthands the sentence to the bridge as asay(protocol v1.1), which a bridge that does not list the feature never receives. A stop the agent itself sent is not said again: its reply already says it. - Turn-taking that survives the duck's own voice: one microphone, no echo cancellation and a speaker beside it (ADR 0003), so after the acknowledgement the satellite throws away the tail its own speaker is still emitting and then holds the mic open for three seconds whatever it hears — speech is waited out to its end. Found by talking to it: before this, a turn closed on the quack and the transcriber answered the silence with a hallucination.
- Pinned to microduck
daemon-v0.14.4(2026-09-23): builds and tested against the current release. Since 0.14 a robot's skills are its own config, sorobot.skillannounces and enforces the list the robot reports (robot.skills) rather than five names compiled in — re-read whenever the request lane is dialled again, which it now is: onerobotd::Lanebehind every backend, so robotd restarting under the satellite no longer leaves the robot tools answering "robot unreachable" — or, on the Home Assistant path, the duck mute and still while the pipeline carries on. What that jump cost is written up indocs/study/microduck-ipc-and-packaging.md.
Every release carries an install package for the duck's board:
quacksat-<version>-aarch64-linux.tar.gz with its .sha256, from
the releases page.
Download, verify, unpack, then from your computer:
./install-on-duck.sh microduck@<duck> # --dry-run first prints what it would doNo checkout and no build: it installs the binary, the systemd unit, the service account and the example config, and downloads the wake-word models on the duck. Every step, and what to set in the config (the backend, the endpoints and their keys, the wake word, the audio, quack-navd's socket, the duck's own announcements), is in the package's README-install.md. The bridge is not in it: it runs on a server, from a checkout of the same tag (below).
The duck's board is a Radxa Zero 3 (aarch64 Rockchip RK3566) running
Armbian with the Debian 13 userland. The cross-build needs no Docker:
cargo-zigbuild links with zig cc against a glibc floor of 2.31, so the
binary loads on the board whatever glibc the build host has (on a Mac:
brew install rustup zig cargo-zigbuild, then
rustup toolchain install stable --target aarch64-unknown-linux-gnu):
scripts/cross-build.sh # cross-build the release binary
scripts/install-on-duck.sh microduck@<duck> # install everything over sshThe installer (the same script as in the release package; --dry-run
prints every command and connects to nothing) installs the binary
(/usr/local/bin/quacksat), the systemd unit and its unprivileged
service account, a default config at /etc/robot/quacksat.toml (only
when there is none — edit it there; mode 0640 root:quacksat, as it will
hold API keys), and the wake-word models in /var/lib/quacksat/models
— including "hey Daffy", quacksat's own wake word, which ships in
this repo (models/hey_daffy.onnx); any extra model in your local
models/ (e.g. one trained per docs/custom-wake-word.md) rides along,
and the shared feature models missing there are downloaded on the duck
by scripts/fetch-wake-models.sh, checked against their sha256.
scripts/package.sh <version> <binary> <outdir> packs the release
package locally. Then:
ssh microduck@<duck> journalctl -u quacksat -fPick the backend in the config: wyoming needs nothing else on this
list; agent needs a running bridge (below); direct needs three
OpenAI-dialect endpoint URLs.
On any machine with Python 3.11+ (typically your always-on server):
cd bridge
cp config.example.toml config.toml # then edit: LLM/STT/TTS urls + keys
python3 -m venv .venv && .venv/bin/pip install websockets "mcp>=2" uvicorn
.venv/bin/python bridge.py --config config.tomlPoint the satellite at it ([agent] url = "ws://<bridge-host>:8765").
--fake instead of --config exercises the whole protocol with no AI
services. Details and provider profiles: bridge/README.md.
cd bridge
cp config.example.toml config.toml # then edit it
docker compose up -d --build
docker compose logs -f bridgePorts: 8765 (satellite WebSocket), 8766 (MCP server when [mcp] is
enabled). Protocol smoke test without AI services:
docker compose run --rm --service-ports bridge python bridge.py --fake.
robotd --fake (from a pollen-robotics/microduck checkout) stands in
for the real robot, or forward the real socket:
ssh -L /tmp/robotd.sock:/run/robotd.sock <duck>On macOS the mic and speakers work through sox — see the
capture_command / playback_program hooks in quacksat.example.toml.