I press the Stream Deck key. The daemon logs the press, synthesizes `Cmd+Opt+;` through CoreGraphics, and exits cleanly. Wispr Flow does nothing. Three Apple subsystems and one decompiled Electron bundle later, the working trigger turned out to be a one-line URL. The plan was the boring kind: Stream Deck key (the physical button on Elgato’s programmable USB grid) → WebSocket message → my daemon (long-running background process) → synthesized global hotkey → Wispr Flow’s hands-free dictation starts — Wispr Flow is the voice-to-text Mac app that types your speech into the focused window — → I talk → words show up in my editor. I’d done variants of this with osascript (macOS’s command-line AppleScript runner) years ago. Should have taken an afternoon.
Two undocumented behaviours in the Elgato Stream Deck SDK ate most of a weekend: a per-key title-alignment cache that silently ignores manifest updates, and a `willAppear` event that doesn't always re-fire after a plugin restart. The fixes are short. Finding them was not. (Skip this paragraph if you’ve shipped a Stream Deck plugin before. The Stream Deck is Elgato’s USB grid of programmable LCD keys — common on streamer desks for scene switching. A “plugin” is a small program — TypeScript, in my case — that runs as a child process of Elgato’s Stream Deck app, registers one or more actions the user can drag onto keys, and reacts to events like “key pressed” or “key visible.” The SDK is @elgato/streamdeck from npm. A manifest is a manifest.json next to the plugin that declares its actions, supported devices, default icons, and per-state defaults like title alignment.)
Claude Code calls a custom statusline command every turn with a JSON payload on stdin. The payload includes the current context-window fill percentage, model, cost, and cwd. Nothing in the contract says you can only read it — you can fork it to anything you want, and the command stays a statusline. (Quick framing for anyone new to Claude Code: it’s Anthropic’s terminal CLI for Claude, and the statusline is the configurable line of text it prints under your prompt every turn — like a shell prompt for the agent. You point at any script in settings.local.json, Claude pipes a JSON object to it on stdin, and whatever the script writes to stdout becomes the visible line.)
I wrote a daemon to listen to Claude Code hooks. My first version read `$CLAUDE_HOOK_PAYLOAD` and logged empty bodies for two days straight. The payload was sitting on stdin the whole time. This post is the five gotchas I hit while wiring up ClaudeDeck — a Stream Deck plugin (a small program that runs inside Elgato’s Stream Deck app on the USB grid of programmable LCD keys) that talks to Claude Code over its hooks system. Claude Code is Anthropic’s terminal CLI for Claude — claude in your shell — and its hooks are user-defined scripts it spawns at certain points in a session (before a tool call, on session start, on prompt submit). My daemon is a long-running background process the plugin and the hooks both talk to over a local socket. None of the gotchas are exotic. All of them cost me hours. Each one is a place where the docs were either silent, ambiguous, or contradicted by tribal knowledge I picked up from other people’s projects.
My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed — the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. I’m building a Stream Deck plugin called ClaudeDeck — Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one. The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability — automating other apps — through System Settings → Privacy & Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. So on first install I added the daemon, toggled it on, and got back to work.
`launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the "unload succeeded" line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement. (One-paragraph grounding if launchd isn’t your daily driver: launchd is macOS’s init system — the equivalent of systemd on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A LaunchAgent is a per-user launchd job, defined by an XML plist — property list — at ~/Library/LaunchAgents/<name>.plist. KeepAlive is one of the plist keys; set it to true and launchd will respawn the job whenever it exits. launchctl is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think systemctl driving systemd unit files. The Stream Deck plugin and its daemon are described in the TCC cdhash trap post if you want the project context.)
I built a Claude Code permission gate that holds an HTTP response open until a Stream Deck key is pressed. Then I needed to inject a keystroke into Claude Code's own TTY so a key press could write `1\r` straight into Claude's stdin. Bun can hold HTTP open all day. Bun cannot reliably wrap a child PTY through `node-pty` and capture the parent shell's PID. So I split my daemon: HTTP and WebSocket stay on Bun, and a Node CommonJS subprocess owns the PTY that runs Claude. (Quick grounding before the story: a PTY — pseudo-terminal — is the kernel object every interactive shell talks to. It’s a pair of file descriptors, master and slave; the program reads/writes the slave end as if it were a real terminal, and anything you write to the master end looks to that program like a human typing. The TTY is the slave end seen from the child’s side. node-pty is Microsoft’s library that gives a JavaScript parent process a writable handle to the master. Bun is a JavaScript runtime — Node’s faster sibling — and Node CommonJS is plain old require()-based Node, no transpile step. The story below is about which runtime owns the PTY.)
My daemon logged 111 consecutive HTTP 429s against `https://api.anthropic.com/api/oauth/usage` over an 18-hour stretch, with zero successful responses ever in its lifetime. The poller was reading `Retry-After: 272` and ignoring it. While I was arguing with the backoff, Claude Code was pushing the same `rate_limits.five_hour` and `rate_limits.seven_day` numbers to my statusline command every turn, on stdin, for free. (Quick framing: Claude Code is Anthropic’s terminal CLI for Claude; Claude Max is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP 429 is “Too Many Requests” — the server’s polite way of saying “back off.” Retry-After is the response header that tells the client how long to wait. OAuth is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the statusline — the same one I covered in the statusline side-channel post — is the script Claude Code spawns every turn with a JSON blob on stdin.)
Claude Code wants to run a shell command. I want to press a physical Stream Deck key — the YES key, two inches to the left of my keyboard — to approve it. The hook gets exactly one HTTP response to decide allow vs deny. The key press might land in 200 milliseconds; it might land seven minutes later, after I've been pulled into a meeting and come back. The trick is that Claude Code's hook timeout is 600 seconds, which turns out to be just enough headroom to hold the HTTP response open the whole time and let a hardware button write the answer. (Setup, for anyone who hasn’t seen this stack before: Claude Code is Anthropic’s terminal CLI for Claude, and one of its hook events — PreToolUse — is a script Claude spawns and waits on before running a tool like Bash or Edit. The script’s stdout decides “allow” / “deny” / “ask”. Stream Deck is Elgato’s USB grid of programmable LCD keys. The plumbing I’m describing here lives in a daemon — a background process at 127.0.0.1:9127 — that the hook script POSTs to and that the Stream Deck plugin connects to over WebSocket. For the hooks docs themselves and the four other gotchas in that layer, see the hooks-reality post.)
A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't "does the right session get evicted" — it's "do the four survivors stay on the keys they were already on." (Two bits of context for anyone new to the stack: Stream Deck is Elgato’s USB grid of programmable LCD keys, and a “session” here is a single Claude Code conversation — claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for “least-recently used,” the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)