Skip to content

Repository files navigation

Stream Deck Code Companion

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.

Requirements

  • 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.

Install

npm install
npm run build       # compiles the plugin
npm run validate    # sanity-checks the bundle
npm run link        # registers it with the Stream Deck app

The keys appear under Code Companion → Session in the Stream Deck app.

Configure your keys

  1. Drag Code Companion → Session onto a key. Repeat for each session you want to track (up to 6 on a Mini).
  2. 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.)
  3. Optionally pick a different animation per state (Busy, Idle, Attention, Error, Disconnected, Sleep) from the dropdowns — and add your own; see Custom animations.
  4. Sleep timer (min): how long a connected session may sit idle before the key shows the Sleep animation. Blank or 0 = never sleeps.
  5. 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.

Launch Claude bound to a key

Tag the session with the matching name:

CLAUDE_BUTTON=frontend claude

If 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.

Press to focus

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.

Custom animations

Any state can show your own animation — a looping sequence of full-key frames, so you can draw anything, not just the robot.

  1. In a key's settings, click Open animations folder (it's created for you and opens in Finder).
  2. Drop in a folder of frames plus a small animation.json.
  3. 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.

Hooks

State changes are driven by Claude Code hooks calling a small notifier script.

  • Script: hooks/notify-streamdeck.sh → install to ~/.claude/hooks/ and chmod +x it.
  • Wire it into ~/.claude/settings.json for these events: UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SessionStart, SessionEnd, and — for the error/sleep states — PostToolUseFailure and StopFailure.

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.

Remote sessions (SSH)

The plugin listens on one local port (127.0.0.1:8765 by default). Remote Claude Code reaches it through a tunnel. Two options:

Option A — SSH reverse tunnel (per-connection, zero standing infra)

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:8765

On the remote, the default endpoint now tunnels straight back to the Mac — no config change needed. Tied to the SSH session.

Option B — WireGuard / Pangolin (persistent, survives reconnects)

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 editing bridge.json: streamdeck restart io.dmetr.code-companion.

Flagging errors

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.

Configuration reference

~/.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

Verify it works

  1. Place two keys named a and b.
  2. CLAUDE_BUTTON=a claude in one dir, CLAUDE_BUTTON=b claude in another.
  3. Trigger a tool in a → key a animates busy; b is unchanged.
  4. Drive a to a permission prompt → key a shows ?; answer it → busy → idle on stop.
  5. 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"}'

Troubleshooting

  • Keys don't react: confirm the plugin is running (streamdeck list), the Stream Deck app is ≥ 7.1, and the key's name matches CLAUDE_BUTTON exactly.
  • Nothing on remote: test the tunnel — curl localhost:8765/health from the remote should return the Mac's health response.
  • Logs: io.dmetr.code-companion.sdPlugin/logs/.

What it doesn't do

  • 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages