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.
It took three days. And the eventual fix bypassed keystroke synthesis entirely, by routing through an Electron URL handler — Electron apps (Slack, Discord, VS Code, Wispr Flow) are Chromium plus Node bundled into a desktop binary, and they often register custom URL schemes like slack:// — that Wispr Flow doesn’t publicly document but ships in every release.
This is the story of why hotkey synthesis is, on macOS 26.5 (codename Tahoe), a dead end for unsigned daemons — and how to find the side door that nearly every Electron app leaves open. (For the related “why does my macOS Accessibility grant keep resetting” story on the same daemon, see the TCC cdhash trap.)
The symptom #
Logs from the daemon:
[wispr] press received, synthesizing Cmd+Opt+;
[wispr] CGEventPost done, 6 events posted, eventStateId=1Logs from Wispr Flow: nothing. The hands-free indicator never appears. The transcription never starts.
A real keypress of the same combo opens hands-free immediately. So Wispr Flow’s listener works; my events aren’t reaching it.
Plan A: AppleScript #
The lowest-friction attempt. AppleScript via osascript has been the macOS scripting hammer for two decades.
tell application "System Events"
key code 41 using {command down, option down}
end tellosascript returns 0. Wispr Flow doesn’t react.
I half-expected this. System Events injects events from a different layer than the real keyboard — it goes through AppleEvents (Apple’s decades-old inter-app message bus, the thing AppleScript actually runs on) into a high-level “post this to the focused app” path. Wispr Flow registers its hotkey via Carbon’s RegisterEventHotKey — Carbon is Apple’s pre-2007 C API layer; lots of it is deprecated but RegisterEventHotKey is still the standard “register a global keyboard shortcut” function — which sits much lower in the stack. The synthesized event never reaches the matcher.
Not great, but Plan A was always a long shot. On to CoreGraphics.
Plan B: CGEventPost via bun:ffi #
CoreGraphics (the macOS framework behind everything that draws on screen, including event injection) is supposed to inject events early enough that even Carbon hotkeys see them. I wired up the six-event sequence (Cmd↓, Opt↓, ;↓, ;↑, Opt↑, Cmd↑) through Bun’s :ffi binding to the framework. (FFI — “Foreign Function Interface” — is the standard way for a high-level language to call C functions in a shared library; dlopen opens the .dylib, the schema tells Bun the argument types.)
import { dlopen, FFIType, ptr, CString } from "bun:ffi";
const cg = dlopen(
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
{
CGEventCreateKeyboardEvent: { args: ["ptr", "u16", "i32"], returns: "ptr" },
CGEventPost: { args: ["i32", "ptr"], returns: "void" },
CGEventSetFlags: { args: ["ptr", "u64"], returns: "void" },
CFRelease: { args: ["ptr"], returns: "void" },
},
);
const SEMICOLON = 0x29; // virtual keycode for ;
const FLAG_CMD = 0x100000;
const FLAG_OPT = 0x080000;
const TAP_HID = 0; // kCGHIDEventTap
The pattern is canonical. skhd, Hammerspoon, and yabai (popular macOS hotkey and window-management daemons that ship as open-source reference implementations of this exact approach) all do it the same way. I added 30 ms delays between events. I set kCGEventSourceStateHIDSystemState=1 so the modifier-state table — the one macOS keeps for “is Cmd currently held down” — looked right. I verified each event had the cmd+alt flags before posting.
Wispr Flow: still nothing.
But — and this is the part that wasted a full day — a bare key post did work:
$ osascript -e 'tell app "System Events" to key code 36'
# Enter key lands in TextEdit, types a newlineSo the daemon has some synthesis capability. Bare keys go through. Modifier-bearing keys destined for Carbon hotkey listeners do not. The daemon is granted Accessibility. AXIsProcessTrustedWithOptions(NULL) — the “is my process allowed to use the Accessibility APIs” check — returns false anyway, which I assumed was the problem. (See the TCC cdhash trap post for why TCC grants can be present in the UI but invisible to APIs.)
It wasn’t. AX trust and event-post privilege are different TCC services. Quinn at Apple DTS (Developer Technical Support — Apple’s official engineer-staffed forum where “Quinn” is a recognised name engineers cite by) spells this out on the dev forums: kTCCServiceAccessibility, kTCCServicePostEvent, and kTCCServiceListenEvent are three independent buckets — three separate rows in TCC.db, each tracked independently. The Accessibility toggle flips all three for keyboard-poster apps, but the APIs query them separately. CGEventPost was working — my events were reaching WindowServer (the macOS process that owns the screen, all windows, and the keyboard/mouse event pipeline; analogous to Xorg on Linux). They were just being dropped before the hotkey matcher.
The gate I’d been ignoring #
Three days in, I went looking for the actual filter. The answer is in WindowServer.
Carbon’s RegisterEventHotKey is a thin shim over a private SkyLight RPC (SkyLight is the private framework WindowServer exposes — undocumented by Apple, but reverse-engineered for years; “RPC” here just means cross-process function call) that registers a per-process entry in WindowServer’s hotkey table. The match happens inside the WindowServer process, before per-app dispatch. WindowServer can distinguish events that came from the IOHIDSystem driver (real hardware — the kernel module that reads bytes off the USB keyboard) from events that came from CGEventPost. The function that draws the line is CGXSenderCanSynthesizeEvents().
Jamf’s reverse-engineering writeup quotes WindowServer’s own log strings: “Dropping mouse down event because sender’s PID (899) isn’t 0 or self (828)”. That’s the gate refusing a synthesized event because the sender PID isn’t WindowServer itself. The same gate fires for keyboard events destined for the hotkey matcher.
I was thinking about it wrong. On older macOS, ad-hoc-signed binaries with a TCC PostEvent grant could synthesize events that reached the matcher, sometimes. I have no source confirming that’s ever been reliable. What I can say is what I observed on macOS 26.5: the bare-key post lands in the focused field, the modifier-bearing post to a Carbon hotkey listener does not — for a daemon signed only with codesign --sign -. Same daemon, same TCC grants, two different outcomes.
The two paths past the gate, as far as I can tell, are:
- Ship through the Mac App Store and inherit a real signing identity.
- Inject events through a virtual HID device that registers as real hardware. Karabiner-Elements ships exactly that —
Karabiner-DriverKit-VirtualHIDDevice— and the Karabiner team explicitly calls out CGEventPost as inadequate for the same reason.
Neither was realistic for a hobby daemon whose code-signing budget is “what security create-certificate lets me do for free.” Time to find another way in.
Plan C: ask the app, not the OS #
Reframe: if I can’t make the OS deliver the keystroke, can I trigger Wispr Flow’s start-dictation function some other way? The hotkey is one entry point. Surely it’s not the only one.
Wispr Flow is an Electron app. Electron apps almost universally register a URL scheme through Info.plist’s CFBundleURLTypes key (Info.plist is the XML/binary metadata file inside every .app bundle at Contents/Info.plist; the CFBundleURLTypes array tells macOS “this app handles these foo:// URLs”) — that’s how install flows and “open in app” buttons on websites work. If Wispr Flow’s URL handler routes to the same internal function as the hotkey, I’d have a working path with zero synthesis.
Step one: does it have a scheme?
$ /usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" \
"/Applications/Wispr Flow.app/Contents/Info.plist"
Array {
Dict {
CFBundleURLName = ai.wispr.flow.deeplink
CFBundleURLSchemes = Array {
wispr-flow
}
}
}It does. wispr-flow://. But what URLs does it accept?
Step two: grep the bundle. (Electron ships its JavaScript inside app.asar — a single archive file mounted like a tar, containing the entire Electron app’s JS source. It’s plain enough that strings lifts readable text out of it directly.)
$ strings "/Applications/Wispr Flow.app/Contents/Resources/app.asar" \
| grep -E "wispr-flow://[a-z/-]+" | sort -u
wispr-flow://auth/transfer/success
wispr-flow://billing/cancel
wispr-flow://billing/success
wispr-flow://open
wispr-flow://start-hands-free
wispr-flow://stop-hands-free
wispr-flow://switch-micstart-hands-free was sitting there the whole time.
Step three: confirm it routes through the same code path as the hotkey, not some second-class deeplink-only handler that does half the work.
$ strings "/Applications/Wispr Flow.app/Contents/Resources/app.asar" \
| grep -B 1 -A 4 "DeeplinkStartHandsFree"
if (status === Idle || status === Dismissed) {
Qw(SB.Deeplink);
sendEvent("DeeplinkStartHandsFree", { success: true });
} else {
sendEvent("DeeplinkStartHandsFree",
{ success: false, reason: "not_idle" });
}Qw(SB.Deeplink) is the same state-machine call as the hotkey path. SB.Deeplink is just an enum tag for analytics. The handler even emits a DeeplinkStartHandsFree event with a success: false, reason: "not_idle" branch — which means it’s a real, tested code path, not a vestigial scaffold.
The fix #
open -g wispr-flow://start-hands-freeIn the daemon, that’s a single spawn (daemon/src/wisprFlowTrigger.ts):
spawn("open", ["-g", url], {
detached: true,
stdio: ["ignore", "pipe", "pipe"],
}).unref();The -g flag is load-bearing. Without it, open activates Wispr Flow and steals focus from your editor, so when you finish dictating, the transcribed text lands in Wispr Flow’s own window instead of where you were typing. With -g, LaunchServices (the macOS subsystem that knows which app handles which file type or URL scheme — what fires when you double-click a .pdf or click a slack:// link) delivers the URL without bringing the app forward.
No CGEventPost. No bun:ffi. No fight with CGXSenderCanSynthesizeEvents. No TCC PostEvent grant needed for the trigger. The daemon doesn’t even need Accessibility for this path — just permission to spawn open, which every process has.
Side notes that took time to verify #
A few caveats that aren’t load-bearing for the fix but were worth checking:
Cold-start latency. If Wispr Flow isn’t running, the first open launches it; ~1–2 seconds in my testing on this M-series machine. Subsequent presses dispatch instantly. Adding Wispr Flow to Login Items skips the cold start entirely.
Stopping dictation. wispr-flow://stop-hands-free exists and works the same way. In practice Wispr Flow auto-stops on silence, so I haven’t wired stop to the Stream Deck yet.
Wispr Flow’s own focus call. I traced the deeplink dispatcher further and found it calls hubWindow.focus() unconditionally at the entry point, before routing to the start-hands-free handler. If Wispr Flow’s hub window happens to be visible, that focus call can still steal focus even with open -g. The mitigation is a 200–400 ms timer that restores the original window’s focus after firing the URL. I haven’t shipped that yet because the hub window is hidden in my workflow, so the hubWindow.focus() is a no-op visually. If the focus steal becomes a problem, the research doc has the full restore pattern, lifted from Hammerspoon and BetterTouchTool.
Why this path is structurally better #
Hotkey synthesis, when it works, traverses something like:
daemon → CGEventPost → WindowServer → CGXSenderCanSynthesizeEvents
→ Carbon hotkey table match → Wispr Flow.startHandsFree()Four kernel-side gates. Two of them care about whether you’re signed by Apple.
The deeplink path:
daemon → open → LaunchServices → Wispr Flow.appDelegate.application(_:open:)
→ Wispr Flow.startHandsFree()Two gates. Neither one cares about signing. Same end state.
The synthesis path was the workaround. The deeplink is the direct call.
Lessons #
A few principles that would have saved me three days, in order of how often I’ll need them again:
- When the OS won’t deliver your event, the app probably exposes what you want via a URL scheme. Check
Info.plist’sCFBundleURLTypesfirst. For Electron apps,strings app.asar | grep '://'finds every registered scheme in 30 seconds. AXIsProcessTrustedreturning false is not always the problem. TCC has separate buckets for Accessibility, PostEvent, and ListenEvent. UseCGPreflightPostEventAccessif all you do is post events.- WindowServer can tell synthesized events from real ones. No public flag on
CGEventRefchanges that. If you need to pass for real hardware, you need a virtual HID device, not a smarterCGEventPostinvocation. - Carbon
RegisterEventHotKeymatches inside WindowServer, before per-app dispatch. Synthesizing events for a foreground app to consume is a different problem from synthesizing events for a Carbon hotkey listener to consume. Plan A and Plan B were the same dead end; I just didn’t know it. - If you’re going to decompile an Electron app to find an undocumented entry point, do it in the first hour, not the fourth day.
Hotkey synthesis is a 2010 technique. On Tahoe, for unsigned binaries, it’s deprecated in practice if not on paper. If you’re hitting it for a daemon you’re not going to ship through the App Store, jump straight to the URL-scheme search.
Related #
- The cdhash trap that broke my Accessibility grant every rebuild — same daemon, different TCC pitfall
- The Claude Code hooks docs are wrong. Here’s what’s actually on the wire. — what the daemon does once it’s running
- Holding HTTP open for 590s to gate a tool call — the daemon’s permission flow
References #
- Aditya Vaidyam, Building a Better RegisterEventHotKey (2018-03-16) — reverse-engineering of the SkyLight RPC behind Carbon hotkeys.
- Jamf, Synthetic Reality — WindowServer log strings showing the
CGXSenderCanSynthesizeEventsfilter rejecting synthesized events by PID. - Tencent KeenLab, WindowServer: The privilege chameleon on macOS Pt.1 (2016-07-22) — internal IPC structure of WindowServer, including the synthesizer-privilege gate.
- Apple Developer Forums #744440 — Quinn on CGEventTap vs
AXIsProcessTrusted, with the recommendation to useCGPreflight{Post,Listen}EventAccess. - Apple,
CGRequestPostEventAccess— the PostEvent TCC gate. - Karabiner-Elements DEVELOPMENT.md — explicit rationale for not using
CGEventPost, and the virtual HID driver that bypasses the filter. - Hammerspoon issue #313 — independent confirmation that
hs.eventtap.keyStrokecannot triggerhs.hotkeylisteners, with the same WindowServer root cause. - ClaudeDeck deeplink trigger implementation (Bucket A):
daemon/src/wisprFlowTrigger.ts. - ClaudeDeck full research notes for this post (Bucket A):
docs/2026-05-09-wispr-flow-hotkey-research.md. - The
wispr-flow://start-hands-freeURL was not found in any public Wispr Flow documentation. I discovered it by running/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes"againstWispr Flow.app/Contents/Info.plistand thenstrings Contents/Resources/app.asar | grep 'wispr-flow://'. Reproducible on any installed copy.