Bolt Desktop Pet
A little animated pet that lives on your desktop and is just a face on top
of your Bolt server — same brain, memory, tools, and persona as Discord
chat and the Linux desk client. It talks to ai/desk_api.py on the server
exactly the way desk_client/bolt_desk.py does; this project only adds the
on-screen pet and swaps Deepgram/ElevenLabs playback to be cross-platform
(no mpv/ffplay/espeak-ng subprocess calls — pure sounddevice).
mic → wake-phrase spotter ("thunderbolt") / hotkey / click → record utterance
→ Deepgram STT (+ the focused window's title, for "what's this error?")
→ POST /desk/converse on your Bolt server → [server may relay a shell
command back to run on THIS machine, or a `petctl` command that moves
or emotes the pet] → reply → ElevenLabs streaming TTS → speakers
→ shown in a speech bubble + the pet's sprite state (idle/listening/
thinking/talking) updates the whole time
Nothing is sent to the server until the wake phrase fires, you press the push-to-talk hotkey, or you click the pet — plus, if you turn them on, the heartbeat and the desktop-notification bridge.
Why a separate project instead of living in the tmn-api repo
This runs on your desktop machine, not the server — same relationship as
desk_client/ (Linux) or the Android app, both of which are just clients of
the desk API over HTTP. It has no import dependency on the server repo at
all, so it can be copied anywhere and configured with its own .env.
Setup
-
Copy this whole
bolt-pet/folder to the machine you want the pet to run on (if that isn't already this machine). -
cp .env.example .envand fill in:BOLT_SERVER_URL+DESK_API_KEY— same asdesk_client/.envon the server side. Use the server's masterDESK_API_KEY, or mint yourself a personal one via the desk-onlyapi_key_generatemarker (see the main repo'sCLAUDE.md→ "Per-user API keys").DEEPGRAM_API_KEYfor STT.ELEVENLABS_API_KEY+ELEVENLABS_VOICE_IDfor TTS (optional — falls back to offline TTS viapyttsx3if omitted or if a request fails).
-
Run it:
- macOS/Linux:
./run.sh - Windows:
run.bat
Both scripts create a local
.venvand installrequirements.txton first run. On Linux you'll also need system packages for audio:sudo apt install libportaudio2 espeak-ng. - macOS/Linux:
The pet appears near the bottom-right of your screen. It wanders off on its own now and then; drag it anywhere and it tucks itself flush against a nearby screen edge. That position isn't saved across restarts (see Known limitations).
Talking to it
- Say "thunderbolt" — detected fully on-device by a custom-trained
openWakeWord model (
thunderbolt.onnx, ships in the project root), the same way the server repo'sdesk_client/bolt_desk.pydetects "hey bolt" withbolt.onnx. Matches theDEFAULT_WAKE_WORDalready used for Bolt's Discord voice channels, so it's the same word everywhere. - Or press Ctrl+Alt+Space (
PUSH_TO_TALK_HOTKEY) from anywhere — useful in a noisy room where the wake word misfires. Needspynputand a session that allows global key hooks; most Wayland sessions don't, in which case it logs why at startup and everything else still works. - Or just click the pet once (a drag doesn't count as a click).
- Talk over it to cut a long answer short — the mic stays live while it
speaks, and barging in starts your next turn immediately (
BARGE_IN). - Right-click the tray icon for Talk now, Mute mic, Nap, Wander around, Click through the pet, History…, Wake word tuning…, Use default voice and Quit — the pet window itself has no title bar or taskbar entry.
- Click the speech bubble to copy what it just said; the tray's
History… window keeps the last
HISTORY_LIMITturns.
What it does on its own
- Wanders the desktop while idle (
PET_WANDER), stands still while listening/thinking/talking or while a bubble is up. - Moves and emotes on command. Bolt can relay
petctl move top-left,petctl emote wave|hop|spin|nod|shake,petctl say ...,petctl wander on|off,petctl nap on|off,petctl voice reset, anddialoguectlfor a multi-voice scene. These are intercepted here and never reach a shell. - Naps during
QUIET_HOURS(e.g.23:00-08:00) or while a fullscreen app is focused (DND_ON_FULLSCREEN) — it dims, stops wandering, and makes no proactive noise. It still answers when you speak to it. - Reacts to desktop notifications if you turn on
NOTIFICATION_BRIDGE(Linux/D-Bus) and set aNOTIFICATION_FILTERregex — matching notifications get forwarded to the server, so it can tell you the deploy went green. Off by default: each one costs a round trip.
Speaking in another voice
Ask for a different voice — "use a clearer voice", "talk like a pirate", "say
that in Japanese" — and Bolt searches the ElevenLabs voice library on the
server, picks one, and tags his reply with it (speak_as); the pet is what
actually speaks in it. A Voice Library pick is added to your ElevenLabs
account automatically the first time it's used, and non-English replies (or
any picked voice) go through ELEVENLABS_MULTILINGUAL_MODEL_ID rather than
the English-only eleven_flash_v2 default.
The new voice stays on for the rest of the conversation, because the
server tags a single reply and doesn't remember which voice it chose — so
"keep talking like that" would otherwise send it hunting for a voice again.
To get his own voice back: ask him ("use your normal voice" — he relays
petctl voice reset), use Use default voice in the tray menu (greyed
out unless a picked voice is active), or restart the pet. Set
VOICE_STICKY=false in .env if you'd rather each pick lasted exactly one
reply.
Multi-voice dialogue
Ask for a scene — "do the argument between the two of them", "read that back
as a radio play" — and Bolt can relay a dialoguectl command that the pet
renders through ElevenLabs' Text to Dialogue endpoint: several voices in one
take, with delivery tags the v3 model acts on ([cheerfully], [whispering],
[stuttering]). One request per scene, so the voices actually react to each
other instead of sounding like clips glued together.
Name the cast in .env (DIALOGUE_VOICES=narrator:9BWts…,villain:IKne3…);
the name self always means whatever voice the pet is currently using, so
Bolt sounds like himself in his own scenes — including after a speak_as
switch. Scenes show up in the speech bubble with the tags stripped, count as
normal speech for the transcript, and can be talked over like any other reply.
DIALOGUE=false turns the whole thing off on this device.
Wake-word detection
bolt_pet/audio/wake_word.py feeds every mic frame into thunderbolt.onnx
via the openWakeWord runtime (ONNX inference) and treats any class score at
or above WAKE_WORD_THRESHOLD (default 0.5) as a detection — the exact
same per-frame predict()/reset() pattern as desk_client/bolt_desk.py's
main loop. Point WAKE_MODEL_FILE in .env at a different .onnx model to
change the wake phrase later without touching any other code.
If it keeps ignoring you (or firing at the TV), open Wake word tuning…
from the tray: it shows the peak score while you talk and a rolling list of
near misses — frames that scored just under the threshold — and the slider
takes effect immediately, mid-listen. Set the threshold just below the peak
you can hit reliably, then write it into .env as WAKE_WORD_THRESHOLD.
Project layout
bolt_pet/
config.py .env loading (same pattern as desk_client/bolt_desk.py)
state.py PetState enum + a small transition-checked state machine
server_client.py /desk/converse, /desk/tool_result, /desk/report_status
controller.py the pipeline: wake word -> STT -> server -> TTS, on a QThread
speech_text.py strips markdown/emoji/URLs so the voice never says "asterisk"
pet_actions.py petctl move/emote/say/wander/nap parsing
screen_context.py active-window title + fullscreen detection
quiet.py quiet-hours schedule
notifications.py desktop notification bridge (Linux/D-Bus)
history.py rolling conversation transcript
hotkey.py global push-to-talk (pynput, optional)
audio/
mic.py input stream + energy-based VAD utterance capture
wake_word.py openWakeWord thunderbolt.onnx detection (see above)
stt.py Deepgram
tts.py ElevenLabs streaming PCM, offline pyttsx3 fallback
barge_in.py "you started talking" detector, to cut playback short
ui/
app.py wires QApplication + window + tray + controller thread together
pet_window.py frameless/translucent/always-on-top sprite window + speech bubble
sprite.py frame animation loader (see assets/sprites/README.md)
tray.py system tray menu
history_window.py conversation scrollback (copyable)
wake_tuner.py live wake-word threshold + near-miss log
assets/sprites/ Kenney robot-pack art (CC0) — see assets/sprites/README.md
scripts/
slice_spritesheet.py cuts a grid sprite sheet into the per-frame convention
tests/ pure-logic unit tests (state machine, wake-phrase
matching, HTTP client against mocks) — nothing here
needs real audio hardware or a display
Security notes
Same as desk_client/bolt_desk.py: the server can relay a shell command
back to this machine ("full desktop control" — "open firefox", "how full is
my disk", etc.), which this client executes as your desktop user with a
30-second timeout (COMMAND_TIMEOUT_SECONDS). That's the same trust model
as the Linux desk client and the Android app — commands only ever originate
from your own voice/click requests in your own session. Keep DESK_API_KEY
private; don't expose the desk API port to the open internet.
petctl commands (move/emote/say/wander/nap) are handled inside the pet and
never reach a shell, so that channel can't run anything.
Two features widen what leaves this machine, both off-switchable in .env:
SCREEN_CONTEXT=true (default) appends the focused window's title to what
you say — titles often contain file paths, document names or email subjects —
and NOTIFICATION_BRIDGE=false (default) can forward matching desktop
notifications. No screenshots or images are ever sent.
Known limitations / not-yet-done
- Pet screen position isn't persisted across restarts.
- Wandering is a straight walk to a random point — no Shimeji-style physics, wall-climbing or falling.
- Push-to-talk and the notification bridge are platform-limited: the hotkey needs a session that allows global key hooks (most Wayland setups don't), and the notification bridge is Linux/D-Bus only.
- Barge-in listens through the same mic that hears the pet's own voice. It
wants headphones or a decent gap between speaker and mic; if playback
interrupts itself, raise
BARGE_IN_RMS_THRESHOLDor setBARGE_IN=false. - Screen context is the window title only — the desk API takes text, so there's no screenshot understanding.