A Node.js TypeScript application for controlling a 15-key Elgato Stream Deck on macOS, with a lightweight state machine, AeroSpace integration, and an HTTP status server for external callbacks.
- State Machine: Manages lifecycle with three states:
IDLE,ACTIVE,PAUSED - Stream Deck Integration:
- Key 0 (top-left): Lifecycle control (
START/PAUSE/RESUME) - Key 1 (top row): Stash/restore all visible windows (“big stash” toggle)
- Key 5 (middle-left): AI pulse indicator
- Keys 10–14 (bottom row): Microsoft Edge shortcuts (when Edge is visible)
- Key 0 (top-left): Lifecycle control (
- Action Layers:
- Meta-actions: global controls that stay consistent across contexts
- App-specific actions: context-aware shortcuts that change based on active app
- AeroSpace CLI Integration: Stash/unstash helpers for window management
- Native Key Sender: Swift helper for reliable macOS shortcut delivery (
native/keysender) - Status Server (defaults to port 3000):
GET /healthGET /statusPOST /pingPOST /updateGET /agent-done(triggers AI pulse)GET /aerospace-event(refreshes context on workspace changes)
- Node.js (v18 or higher). If you use
nvm, this repo includes.nvmrc(runnvm use). - Elgato Stream Deck (15-key model)
- macOS (AeroSpace optional, for window stashing)
- Clone the repository:
git clone https://github.com/hugocool/productive_streamdeck.git
cd productive_streamdeck- Install dependencies:
npm installLong-term, this should ship as a signed, installable macOS menu bar app. To get there incrementally, we plan to do:
Goal: repeatable installs + “runs on login” without building toolchains on end-user machines.
Deliverables:
- A
brewtap/formula that installsdist/, production deps, and a prebuiltnative/keysender. - A
launchdLaunchAgent (~/Library/LaunchAgents/...plist) that runs the controller on login. - A
productive-streamdeck setupcommand that:- Installs/updates an AeroSpace hook script (instead of hardcoding
http://127.0.0.1:3000/...in~/.aerospace.toml). - Stores runtime config in
~/Library/Application Support/<bundle-id>/config.json. - Prints a minimal, safe patch to apply to the user’s existing
~/.aerospace.toml(no blind overwrite).
- Installs/updates an AeroSpace hook script (instead of hardcoding
Notes:
- This phase forces us to define stable paths, logging, and an uninstall story early.
- If the HTTP server port changes, AeroSpace should call a hook script that discovers the current port (preferred) rather than hardcoding
3000.
Goal: “download → install → guided setup”, with a UI for state/health and permissions.
Architecture (recommended):
- A lightweight macOS menu bar app (Swift/AppKit) that manages a background daemon.
- The daemon continues to run the Node Stream Deck controller (and shells out to AeroSpace +
native/keysender). - The menu bar app provides: start/stop/restart, connection status, port/config display, logs, and “Install AeroSpace hook”.
Work needed to get there:
- Packaging:
- Bundle a Node runtime (or a single packaged Node executable) +
dist/+node_modules+keysender. - Code signing + notarization.
- Bundle a Node runtime (or a single packaged Node executable) +
- Configuration/secrets:
- Move API keys into macOS Keychain (menu bar UI for setup/rotation).
- Keep non-secret preferences in Application Support.
- Networking:
- Bind the local server to
127.0.0.1only. - Prefer a stable discovery mechanism (hook script + config file) over a fixed port.
- Bind the local server to
- Permissions/onboarding:
- Guided checklist for Accessibility / Input Monitoring (required for key sending).
- Health UI for Stream Deck connection and AeroSpace CLI availability.
- Updates:
- Add auto-update (Sparkle) once signing/notarization is in place.
Rough effort (very approximate):
- Minimal prototype menu bar controller (start/stop + status + logs): ~3–7 days.
- Production-ready (signing/notarization, Keychain, onboarding UX, updater): ~2–4 weeks.
- Build the project:
npm run build- Start the application:
npm startUse the minimal Stream Deck + AeroSpace probe:
npm run check:deckThe application will:
- Connect to your Stream Deck
- Start the Express server (defaults to port 3000)
- Initialize the state machine in IDLE state
[ 0 STATE ] [ 1 STASH] [ 2 ] [ 3 ] [ 4 ]
[ 5 AI ] [ 6 ] [ 7 ] [ 8 ] [ 9 ]
[10 EDGE* ] [11 EDGE*] [12 EDGE*] [13 EDGE*] [14 EDGE*]
EDGE* keys render only when Edge is visible (otherwise the bottom row is cleared).
We design for two complementary modes:
- System-level productivity: global controls that stay consistent across contexts
- App-specific productivity: context-aware shortcuts that adapt to the active app or monitor
The long-term goal is to make the workflow/meta buttons feel like Git:
- Branch == task context (a named set of windows/workspaces)
- Checkout == switch tasks (swap the visible task across monitors)
- Stash push/pop == hide/restore windows (move to/from a hidden workspace)
- Commit == snapshot (save current window set/layout for later restore)
- Clean == close strays (end interruptions by removing unrelated windows)
Defaults we’re building toward (important):
- START: checkout/focus only; windows are tracked explicitly (“add window to task”).
- Hold START: adopt visible → task (bulk add).
- PAUSE: stash all visible workspaces (multi-monitor) + switch to an
INBOX/untracked zone.- Hold PAUSE: stash focused workspace only.
- STOP: stash + archive (recoverable).
- Hold STOP: hard stop (close windows).
- VIEW: dedicated layer for status/log/stash list/recovery.
- Hold = intensity on same intent; Layer toggle = different intent category.
Task/branch naming is expected to come from a task tracker (Notion/TickTick/Toggl/etc); the exact workspace representation is intentionally deferred until sync exists.
Current app-specific targets:
- Microsoft Edge (Notion lives here)
- Visual Studio Code
- TickTick
- Slack (or other messaging apps)
- Notion Calendar
- Toggl
- Raycast
AeroSpace is the always-on "physics layer" for window routing and workspace invariants. We keep that logic in a static config, and run state transitions from Node.
Template config:
config/aerospace.toml
Install (copy and customize app IDs):
cp config/aerospace.toml ~/.aerospace.tomlTo find bundle IDs:
aerospace list-appsNote: avoid force-assigning workspaces you want to "swap" across monitors.
If you want the “INBOX/untracked by default” workflow, adjust the AeroSpace routing so new windows land in INBOX (or remove per-app auto-routing).
We use exec-on-workspace-change to notify the Node controller so the
Stream Deck can refresh context-aware buttons.
If window stashing/restoring looks like “nothing happened”, validate AeroSpace behavior directly first:
aerospace list-windows --monitor focused --workspace focused --format "%{window-id} | %{workspace} | %{app-name} | %{window-title}"
aerospace list-windows --focused --format "%{window-id} | %{workspace} | %{app-name} | %{window-title}"The first command lists windows in the focused workspace; the second lists (at most) the focused window. Also check logs for ENOENT if the controller can’t find the aerospace binary via PATH.
The native shortcut helper uses CoreGraphics to post synthetic key events. macOS may prompt for Accessibility / Input Monitoring permissions.
Note: the server defaults to port 3000, but may auto-increment if that port is busy. Check the app logs for the active port.
Returns server health status
curl http://localhost:3000/healthReturns current application state
curl http://localhost:3000/statusTrigger an AI pulse on the Stream Deck
curl http://localhost:3000/agent-doneRefresh Stream Deck context after AeroSpace workspace changes
curl http://localhost:3000/aerospace-eventSend a status ping from external agents
curl -X POST http://localhost:3000/ping \
-H "Content-Type: application/json" \
-d '{"source": "raycast", "message": "Task completed"}'Send an action update
curl -X POST http://localhost:3000/update \
-H "Content-Type: application/json" \
-d '{"action": "status_update", "data": {"progress": 50}}'productive_streamdeck/
├── src/
│ ├── index.ts # Main entry point
│ ├── stateMachine.ts # State machine implementation
│ ├── streamDeck.ts # Stream Deck controller + button artwork
│ ├── aerospace.ts # AeroSpace CLI wrappers
│ └── server.ts # Express server
├── package.json # Project dependencies
├── tsconfig.json # TypeScript configuration
└── README.md # This file
Key technologies:
@elgato-stream-deck/node: Stream Deck hardware interfacesharp: Image processing for button graphicsexpress: HTTP server for external integrations
MIT