Turn a Stream Deck into an always-visible status surface for your Claude Code sessions. Each key tracks one named session and shows its live state as a little retro-robot whose screen changes with what the session is doing:
| State | What the key shows | When |
|---|---|---|
| Busy | scrolling oscilloscope (or matrix rain / code keywords) | the session is working — you submitted a prompt or a tool is running |
| Attention | an amber ? (static, pulsing, or wiggling) |
the session is waiting on you — a permission or input prompt |
| Idle / ready | a calm robot face that blinks now and then | the session just finished its turn |
| Sleep | the robot asleep with rising zs |
the session has been waiting a while (Claude's idle prompt) or sat past your sleep timer |
| Error | red-X eyes with smoke | a tool failed, the turn errored, or a script flagged it (see Flagging errors) |
| Disconnected | a dim dot (or a grey robot outline) | no session is connected |
Updates are event-driven via Claude Code hooks — no polling. Works identically for local sessions and remote sessions over SSH. Pressing a key brings the session's terminal/editor forward so you can answer.
- Stream Deck app v7.1 or newer.
⚠️ The plugin's Node backend requires it; on 7.0.x it will not load. Update via the Stream Deck app (Preferences → check for updates) or re-download from Elgato. - Node.js v24+ — only to build the plugin (the Stream Deck app runs its own Node).
- Claude Code — the sessions you want to track.
@elgato/cli:npm install -g @elgato/cli.
npm install
npm run build # compiles the plugin
npm run validate # sanity-checks the bundle
npm run link # registers it with the Stream Deck appThe keys appear under Code Companion → Session in the Stream Deck app.
- Drag Code Companion → Session onto a key. Repeat for each session you want to track (up to 6 on a Mini).
- Select the key and, in the settings panel on the right, set Session name —
e.g.
frontend,remote-api. This is the name the key watches. (An unconfigured key shows a dashed "set name" placeholder.) - Optionally pick a different animation per state (Busy, Idle, Attention, Error, Disconnected, Sleep) from the dropdowns — and add your own; see Custom animations.
- Sleep timer (min): how long a connected session may sit idle before the key
shows the Sleep animation. Blank or
0= never sleeps. - Busy timeout (min): safety net for interrupted turns — if the key stays Busy
this long with no new activity, it returns to Idle. Claude Code fires no hook when
you interrupt (Esc), so without this an abandoned turn can sit Busy until your next
prompt. Set it above your longest normal tool run; blank or
0= off.
Tag the session with the matching name:
CLAUDE_BUTTON=frontend claudeIf CLAUDE_BUTTON is unset, the key name falls back to the basename of the
session's working directory — so running claude inside ~/proj/frontend
lights the key named frontend too.
Tip: set CLAUDE_BUTTON automatically per project with direnv (.envrc:
export CLAUDE_BUTTON=frontend) or a shell alias, so you never have to remember it.
Pressing a key brings the session's app forward (focus-first — answering blind is useless), and it figures out which app on its own — no configuration for a normal local terminal or editor (VS Code, Terminal, iTerm, Ghostty, WezTerm, kitty, Warp, …).
There's an optional Focus on press field — leave it blank for auto-detect; set it only to override, or for cases auto-detect can't cover:
- tmux / screen reparent the process and break detection → set the field to your
terminal app (e.g.
Ghostty). - Remote SSH sessions run in a terminal on the remote machine, which this Mac can't focus → set the field to your local SSH client app.
Either way it focuses the app's frontmost window, not a specific tab or split.
Any state can show your own animation — a looping sequence of full-key frames, so you can draw anything, not just the robot.
- In a key's settings, click Open animations folder (it's created for you and opens in Finder).
- Drop in a folder of frames plus a small
animation.json. - Reopen the settings panel — your animation now appears under Custom in every state's dropdown.
Full format and a ready-to-paste prompt for generating frames with an LLM are in
docs/custom-animations.md.
State changes are driven by Claude Code hooks calling a small notifier script.
- Script:
hooks/notify-streamdeck.sh→ install to~/.claude/hooks/andchmod +xit. - Wire it into
~/.claude/settings.jsonfor these events:UserPromptSubmit,PreToolUse,PostToolUse,Notification,Stop,SessionStart,SessionEnd, and — for the error/sleep states —PostToolUseFailureandStopFailure.
UserPromptSubmit is what flips a key to busy the instant you submit — without
it, busy only fires once a tool runs, so think-first turns look laggy. Claude Code
may need a restart (or a pass through /hooks) to pick up newly added hook events.
The notifier is fire-and-forget (short timeout, output discarded, always exits 0),
so a stopped or unreachable plugin never blocks Claude Code. To set it up on another
machine, copy the script and add the same hook entries to that machine's
~/.claude/settings.json.
The plugin listens on one local port (127.0.0.1:8765 by default). Remote Claude
Code reaches it through a tunnel. Two options:
Forward the port back to your Mac when you connect:
ssh -R 8765:localhost:8765 your-remote-host
# or persist it in ~/.ssh/config:
# Host your-remote-host
# RemoteForward 8765 localhost:8765On the remote, the default endpoint now tunnels straight back to the Mac — no config change needed. Tied to the SSH session.
The remote POSTs to the Mac's tunnel IP, so the plugin must listen on that
interface. Create ~/.config/streamdeck-code-companion/bridge.json on the Mac:
{ "host": "0.0.0.0", "port": 8765, "token": "pick-a-long-random-secret" }Then on the remote, point the notifier at the Mac and pass the token:
export STREAMDECK_BRIDGE_URL="http://<mac-wireguard-ip>:8765/event"
export STREAMDECK_BRIDGE_TOKEN="pick-a-long-random-secret"(Set these in the remote shell profile or .envrc alongside CLAUDE_BUTTON.)
Bind beyond loopback only with a token. With
host: "0.0.0.0"the port is reachable by anything that can route to it; the token check keeps strangers from flickering your keys. Restart the plugin after editingbridge.json:streamdeck restart io.dmetr.code-companion.
Claude Code's hooks don't expose tool exit status, so the Error state isn't auto-detected — you set it explicitly. Handy for showing a failed build on a key:
mycmd || curl -s localhost:8765/event \
-H 'X-Claude-Button: frontend' -H 'X-Claude-State: error' -d '{}'Any of busy, question, idle, offline, error works in X-Claude-State.
~/.config/streamdeck-code-companion/bridge.json (all optional; defaults shown):
| Key | Default | Notes |
|---|---|---|
host |
127.0.0.1 |
set to 0.0.0.0 / tunnel IP for WireGuard |
port |
8765 |
listen port |
token |
(none) | require X-Bridge-Token; strongly advised when not on loopback |
Notifier environment variables (read by notify-streamdeck.sh):
| Var | Purpose |
|---|---|
CLAUDE_BUTTON |
session name (fallback: cwd basename) |
STREAMDECK_BRIDGE_URL |
endpoint (default http://127.0.0.1:8765/event) |
STREAMDECK_BRIDGE_TOKEN |
shared secret for X-Bridge-Token |
- Place two keys named
aandb. CLAUDE_BUTTON=a claudein one dir,CLAUDE_BUTTON=b claudein another.- Trigger a tool in
a→ keyaanimates busy;bis unchanged. - Drive
ato a permission prompt → keyashows?; answer it → busy → idle on stop. - Repeat over SSH with a reverse tunnel → identical behavior.
Quick smoke test without Claude:
curl -s localhost:8765/health
curl -s -X POST localhost:8765/event -H 'X-Claude-Button: a' \
-H 'Content-Type: application/json' -d '{"hook_event_name":"PreToolUse"}'- Keys don't react: confirm the plugin is running (
streamdeck list), the Stream Deck app is ≥ 7.1, and the key's name matchesCLAUDE_BUTTONexactly. - Nothing on remote: test the tunnel —
curl localhost:8765/healthfrom the remote should return the Mac's health response. - Logs:
io.dmetr.code-companion.sdPlugin/logs/.
- It displays state and focuses the session's app — it does not type answers. Use native Stream Deck Hotkey/Text actions to send answers to the focused window.
- It focuses the app's frontmost window, not a specific terminal tab or split.
Building on the plugin or its animations? See README.dev.md.