This is a human-focused project, made by humans, for humans, that is openly
AI-assisted. If you are an AI agent working here, the project's AI policy
(AI_TRANSPARENCY.md) is binding on you, not just
descriptive. The core rule: anything you author is acknowledged as such. In
practice:
- Keep the
Co-Authored-By:trailer naming your model on every commit you help write (the global commit/PR conventions already require this — honour them, never strip them). - Note AI assistance in PR bodies you draft, and mark substantial AI-drafted docs as such.
- Never present agent work as unaided human work, and never remove existing
attribution. Leave the human-accountability chain intact: you propose, a human
reviews and is accountable for what lands. Don't merge to
mainunattended.
Your self-hosted StackChan robot assistant. A fully self-hosted voice stack for the M5Stack StackChan desktop robot. The default persona is "Dotty" (customizable via make setup). Voice I/O routes through a self-hosted xiaozhi-esp32-server; the brain is a pi coding agent running in the dotty-pi container. No cloud AI services — fully self-hosted except for the LLM call (replaceable with local Ollama).
The voice path runs through a single LLM provider — PiVoiceLLM, selected via selected_module.LLM in data/.config.yaml. One alternate provider ships as a fallback (OpenAICompat). (The former Tier1Slim two-tier provider was removed in the 2026-05-29 alignment pass — its tool escalation depended on the retired ZeroClaw bridge.)
StackChan hardware → configured persona
│ ESP32-S3, xiaozhi firmware (built from m5stack/StackChan source)
│ WiFi / WebSocket (Xiaozhi protocol)
▼
xiaozhi-esp32-server (Docker)
├─ ASR: FunASR SenseVoiceSmall / WhisperLocal (local)
├─ TTS: LocalPiper; EdgeTTS / StreamingEdgeTTS alternates
└─ LLM: PiVoiceLLM
│ PiClient → `docker exec -i dotty-pi pi --mode rpc …` (JSONL over stdio)
▼
dotty-pi container — the pi coding agent (the brain)
├─ outer loop: qwen3.5:4b on llama-swap
└─ dotty-pi-ext extension → 7 voice tools:
memory_lookup · remember · recall_person · remember_person · think_hard (→ qwen3.6:27b-think) · take_photo · play_song
only TTS-bound text streams back to xiaozhi-server
Perception + ambient behaviour: firmware `event` frames → xiaozhi relay → dotty-behaviour (FastAPI, :8090)
Admin dashboard: bridge.py (FastAPI, :8081, served at /ui)
All four server-side services — xiaozhi-server, dotty-pi, dotty-behaviour, and the bridge.py dashboard — run as Docker containers on a single Docker host.
Smart-mode currently flips behaviour but not the backend model — the model-swap path was dropped in the #36 cutover and is v2 scope (see docs/cutover-behaviour.md).
Cutover note: until the #36 cutover (executed 2026-05-19) the brain was ZeroClaw, a Rust AI-agent fronted by a FastAPI bridge on a separate Raspberry Pi. That path — ZeroClaw, the ACP protocol, the
ZeroClawLLMprovider, and the RPi host — has been retired.bridge.pysurvived as the dashboard service; its voice and perception roles moved todotty-pianddotty-behaviour. Historical record:docs/cutover-behaviour.md.
See README.md for the full visual architecture and message-flow diagrams.
- Admin workstation (this machine): Development/admin workstation. Runs Claude Code sessions.
- Docker host: runs xiaozhi-esp32-server,
dotty-pi,dotty-behaviour, and thebridge.pydashboard — all as containers. Any Linux box with Docker works. Reachable on the LAN (and optionally Tailscale). - StackChan: On LAN WiFi only (not on Tailnet). Needs LAN IPs for OTA and WebSocket.
SSH access is via Tailscale hostnames. Discover actual Tailscale hostnames at runtime with tailscale status.
This repo uses placeholders (<XIAOZHI_HOST>, <XIAOZHI_USER>, <XIAOZHI_PATH>, etc.) everywhere real values would normally appear — see the "Configuring for your environment" section of README.md for the full list.
- xiaozhi-server install dir (on the Docker host):
<XIAOZHI_PATH>(e.g./opt/xiaozhi-server/) - Custom LLM provider (on the Docker host): mounted into the xiaozhi container at
/opt/xiaozhi-server/core/providers/llm/pi_voice/ - dotty-pi / dotty-behaviour / bridge.py: each deployed as its own container on the Docker host (see their respective
README.mdfiles; deploy viascripts/deploy-behaviour.shandscripts/deploy-bridge-unraid.sh) - This project dir: wherever you cloned
dotty-stackchan
| Service | Host | Port | Protocol |
|---|---|---|---|
| xiaozhi WebSocket | Docker host LAN IP | 8000 | ws:// |
| xiaozhi OTA/HTTP | Docker host LAN IP | 8003 | http:// |
dashboard service (bridge.py) |
Docker host LAN IP | 8081 | http:// (/ui) |
| dotty-behaviour (perception, vision, greeter) | Docker host LAN IP | 8090 | http:// |
.config.yaml(repo root; deployed to the Docker host asdata/.config.yaml) — the xiaozhi-server override config. Never overwrite wholesale on upgrades; merge keys.custom-providers/pi_voice/— thePiVoiceLLMprovider +PiClient, the default voice path. xiaozhi-server's LLM call is translated into a pi RPC request and run inside thedotty-picontainer viadocker exec -i dotty-pi pi --mode rpc …; pi owns the agent loop and tools, and only TTS-bound text streams back. Selected whenselected_module.LLM = PiVoiceLLM. Requires the host docker socket bind-mounted into the xiaozhi container — seecustom-providers/pi_voice/README.md.custom-providers/edge_stream/edge_stream.py— custom streaming TTS provider. Mounted similarly.custom-providers/openai_compat/openai_compat.py— OpenAI-compatible LLM provider; the alternate voice backend toPiVoiceLLM(point it at a local llama-swap endpoint or any OpenAI-compatible API). Selected whenselected_module.LLM = OpenAICompat.custom-providers/piper_local/piper_local.py— local Piper TTS provider (offline alternative to EdgeTTS).custom-providers/asr/fun_local.py— patched FunASR provider. Adds alanguageconfig key (upstream hardcodes"auto", which mis-detects Korean/Japanese on unclear English). Mounted as a file-level override over the upstream provider.custom-providers/xiaozhi-patches/{http_server,websocket_server,portal_bridge}.py— drop-in overrides against upstream xiaozhi-server. Add the/xiaozhi/admin/*admin routes (inject-text, abort, set-state, set-toggle, set-head-angles, take-photo, play-asset, songs catalogue, say) and theactive_connectionsregistry that lets admin routes reach a live device WS. (Theset-tier1slim-modelroute andshared_llmsingleton were removed with Tier1Slim in the 2026-05-29 alignment pass.)bridge.py— the admin dashboard service (FastAPI, port 8081, served at/ui); runs as a container on the Docker host (build viabridge/Dockerfile, deploy viascripts/deploy-bridge-unraid.sh). Its former voice and perception-bus roles were retired in #36; the dashboard now pulls its perception/vision/audio cards fromdotty-behaviour(#115 series). Supporting modules live underbridge/.dotty-pi/— Docker image + compose for the pi agent container (the brain). Seedotty-pi/README.md.dotty-pi-ext/— pi extension providing the seven voice tools (memory_lookup,remember,recall_person,remember_person,think_hard,take_photo,play_song), loaded into thedotty-piagent. (recall_person/remember_personwere added in #53.)dotty-behaviour/— FastAPI service (port 8090): the perception event bus, ambient consumers, vision/audio explain endpoints, the proactive greeter, and calendar context. Successor to the bridge's perception role. Seedotty-behaviour/README.md.personas/default.md— default robot persona prompt (swappable).session-prompt.md— Claude Code session prompt for infrastructure setup.
The LLM response MUST start with an emoji. The xiaozhi firmware parses it into a face animation: 😊=smile 😆=laugh 😢=sad 😮=surprise 🤔=thinking 😠=angry 😐=neutral 😍=love 😴=sleepy
Two layers enforce this on the live PiVoiceLLM path:
- The pi agent's persona prompt (the configured persona) — primary source.
- xiaozhi-server top-level
prompt:indata/.config.yaml— injected as a system message.
The old third layer — a _ensure_emoji_prefix fallback in bridge.py — only ran on the retired ZeroClaw voice path; PiVoiceLLM has no equivalent, so the persona prompts are load-bearing.
custom-providers/— all custom ASR/LLM/TTS providers (mounted into the xiaozhi container)bridge/— supporting modules for thebridge.pydashboard service (dashboard UI, templates, static assets, CSRF, metrics)dotty-pi/,dotty-pi-ext/,dotty-behaviour/— the pi agent container, its voice-tool extension, and the perception/greeter service (see Config Files above)firmware/— StackChan firmware patches, remote config, and server-side OTA assetspersonas/— swappable robot persona promptsdocs/— deep technical reference (architecture, hardware, protocols, brain, latent capabilities)
Run make help for the full list. Key targets:
make setup— interactive first-run wizard (substitutes placeholders, fetches models, starts containers)make doctor— health checks on config, models, and servicesmake fetch-models— download SenseVoiceSmall + Piper voice modelsmake up/make down/make logs/make status— docker compose shortcuts
- Change TTS voice: Edit
data/.config.yamlon the Docker host. For the defaultLocalPiper, swap thevoice+model_path+config_pathkeys (download a new.onnx/.onnx.jsonpair intomodels/piper/). ForEdgeTTS/StreamingEdgeTTSalternates, changeTTS.EdgeTTS.voice/TTS.StreamingEdgeTTS.voiceand switchselected_module.TTS. Restart container. - Change system prompt: Edit
data/.config.yamlon the Docker host, top-levelprompt:block. Restart container. - Check logs:
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs -f xiaozhi-esp32-server' - Restart pipeline:
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'cd <XIAOZHI_PATH> && docker compose restart' - Test the dashboard service:
curl http://<XIAOZHI_HOST>:8081/health - Test dotty-behaviour:
curl http://<XIAOZHI_HOST>:8090/health
The firmware/ directory at the root of this repo is a git submodule that pins a release of the StackChan firmware fork (BrettKinny/StackChan @ dotty). It exists so the public repo has a reproducible firmware-version pointer; updating it is a release action, not a development action. The build commands below operate inside that submodule and are appropriate for users who only have this repo cloned.
If you maintain a separate firmware checkout for active development (recommended for non-trivial firmware work — keeps the submodule clean and avoids accidental commits into a release pin), point the same docker run invocations at that checkout instead and bump the submodule pointer here only when cutting a release.
Build + flash the StackChan firmware locally with the cached IDF container — no GHA round-trip needed for dev cycles.
cd firmware/firmware
# Build (≈5 min cold, faster incremental). fetch_repos.py clones
# upstream xiaozhi-esp32 v2.2.4 and applies patches/xiaozhi-esp32.patch.
docker run --rm -v "$PWD:/project" -w /project \
espressif/idf:v5.5.4 bash -lc \
'git config --global --add safe.directory "*" && python fetch_repos.py && idf.py build'
# USB-C flash (device shows up as /dev/ttyACM0).
docker run --rm -v "$PWD:/project" -w /project \
--device=/dev/ttyACM0 espressif/idf:v5.5.4 \
bash -lc 'idf.py -p /dev/ttyACM0 -b 921600 flash'Gotchas hit in real sessions:
- CMake GLOB cache: when adding a new
.cpp/.hundermain/stackchan/,idf.py buildwill silently not compile it and you'll get a linker error likeundefined reference to '...'. Force a reconfigure withtouch main/CMakeLists.txtthen rebuild — or runidf.py reconfigureonce. %lldprintf: ESP-IDF newlib's printf doesn't reliably honour%lldin this build. Use%.0fwith adoublecast for >32-bit integers, or manually split the value.- Upstream xiaozhi-esp32 changes go through
firmware/firmware/patches/xiaozhi-esp32.patch, not directly into the working tree (whichfetch_repos.pyre-fetches). After editing the upstream tree locally for a build, regenerate withgit -C firmware/firmware/xiaozhi-esp32 diff HEAD > firmware/firmware/patches/xiaozhi-esp32.patch. Verify the patch applies cleanly to a freshv2.2.4checkout before committing. /dev/ttyACM0disappears after a hard reset / power cycle; ifdocker runcomplains "no such file", either re-plug the USB-C cable or wait for the device to finish booting back into the JTAG-Serial endpoint.
Forward-looking modes (face-detected greeting, sound-direction head-turn, future curiosity / boredom mode) all subscribe to a single perception event bus in dotty-behaviour. Producers are firmware-resident and emit JSON event frames over the WS:
{"type":"event","name":"face_detected","data":{}}
{"type":"event","name":"face_lost","data":{}}
{"type":"event","name":"sound_event","data":{"direction":"left","balance":0.997,"energy":1807933247}}Plumbing:
- Firmware emit:
Application::SendEvent(name, data_json)in upstreamapplication.cc(lazy-opens the WS viaOpenAudioChannel()because xiaozhi WS is otherwise session-scoped — without lazy-open, perception events from idle silently drop). - xiaozhi-server relay: custom override at
custom-providers/xiaozhi-patches/textMessageHandlerRegistry.pyadds anEventTextMessageHandlerthat POSTs each event frame todotty-behaviour's/api/perception/event. - dotty-behaviour bus: the perception event bus + per-device state live in
dotty-behaviour(perception/state.py,perception/snapshot.py). - Consumers (
dotty-behaviour/consumers/):face_greeter(Hi! greeting via/xiaozhi/admin/inject-text),sound_turner(head-turn via/xiaozhi/admin/set-head-angles),face_lost_aborter(TTS abort when audience walks away), and six more — seedotty-behaviour/README.md.
WS lifecycle is the structural fact most easily forgotten: xiaozhi only opens the WS during a conversation, not persistently. Anything that needs to fire a server-bound event from idle has to either (a) trigger OpenAudioChannel() first or (b) accept that events are session-only. Producer A and B both assume (a) — done in SendEvent.
The Phase 4 firmware StateManager (firmware/main/stackchan/modes/state_manager.{h,cpp}) is a producer too — it emits state_changed on every mutex-state transition (idle / talk / story_time / security / sleep / dance) so dotty-behaviour consumers can gate behaviour on state. dotty-behaviour tracks per-device current_state from those events.
docs/modes.md is the authoritative source for the six-state mutex (idle / talk / story_time / security / sleep / dance), the orthogonal toggles (kid_mode, smart_mode), the LED contract (state arc on left ring 0-5; face-state / kid / smart / listening indicators on right ring 6 / 8 / 9 / 11 with reserved pixels at 7 / 10 — all six right-ring pixels owned by StateManager and re-asserted at 5 Hz), the voice-phrase triggers, and the per-state backing-architecture (which states use the pi agent vs direct OpenRouter). When adding behaviour that responds to or changes Dotty's mode, read modes.md first — don't reinvent.
For hardware specs, protocol details, model internals, latent capabilities, and the behavioural mode + LED contract, see docs/README.md and its linked files (architecture.md, hardware.md, voice-pipeline.md, brain.md, protocols.md, modes.md, latent-capabilities.md, references.md).
- xiaozhi-esp32-server: https://github.com/xinnan-tech/xiaozhi-esp32-server
- xiaozhi-esp32 firmware (upstream): https://github.com/78/xiaozhi-esp32
- StackChan (hardware + firmware patches): https://github.com/m5stack/StackChan
- Emotion protocol: https://xiaozhi.dev/en/docs/development/emotion/
Issues live as GitHub issues on BrettKinny/dotty-stackchan (the origin remote), managed via the gh CLI. See docs/agents/issue-tracker.md.
Five canonical triage roles use their default label strings (needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix), orthogonal to the existing status:* / area:* labels. See docs/agents/triage-labels.md.
Single-context: one CONTEXT.md + docs/adr/ at the repo root. See docs/agents/domain.md.