Reviewed-on: #1
streamdeck-go
Important
This is a sloppy utility that's designed to just work. 406.fail
A lightweight, dotfile-style controller for the Elgato Stream Deck XL on Linux and macOS. No Elgato software required — communicates directly with the device over USB HID.
Features
-
Configure keys with a single YAML file (designed for dotfiles compatibility)
-
PNG, JPEG, and SVG icons, automatically scaled to key size
-
Animated GIF support — frames pre-encoded at startup, cycled at the GIF's native rate
-
Runs any shell command on key press
-
Status/toggle keys — poll any shell command on an interval, swap icons based on output; icon updates on press
-
Live config reload — save your config and the deck updates instantly, no restart needed
-
Privileged commands — run whitelisted root/admin commands; Linux uses a root helper daemon, macOS uses the native admin auth dialog
-
Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume
-
Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
-
No Stream Deck app, no Node.js, no Electron
-
Modules — define reusable, parameterised commands in
modules.yamlwith Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). See Modules. -
Interactive config builder — TUI tool (
streamdeck-init) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See Config builder.
Planned: text/label overlays on keys, multi-page layouts, AUR package — see Roadmap
Architecture
streamdeck-go/
├── cmd/
│ ├── streamdeck/
│ │ └── main.go # Entry point, config watcher, event loop
│ └── streamdeck-helper/
│ └── main.go # Privileged helper daemon (Linux only)
├── internal/
│ ├── config/
│ │ └── config.go # YAML parsing with XDG-aware defaults
│ ├── device/
│ │ └── streamdeck.go # USB HID communication, image encoding, button reads
│ └── modules/
│ └── modules.go # Module registry, template resolution, env/expiry helpers
├── systemd/
│ └── streamdeck-go.service # systemd user service (Linux)
├── launchd/
│ └── com.woodarddigital.streamdeck-go.plist # launchd agent (macOS)
├── config.example.yaml # Starter config (copied to ~/.config on install)
├── modules.example.yaml # Example modules file (Slack integration)
├── Makefile # build / install / uninstall
├── go.mod
└── go.sum
How it works
~/.config/streamdeck-go/config.yaml
~/.config/streamdeck-go/modules.yaml (optional)
│
├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload both → restart run()
│
└──▶ run(ctx, registry)
│
├── module resolution ──▶ key has module/function?
│ └──▶ registry.Resolve() → rendered shell command
│ (templates expanded, env vars resolved)
│
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
│
├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once)
│ └──▶ goroutine/key: loop frames, sleep per-frame delay
│ (cancelled via ctx on reload)
│
├── poll goroutine/key ──▶ exec poll command every interval
│ └──▶ match in stdout? swap icon_true / icon_false
│ (also triggered immediately on button press)
│
└── event loop ──▶ device.ReadButtons() (250 ms timeout, checks ctx)
└──▶ key-down: exec.Command("sh", "-c", command)
HID output reports are mutex-guarded so concurrent animation goroutines never interleave partial image data across keys.
Dependencies
Go packages
| Package | Purpose |
|---|---|
github.com/sstallion/go-hid |
Bindings for libhidapi — USB HID read/write |
github.com/fsnotify/fsnotify |
Config file watching for live reload |
golang.org/x/image |
Bi-linear image scaling |
gopkg.in/yaml.v3 |
YAML config parsing |
github.com/srwiley/oksvg |
SVG rasterisation |
Go stdlib image/gif, image/jpeg, image/png |
Image decoding and JPEG encoding |
System library
libhidapi must be present at runtime:
| Platform | Command |
|---|---|
| macOS | brew install hidapi |
| Arch / Manjaro | sudo pacman -S hidapi |
| Debian / Ubuntu | sudo apt install libhidapi-hidraw0 |
| Fedora / RHEL | sudo dnf install hidapi |
| openSUSE | sudo zypper install libhidapi-hidraw0 |
Installation
macOS
# Prerequisites
brew install go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go
cd streamdeck-go
make install
The binary installs to ~/go/bin/streamdeck-go. Make sure ~/go/bin is in your PATH:
# Add to ~/.zshrc or ~/.bash_profile if not already present:
export PATH="$HOME/go/bin:$PATH"
A launchd agent is installed to ~/Library/LaunchAgents/ and started automatically.
No sudo required for the main binary.
For privileged commands (sleep, reboot, etc.) see Privileged commands — macOS.
Linux — make install (recommended)
An interactive installer that handles everything, including optional dotfiles directory integration.
# Prerequisites — Arch example; adjust for your distro (see table above)
sudo pacman -S go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go
cd streamdeck-go
make install
The installer walks you through the whole setup:
❯ Building streamdeck-go...
✓ Build complete
❯ Checking udev rule...
✓ udev rule already installed — skipping
Config location
─────────────────────────────────────────────
· streamdeck-go stores its config and icons in a single directory.
· You can keep that directory inside your dotfiles repo and symlink it
· into ~/.config — the same pattern used by Hyprland, Waybar, etc.
❯ Use a dotfiles directory? [Y/n]
❯ Path to dotfiles repo [~/dotfiles]:
· Will create:
· ~/dotfiles/.config/streamdeck-go/
· ~/dotfiles/.config/streamdeck-go/config.yaml
· ~/dotfiles/.config/streamdeck-go/icons/
· Will symlink:
· ~/.config/streamdeck-go
· └─▶ ~/dotfiles/.config/streamdeck-go
❯ Confirm? [Y/n]
The resulting structure inside your dotfiles repo mirrors everything else in .config:
~/dotfiles/
└── .config/
├── hypr/
├── waybar/
└── streamdeck-go/ ← lives here, symlinked to ~/.config/streamdeck-go
├── config.yaml
└── icons/
Linux — manual / dev setup
1. udev rule (one-time):
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
| sudo tee /etc/udev/rules.d/99-streamdeck.rules
sudo udevadm control --reload
sudo udevadm trigger
2. Build and run:
git clone https://github.com/WoodardDigital/streamdeck-go
cd streamdeck-go
cp config.example.yaml config.yaml
go run ./cmd/streamdeck/
# Or point at any config file:
go run ./cmd/streamdeck/ -config ~/.config/streamdeck-go/config.yaml
When no -config flag is given the binary checks ~/.config/streamdeck-go/config.yaml
first (respecting $XDG_CONFIG_HOME). The repo's config.yaml is gitignored.
AUR (Arch Linux)
AUR package coming soon. Until then, use
make installabove.
Service management
macOS (launchd)
# Status
launchctl list | grep streamdeck
# Stop / start
launchctl unload ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
launchctl load ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
# Logs (persistent across reboots)
tail -f ~/Library/Logs/streamdeck-go.log
The agent starts automatically at login and restarts if the process exits. Sleep/wake is handled by the reconnect loop in the binary — no extra config needed.
To use a custom config path, edit the installed plist at
~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist and add
-config /path/to/config.yaml to the ProgramArguments array, then reload.
Linux (systemd)
systemctl --user status streamdeck-go # check if running
systemctl --user restart streamdeck-go # restart manually
systemctl --user stop streamdeck-go # stop
journalctl --user -u streamdeck-go -f # follow logs
To use a custom config path:
systemctl --user edit streamdeck-go
Add:
[Service]
ExecStart=
ExecStart=%h/.local/bin/streamdeck-go -config %h/.config/streamdeck-go/config.yaml
Configuration
Config lives at ~/.config/streamdeck-go/config.yaml (or wherever -config points).
Editing and saving the file reloads the deck live — icons update, animations restart,
no service restart required.
icons_dir: ~/.config/streamdeck-go/icons # default; can be any path
brightness: 70 # 0–100
# USB IDs — defaults match Stream Deck XL v2
# macOS: system_profiler SPUSBDataType | grep -A5 "Stream Deck"
# Linux: lsusb | grep Elgato
device:
vendor_id: 0x0fd9
product_id: 0x00ba
# Keys are 0-indexed, left-to-right, top-to-bottom.
# Stream Deck XL layout (8 columns × 4 rows):
#
# 0 1 2 3 4 5 6 7
# 8 9 10 11 12 13 14 15
# 16 17 18 19 20 21 22 23
# 24 25 26 27 28 29 30 31
keys:
0:
icon: ghostty.png # PNG, JPEG, GIF, or SVG — relative to icons_dir
command: ghostty
1:
icon: firefox.png
command: open -a Firefox # macOS; use "firefox" on Linux
8:
icon: loading.gif # animated — cycles at the GIF's native frame rate
command: ""
Status / toggle keys
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs command as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.
keys:
3:
command: pactl set-source-mute @DEFAULT_SOURCE@ toggle
icon_true: mic-muted.png # shown when poll output contains match
icon_false: mic-active.png # shown when poll output does not contain match
poll:
command: pactl get-source-mute @DEFAULT_SOURCE@
interval: 2s # how often to check (default: 2s)
match: "yes" # substring to find in stdout → true
How matching works:
match set? |
True condition | False condition |
|---|---|---|
| Yes | stdout contains the string | stdout does not contain it |
| No (omitted) | command exits 0 | command exits non-zero |
macOS examples:
# Mute microphone (macOS — requires SwitchAudioSource or similar)
3:
command: "osascript -e 'set volume input muted not (input muted of (get volume settings))'"
icon_true: mic-muted.png
icon_false: mic-active.png
poll:
command: "osascript -e 'input muted of (get volume settings)'"
interval: 2s
match: "true"
# VPN toggle (macOS)
4:
command: "osascript -e 'tell application \"Tunnelblick\" to connect \"My VPN\"'"
icon_true: vpn-on.png
icon_false: vpn-off.png
poll:
command: "scutil --nc status \"My VPN\""
interval: 5s
match: "Connected"
Linux examples:
# VPN status (exit-code match — no match string needed)
4:
command: nmcli connection up my-vpn
icon_true: vpn-on.png
icon_false: vpn-off.png
poll:
command: nmcli connection show --active my-vpn
interval: 5s
# Systemd service toggle
5:
command: systemctl --user toggle my-service
icon_true: service-running.png
icon_false: service-stopped.png
poll:
command: systemctl --user is-active my-service
interval: 3s
# Speaker mute
6:
command: pactl set-sink-mute @DEFAULT_SINK@ toggle
icon_true: speaker-muted.png
icon_false: speaker-on.png
poll:
command: pactl get-sink-mute @DEFAULT_SINK@
interval: 2s
match: "yes"
Launching applications
On Linux, GUI apps are typically launched by their binary name (firefox, ghostty, nautilus). On macOS, apps live in /Applications/ as .app bundles and need to be opened differently.
macOS — open -a
The open -a command launches (or focuses) a macOS application by name:
keys:
0:
icon: firefox.png
command: "open -a Firefox"
1:
icon: slack.png
command: "open -a Slack"
2:
icon: ghostty.png
command: "open -a Ghostty"
3:
icon: finder.png
command: "open -a Finder ~/Documents" # open Finder to a specific folder
4:
icon: vscode.png
command: "open -a 'Visual Studio Code'" # quote names with spaces
How open -a works:
- If the app is already running, it brings it to the front (no duplicate launched).
- If the app is not running, it launches it.
- The app name matches what's in
/Applications/minus the.appextension. - To find the exact name:
ls /Applications/orosascript -e 'tell application "System Events" to get name of every process whose background only is false'
Opening files and URLs:
keys:
5:
icon: project.png
command: "open -a 'Visual Studio Code' ~/Projects/myproject" # open a folder in VS Code
6:
icon: notes.png
command: "open ~/Documents/notes.txt" # opens in default app for .txt
7:
icon: github.png
command: "open https://github.com" # opens in default browser
Linux — direct binary
keys:
0:
icon: firefox.png
command: firefox
1:
icon: slack.png
command: slack
2:
icon: ghostty.png
command: ghostty
3:
icon: files.png
command: nautilus ~/Documents
Most Linux desktop apps can also be launched with their .desktop file via gtk-launch:
keys:
4:
icon: vscode.png
command: "gtk-launch code" # uses the .desktop file name (without .desktop)
Cross-platform keys
If you use the same config on both platforms, you can use a shell one-liner:
keys:
0:
icon: browser.png
command: "if [ \"$(uname)\" = \"Darwin\" ]; then open -a Firefox; else firefox; fi"
Terminal & SSH commands
Any shell command works — including launching terminals and SSH sessions.
Open a terminal:
keys:
0:
icon: ghostty.png
command: ghostty # Linux
1:
icon: terminal.png
command: open -a Ghostty # macOS — or: open -a Terminal, open -a iTerm
SSH:
keys:
5:
icon: homeserver.png
command: "ghostty -e ssh user@homeserver" # Linux
6:
icon: homeserver.png
command: "open -a Terminal ssh://user@homeserver" # macOS
SSH agent & the service
The service starts before your shell environment is fully loaded, so
SSH_AUTH_SOCKmay not be set. Fix:Linux:
systemctl --user import-environment SSH_AUTH_SOCKmacOS — add to
~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plistinside the<dict>block:<key>EnvironmentVariables</key> <dict> <key>SSH_AUTH_SOCK</key> <string>/private/tmp/com.apple.launchd.XXXXX/Listeners</string> </dict>Run
echo $SSH_AUTH_SOCKin a terminal to find the correct path.
Common terminal flags:
| Terminal | Flag to run a command |
|---|---|
| ghostty | ghostty -e <cmd> |
| alacritty | alacritty -e <cmd> |
| kitty | kitty <cmd> |
| wezterm | wezterm start -- <cmd> |
| Terminal.app | open -a Terminal <script> |
| iTerm2 | open -a iTerm <script> |
Privileged commands
Use the priv: prefix to run commands that require elevated privileges.
keys:
30:
icon: suspend.png
command: "priv:suspend"
Privileged commands — macOS
No root daemon required. The main process reads
~/.config/streamdeck-go/privileged.yaml and runs the command via osascript,
which shows the standard macOS admin authentication dialog.
Setup:
make install-helper # copies privileged.yaml to ~/.config/streamdeck-go/
Edit ~/.config/streamdeck-go/privileged.yaml:
commands:
suspend: "pmset sleepnow"
reboot: "shutdown -r now"
poweroff: "shutdown -h now"
Privileged commands — Linux
A root helper daemon (streamdeck-go-helper) validates commands against a
root-owned whitelist at /etc/streamdeck-go/privileged.yaml before running them.
The main daemon communicates with it over a Unix socket.
make install-helper # creates streamdeck group, installs helper + system service
Edit /etc/streamdeck-go/privileged.yaml (as root):
commands:
suspend: "systemctl suspend"
reboot: "systemctl reboot"
poweroff: "systemctl poweroff"
Modules
Modules let you define reusable commands in a separate modules.yaml file instead of inlining shell commands in your config. This is especially useful for API-driven integrations like Slack where commands are long curl calls with tokens and JSON payloads.
How it works
- Create
~/.config/streamdeck-go/modules.yaml(next to yourconfig.yaml). - Define modules with named functions. Each function has an
exectemplate and optional defaultparams. - Reference them in
config.yamlwithmodule,function, and optionalparamsoverrides.
The daemon watches modules.yaml for changes alongside config.yaml — edits to either file trigger a live reload.
Module file format
# ~/.config/streamdeck-go/modules.yaml
modules:
slack:
set_status:
params:
emoji: ":speech_balloon:"
text: "In a meeting"
expiry: "1h"
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
clear_status:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
set_presence:
params:
presence: "away"
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"presence":"{{.presence}}"}'
snooze:
params:
minutes: "60"
exec: |
curl -s -X POST https://slack.com/api/dnd.setSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"num_minutes":{{.minutes}}}'
end_snooze:
exec: |
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
A full example is included at modules.example.yaml in the repo root.
Using modules in config.yaml
Reference a module function instead of writing inline commands:
keys:
# Set Slack status to "In a meeting" for 1 hour (uses module defaults)
10:
icon: meeting.png
module: slack
function: set_status
# Override default params — different emoji, text, and duration
11:
icon: lunch.png
module: slack
function: set_status
params:
emoji: ":knife_fork_plate:"
text: "Out to lunch"
expiry: "30m"
# Clear Slack status
12:
icon: clear-status.png
module: slack
function: clear_status
# Go away / come back
13:
icon: away.png
module: slack
function: set_presence
params:
presence: "away"
14:
icon: active.png
module: slack
function: set_presence
params:
presence: "auto"
# Snooze notifications for 60 minutes
15:
icon: snooze.png
module: slack
function: snooze
# End snooze
16:
icon: unsnooze.png
module: slack
function: end_snooze
Poll commands also support modules — use module, function, and params inside the poll block:
keys:
17:
icon_true: dnd-on.png
icon_false: dnd-off.png
module: slack
function: snooze
poll:
module: slack
function: check_dnd
interval: 10s
match: "snooze_enabled.*true"
Template helpers
Two helpers are available in exec templates:
| Helper | Usage | Description |
|---|---|---|
env |
{{env "VAR_NAME"}} |
Returns the value of an environment variable |
expiry |
{{expiry .duration}} |
Converts a Go duration string (e.g. "1h", "30m") to a Unix epoch timestamp. "0" or "" returns "0" (no expiry) |
Secrets and tokens
Tokens and secrets must never go in modules.yaml or config.yaml. Use the {{env "VAR_NAME"}} template helper to read them from environment variables at runtime.
Setting up your Slack token:
-
Create a Slack app at api.slack.com/apps with these OAuth scopes:
users.profile:write— set/clear statususers:write— set presence (away/auto)dnd:write— snooze/unsnooze notifications
-
Install the app to your workspace and copy the User OAuth Token (
xoxp-...). -
Export the token in your shell profile (
~/.zshrc,~/.bash_profile, etc.):export SLACK_TOKEN="xoxp-your-token-here" -
Make sure the service can see the variable:
macOS — add to
~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plistinside the<dict>block:<key>EnvironmentVariables</key> <dict> <key>SLACK_TOKEN</key> <string>xoxp-your-token-here</string> </dict>Linux — import into the systemd user environment:
systemctl --user import-environment SLACK_TOKENOr add an
Environment=line viasystemctl --user edit streamdeck-go:[Service] Environment=SLACK_TOKEN=xoxp-your-token-here
Security notes:
- The
modules.yamlandconfig.yamlfiles can safely live in your dotfiles repo — they contain no secrets.- On Linux, the systemd override file (
~/.config/systemd/user/streamdeck-go.service.d/override.conf) is user-readable only (mode 600). Still, preferimport-environmentover hardcoding tokens in unit files when possible.- On macOS, launchd plist files in
~/Library/LaunchAgents/are user-readable only by default.- Never commit tokens to git. Add
.envfiles to.gitignoreif you use one.
Config builder
Instead of editing YAML by hand, use the interactive TUI to configure keys:
make build-init
./streamdeck-init
Or with a custom config path:
./streamdeck-init -config ~/dotfiles/.config/streamdeck-go/config.yaml
The tool shows your 8x4 key grid, walks you through each step, and appends the result to your config.yaml:
Stream Deck XL — 8×4 grid
╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮
│Rootshell││ lights ││set_stat ││ WHH ││Dumpster ││ [5] ││ [6] ││ [7] │
╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯
...
[n] = free dim = occupied
? Pick a key slot
> 5 (free)
6 (free)
7 (free)
...
? What should this key do?
> Module function (Slack, etc.)
Shell command
? Pick a module
> slack
? Pick a function from slack
> set_status
clear_status
set_presence
go_offline
snooze
end_snooze
? emoji
> :coffee:
? text
> Coffee break
? expiry
> 30m
? Pick an icon from ~/.config/streamdeck-go/icons
> coffee.png
Summary:
Key: 5
Icon: coffee.png
Module: slack
Function: set_status
Params:
emoji: :coffee:
text: Coffee break
expiry: 30m
? Write this key to config? Yes
✓ Key 5 added to config.
? Add another key? No
✓ Done — the daemon will auto-reload your config.
On a fresh install with no config files, the tool creates config.yaml and modules.yaml automatically.
Supported icon formats
| Format | Notes |
|---|---|
| PNG | Recommended for static icons |
| JPEG | Good for photos / complex images |
| GIF | Animated — all frames pre-encoded at startup, cycled in a background goroutine |
| SVG | Rasterised to 96×96 at startup via oksvg |
Icons are scaled to 96×96 px using bi-linear filtering. The XL renders images mirrored, so they are pre-flipped before sending — your icons will appear the right way round.
Supported Devices
| Model | Product ID | Keys | Linux | macOS |
|---|---|---|---|---|
| Stream Deck XL v2 | 0x00ba |
32 | tested | tested |
| Stream Deck XL v1 | 0x006c |
32 | untested | untested |
| Stream Deck MK.2 | 0x006d |
15 | untested | untested |
To find your device's product ID:
- macOS:
system_profiler SPUSBDataType | grep -A5 "Stream Deck" - Linux:
lsusb | grep Elgato
To add a model, edit the models map in internal/device/streamdeck.go.
Roadmap
Text / label overlay on icons
Render dynamic text directly onto a key image at runtime — useful for showing live state like volume level, a clock, a counter, or the current git branch.
Example config (proposed):
keys:
4:
icon: volume.png
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
label_position: bottom # top | center | bottom
refresh: 5s
Multi-page layouts
Support more than 32 actions by organising keys into named pages. A designated key switches between pages.
Example config (proposed):
pages:
default:
0:
icon: apps.png
command: "page:apps"
1:
icon: firefox.png
command: firefox
apps:
0:
icon: back.png
command: "page:default"
1:
icon: ghostty.png
command: ghostty
AUR package
yay -S streamdeck-go # coming soon