Simple Hotkey Daemon for macOS, ported from skhd to Zig.
This implementation is fully compatible with the original skhd configuration format - your existing .skhdrc files will work without modification. Additionally, it includes new features like process groups and command definitions (.define) for cleaner configs, key forwarding/remapping, and improved error reporting.
skhd.zig now ships a system-level grabber daemon that enables remapping the user-session event tap can't reach. New directives:
.remap caps_lock [device <alias>] : escape— instant 1:1 swap, applied viahidutil(no daemon)..remap key [device <alias>] { tap: …, hold: …, … }— tap vs. hold on the same key (e.g.caps_locktapped = escape, held = control). Routed throughskhd-grabber(root LaunchDaemon) which seizes the keyboard at the IOKit/HID level via Karabiner DriverKit. Required for caps-lock-class rules and modifier-as-hold rules thathidutilsilently drops.- Layer holds —
holdcan target a skhd mode instead of a key, so holding the source key activates the layer for the duration of the hold (e.g. holdspaceto enterfn_layer, wherefn_layer < 1 | f1rebinds the number row to F-keys). .device <alias> { vendor: 0x…, product: 0x… }— scope rules to a specific keyboard, so one config does the right thing on each machine.
Install the alpha (Homebrew formula stays on 0.0.24 until v0.1.0 stable; eager testers download from GitHub releases):
gh release download v0.1.0-alpha --repo jackielii/skhd.zig --pattern '*-arm64-macos.tar.gz'
tar -xzf skhd-arm64-macos.tar.gz -C /tmp
sudo mv /tmp/skhd.app /Applications/
/Applications/skhd.app/Contents/MacOS/skhd --install-service--install-service registers the agent, auto-installs the Karabiner DriverKit pkg if your config has tap-hold rules, prompts for the grabber install via sudo, and pops Accessibility + Input Monitoring dialogs in sequence — one click each.
Try it out — drop this into ~/.config/skhd/skhdrc (replace the vendor/product IDs with your own — find them via skhd --grabber-status once installed, or System Information → USB):
# 1. Declare the keyboard you want to remap.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# 2. caps_lock acts like ctrl when held, escape when tapped.
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
}
# 3. Hold space to enter a "function layer", release to exit.
:: fn_layer @
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
retro_tap : on
}
# 4. While the layer is held, number row → F-row.
fn_layer < 1 | f1
fn_layer < 2 | f2
fn_layer < 3 | f3
# … etcSave, then skhd --restart-service. Tap caps_lock → escape. Hold caps_lock + c → ctrl-c. Hold space then press 1 → F1.
Verify with skhd --status (single command shows the agent, grabber, dext, and TCC state) or skhd --grabber-status (drills into the grabber-side dependency chain). Roll back with skhd --uninstall-service (it prints follow-up sudo skhd --uninstall-grabber instructions if anything's still on disk).
Larger real-world example — my own builtin.skhdrc, which makes a MacBook built-in keyboard behave like a Keebio Convolution running custom QMK firmware (caps_lock as ctrl/escape, space as fn_layer, fn_layer < hjkl as arrows, number row → F-row under layer, etc.):
- skhd config: https://gist.github.com/jackielii/9d24095af57ec35df0d46d38bbbe0449
- QMK source-of-truth keymap it mirrors: https://github.com/jackielii/qmk_firmware/blob/jackie/keyboards/keebio/convolution/keymaps/jackie/keymap.c
See skhd-grabber below for the full architecture, SYNTAX.md for the new directive grammar, and the v0.1.0-alpha CHANGELOG entry for everything that changed.
The easiest way to install skhd.zig:
brew install jackielii/tap/skhd-zigUpgrading from 0.0.17 or earlier? See docs/UPGRADING.md. The 0.0.18 release switches to an
.appbundle to keep accessibility permissions working on macOS Tahoe; one-time re-grant is required.
Apple Silicon only (Intel builds paused as of v0.0.19; build from source for Intel):
skhd-arm64-macos.tar.gz(containsskhd.app)
Extract and install:
tar -xzf skhd-arm64-macos.tar.gz
mv skhd.app /Applications/
# Optional: expose the CLI on your PATH
sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhdThen grant accessibility (see Granting Accessibility below).
If you need builds with different optimization levels (Debug, ReleaseSafe, ReleaseFast, ReleaseSmall), you can download them directly from GitHub Actions:
- Go to the CI workflow in Actions tab. Filter by branch
main. - Click on the latest successful run
- Scroll down to the "Artifacts" section
- Download the build artifact for your desired optimization level:
skhd-Debug- Debug build with full debugging symbolsskhd-ReleaseSafe- Release build with safety checks and runtime safetyskhd-ReleaseFast- Optimized for performance (recommended for daily use)skhd-ReleaseSmall- Optimized for binary size
# Clone the repository
git clone https://github.com/jackielii/skhd.zig
cd skhd.zig
# Build the .app bundle and code-sign it
# (required for Accessibility to persist on macOS Tahoe / Sequoia)
zig build sign-app -Doptimize=ReleaseFast
# Install: symlink the bundle into /Applications, expose the CLI
ln -sfn "$(pwd)/zig-out/skhd.app" /Applications/skhd.app
sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhdFor quick dev iteration without the bundle wrapper, zig build still produces a bare binary at zig-out/bin/skhd. The .app is only needed for the System Settings → Accessibility picker.
The first run of zig build sign-app creates a self-signed skhd-cert certificate in your login keychain. See docs/CODE_SIGNING.md for details and troubleshooting.
skhd captures keyboard events via macOS Core Graphics, which requires Accessibility permission:
- Open System Settings → Privacy & Security → Accessibility
- Click
+, navigate to/Applications/skhd.app, add it - Toggle the entry on
- Run
skhd --restart-service(orskhd --start-serviceif not yet running)
You only need to do this once. The bundle's stable identifier (com.jackielii.skhd) means TCC entries persist across rebuilds and brew upgrade.
If your config uses .remap or .taphold rules, macOS will additionally
prompt you for Input Monitoring the first time the grabber starts.
The grabber binary lives inside the same skhd.app bundle, so the same
bundle identifier covers it — one click in System Settings → Privacy
& Security → Input Monitoring approves both processes.
After installation, run skhd as a service for automatic startup:
# Install and start the service
skhd --install-service
skhd --start-service
# Check if skhd is running properly
skhd --status
# Restart service (useful for restarting after giving accessibility permissions)
skhd --restart-service
# Stop service
skhd --stop-service
# Uninstall service
skhd --uninstall-serviceThe service will:
- Start automatically on login
- Write logs to
~/Library/Logs/skhd.log - Use your config from
~/.config/skhd/skhdrcor~/.skhdrc - Automatically reload on config changes
- Event capturing: Uses macOS Core Graphics Event Tap for system-wide keyboard event interception
- Hotkey mapping: Maps key combinations to shell commands with full modifier support
- Process-specific bindings: Different commands for different applications
- Key forwarding/remapping: Remap keys to other key combinations
- Modal system: Multi-level modal hotkey system with capture modes
- Configuration file: Compatible with original skhd configuration format
- Hot reloading: Automatic config reload on file changes
- Device-aware HID remapping (v0.1.0-alpha): per-keyboard 1:1 remaps via
hidutiland tap-vs-hold rules via the optionalskhd-grabberdaemon. See Device-aware remapping.
- Aliases: Name a modifier combo or a single key (
.alias $hyper cmd + alt + ctrl + shift,.alias $grave 0x32) - Mouse buttons: Bind on
mouse1–mouse5(e.g.cmd - mouse1 : ...); use->for passthrough so the click still reaches the app - Process groups: Define named groups of applications for cleaner configs
- Command definitions: Define reusable commands with placeholders to reduce repetition
- Key Forwarding: Forward / remap key binding to another key binding
- Mode activation with command: Execute a command when switching modes (e.g.,
cmd - w ; window : echo "Window mode") .device+.remap(v0.1.0-alpha): per-device HID-layer remapping, both colon (1:1) and block (tap/hold) forms.- Layer holds (v0.1.0-alpha): a
.remaphold:target can be a skhd mode, so holding a key activates a layer for the duration of the hold.
--version/-v- Display version information--help- Show usage information-c/--config- Specify config file location-o/--observe- Observe mode (echo keycodes and modifiers)-V/--verbose- Debug output with detailed logging-k/--key- Synthesize keypress for testing-t/--text- Synthesize text input-r/--reload- Signal reload to running instance-h/--no-hotload- Disable hotloading-P/--profile- Profile event handling (Debug and ReleaseSafe builds only)
--install-service- Install launchd service (also auto-installs the DriverKit dext + grabber if your config uses.remap/.taphold)--uninstall-service- Remove launchd service--start-service- Start as service--restart-service- Restart service--stop-service- Stop service--status- Combined health: agent PID, event tap, grabber, dext, TCC- PID file management (
/tmp/skhd_$USER.pid) - Service logging (
~/Library/Logs/skhd.log)
--install-grabber- Installskhd-grabberLaunchDaemon (sudo)--uninstall-grabber- Removeskhd-grabberLaunchDaemon (sudo)--install-dext- Install the pinned Karabiner DriverKit VirtualHIDDevice (and its launchd plist)--grabber-status- Drill into the grabber dependency chain (socket, dext version, IOKit match)- Grabber logging (
/var/log/skhd-grabber.log)
- Blacklisting: Exclude applications from hotkey processing
- Shell customization: Use custom shell for command execution
- Left/right modifier distinction: Support for lcmd, rcmd, lalt, ralt, etc.
- Special key support: Function keys, media keys, arrow keys
- Passthrough mode: Execute command but still send keypress to application
- Config includes: Load additional config files with
.loaddirective - Comprehensive error reporting: Detailed error messages with line numbers
- Per-device HID remapping: colon-form
.remapfor instant 1:1 swaps, block-form for tap-vs-hold semantics - Layer holds:
hold:can target a skhd mode instead of a key, so holding the source key activates a layer - DriverKit injection: caps_lock-class keys (and modifier-as-hold rules) routed through
skhd-grabber+ Karabiner DriverKit, sidestepping limits of the user-session event tap
# Build the project (creates executable in zig-out/bin/)
zig build
# Build in release mode with optimizations
zig build -Doptimize=ReleaseFast
# Run the application
zig build run
# Run with arguments
zig build run -- -V -c ~/.config/skhd/skhdrc
# Run tests
zig build testskhd.zig looks for configuration files in the following order:
- Path specified with
-cflag ~/.config/skhd/skhdrc~/.skhdrc
The configuration syntax is fully compatible with the original skhd. See SYNTAX.md for the complete syntax reference and grammar.
# Use custom shell (skips interactive shell overhead)
.shell "/bin/dash"
# Blacklist applications (skip hotkey processing)
.blacklist [
"dota2"
"Microsoft Remote Desktop"
"VMware Fusion"
]
# Load additional config files
.load "~/.config/skhd/extra.skhdrc"
# Define aliases (New in skhd.zig!)
.alias $hyper cmd + alt + ctrl + shift # modifier alias
.alias $super cmd + alt
.alias $grave 0x32 # key alias (UK keyboard backtick)
# Define process groups for reuse (New in skhd.zig!)
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define native_apps ["kitty", "wezterm", "chrome", "whatsapp"]
.define browser_apps ["chrome", "safari", "firefox", "edge"]
# Define reusable commands with placeholders (New in skhd.zig!)
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define yabai_swap : yabai -m window --swap {{1}} || (yabai -m window --display {{1}} && yabai -m display --focus {{1}})
.define toggle_app : open -a "{{1}}" || osascript -e 'tell app "{{1}}" to quit'
.define resize_window : yabai -m window --resize {{1}}:{{2}}:{{3}}
.define toggle_scratchpad : yabai -m window --toggle {{1}} || open -a "{{2}}"
# Declare a keyboard by VendorID/ProductID (v0.1.0-alpha)
# See "Device-aware remapping" below for full details.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# Per-device HID remap — colon form (1:1 swap, applied via hidutil)
.remap caps_lock [device builtin] : escape
# Per-device tap-hold (routed through skhd-grabber)
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
}# Basic format: modifier - key : command
cmd - a : echo "Command+A pressed"
# Multiple modifiers
cmd + shift - t : open -a Terminal
# Different modifier combinations
ctrl - h : echo "Control+H"
alt - space : echo "Alt+Space"
shift - f1 : echo "Shift+F1"# Basic modifiers
cmd # Command key
ctrl # Control key
alt # Alt/Option key
shift # Shift key
fn # Function key
# Left/right specific modifiers
lcmd, rcmd # Left/right Command
lctrl, rctrl # Left/right Control
lalt, ralt # Left/right Alt
lshift, rshift # Left/right Shift
# Special modifier combinations
hyper # cmd + shift + alt + ctrl
meh # shift + alt + ctrl# Navigation keys
cmd - left : echo "Left arrow"
cmd - right : echo "Right arrow"
cmd - up : echo "Up arrow"
cmd - down : echo "Down arrow"
# Special keys
cmd - space : echo "Space"
cmd - return : echo "Return/Enter"
cmd - tab : echo "Tab"
cmd - escape : echo "Escape"
cmd - delete : echo "Delete/Backspace"
cmd - home : echo "Home"
cmd - end : echo "End"
cmd - pageup : echo "Page Up"
cmd - pagedown : echo "Page Down"
# Function keys
cmd - f1 : echo "F1"
cmd - f12 : echo "F12"
# Media keys
sound_up : echo "Volume Up"
sound_down : echo "Volume Down"
mute : echo "Mute"
brightness_up : echo "Brightness Up"
brightness_down : echo "Brightness Down"# Different commands for different applications
cmd - n [
"terminal" : echo "New terminal window"
"safari" : echo "New safari window"
"finder" : echo "New finder window"
* : echo "New window in other apps"
]# Keyboard layout fixes
0xa | 0x32 # UK keyboard § to `
shift - 0xa | shift - 0x32 # shift - § to ~
# Function key navigation (for laptop keyboards)
fn - j | down
fn - k | up
fn - h | left
fn - l | right
# When you have cmd - number for yabai spaces,
# and you still want the cmd - number to work in applications
ctrl - 1 | cmd - 1
ctrl - 2 | cmd - 2
ctrl - 3 | cmd - 3# Execute command but still send keypress to application
cmd - p -> : echo "This runs but Cmd+P still goes to app"# Window management mode with anybar visual indicator
# Install anybar: brew install --cask anybar
# Define window management mode for warp/stack operations
# Use anybar to indicate the mode: https://github.com/tonsky/AnyBar
:: winmode @ : echo -n "red" | nc -4u -w0 localhost 1738
:: default : echo -n "hollow" | nc -4u -w0 localhost 1738
# Enter window mode with meh + m (shift + alt + ctrl + m)
meh - w ; winmode
winmode < escape ; default
winmode < meh - w ; default
# Alternative: Enter window mode AND show notification (New in skhd.zig!)
# This executes the command when switching to the mode
# It allows for different commands to execute and switch to another mode
meh - w ; winmode : osascript -e 'display notification "Window mode active" with title "skhd"'
winmode < escape ; default : osascript -e 'display notification "Normal mode" with title "skhd"'
# Focus operations - basic hjkl for focus
winmode < h : yabai -m window --focus west || yabai -m display --focus west
winmode < j : yabai -m window --focus south || yabai -m display --focus south
winmode < k : yabai -m window --focus north || yabai -m display --focus north
winmode < l : yabai -m window --focus east || yabai -m display --focus east
# Move operations - shift + hjkl for moving
winmode < shift - h : yabai -m window --move rel:-80:0
winmode < shift - j : yabai -m window --move rel:0:80
winmode < shift - k : yabai -m window --move rel:0:-80
winmode < shift - l : yabai -m window --move rel:80:0
# Warp operations - alt + shift + hjkl for warping
winmode < alt + shift - h : yabai -m window --warp west
winmode < alt + shift - j : yabai -m window --warp south
winmode < alt + shift - k : yabai -m window --warp north
winmode < alt + shift - l : yabai -m window --warp east
# Stack operations - ctrl + shift + hjkl for stacking
winmode < ctrl + shift - h : yabai -m window --stack west
winmode < ctrl + shift - j : yabai -m window --stack south
winmode < ctrl + shift - k : yabai -m window --stack north
winmode < ctrl + shift - l : yabai -m window --stack east
# Stack management shortcuts
winmode < s : yabai -m window --insert stack # Toggle stack mode
winmode < u : yabai -m window --toggle float; yabai -m window --toggle float # Unstack window
winmode < n : yabai -m window --focus stack.next # Navigate stack next
winmode < p : yabai -m window --focus stack.prev # Navigate stack prev
# Resize submode
winmode < r ; resize
:: resize @ : echo -n "orange" | nc -4u -w0 localhost 1738
resize < h : yabai -m window --resize left:-20:0
resize < j : yabai -m window --resize bottom:0:20
resize < k : yabai -m window --resize top:0:-20
resize < l : yabai -m window --resize right:20:0
resize < escape ; winmode# Focus windows using command definitions (New in skhd.zig!)
cmd - h : @yabai_focus("west")
cmd - j : @yabai_focus("south")
cmd - k : @yabai_focus("north")
cmd - l : @yabai_focus("east")
# Move/swap windows using command definitions
cmd + shift - h : @yabai_swap("west")
cmd + shift - j : @yabai_swap("south")
cmd + shift - k : @yabai_swap("north")
cmd + shift - l : @yabai_swap("east")
# Resize windows using command definitions
cmd + ctrl - h : @resize_window("left", "-20", "0")
cmd + ctrl - l : @resize_window("right", "20", "0")
# Switch spaces
cmd - 1 : yabai -m space --focus 1
cmd - 2 : yabai -m space --focus 2# Quick app launching (traditional way)
alt - return : open -a Terminal
alt - b : open -a Safari
# Toggle apps using command definitions (New in skhd.zig!)
alt - f : @toggle_app("Finder")
alt - c : @toggle_app("Visual Studio Code")
# Scratchpad apps with yabai (New in skhd.zig!)
# In yabairc: yabai -m rule --add app="^YouTube Music$" scratchpad=music grid=11:11:1:1:9:9
alt - m : @toggle_scratchpad("music", "YouTube Music")
alt - n : @toggle_scratchpad("notes", "Notes")# Linux-style word navigation and deletion
ctrl - backspace [
@native_apps ~ # Terminal apps handle natively
* | alt - backspace # Other apps: delete word
]
ctrl - left [
@native_apps ~ # Terminal apps handle natively
* | alt - left # Other apps: move word left
]
ctrl - right [
@native_apps ~ # Terminal apps handle natively
* | alt - right # Other apps: move word right
]
# Home/End key behavior (with shift for selection)
home [
@native_apps ~ # Terminal apps handle natively
* | cmd - left # Other apps: line start
]
shift - home [
@native_apps ~ # Terminal apps handle natively
* | cmd + shift - left # Other apps: select to line start
]
# Ctrl+Home/End for document navigation
ctrl - home [
@native_apps ~ # Terminal apps handle natively
* | cmd - up # Other apps: document start
]
ctrl - end [
@native_apps ~ # Terminal apps handle natively
* | cmd - down # Other apps: document end
].remap rewrites keys at the HID layer per device. Two forms:
Colon form — instant 1:1 swap, applied via hidutil (no daemon needed):
# Declare the device once, by VendorID/ProductID.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# UK ISO MacBook: make § (top-left) act as the ISO grave key, so it types `.
.remap non_us_backslash [device builtin] : grave
Block form — tap-hold semantics (caps_lock → tap=escape / hold=ctrl,
space → fn_layer, etc.). Goes through skhd-grabber (see below) because
hidutil can't do tap vs. hold.
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
retro_tap : off
}
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
permissive_hold : on
retro_tap : on
}
Source/destination names use HID-standard physical-position naming
(layout-independent) — different from the macOS virtual-keycode names
shown by skhd -o. Run skhd --grabber-status or check
src/HidKeyMap.zig for the full list. Common: a-z, 0-9, caps_lock,
escape, space, return, tab, backspace, lctrl, lshift, lalt,
lcmd (and r* variants), f1..f20, minus, equal, lbracket,
rbracket, backslash, semicolon, quote, grave, comma,
period, slash, non_us_backslash.
macOS's user-level event tap can't see caps_lock or rewrite it cleanly
without LED toggle artifacts. Block-form .remap rules go through a
small system daemon (skhd-grabber) that runs as root, seizes the
matched keyboard via IOHIDManager, and injects through the Karabiner
DriverKit virtual HID device.
# Single command — installs the per-user agent and, if your config has
# caps_lock-class rules with a connected target device, prompts (Y/n)
# to install the system grabber via sudo. The same prompt also auto-
# downloads + installs the Karabiner DriverKit .pkg if it's missing
# and writes the launchd plist for its userland daemon (the .pkg's
# postinstall is a no-op `killall`, so we wire up launchd ourselves —
# but we skip it cleanly when Karabiner-Elements is already managing
# that label via SMAppService).
skhd --install-serviceAfter the grabber install succeeds the agent triggers the Input
Monitoring approval dialog. Granting it to skhd.app covers the
grabber too: both binaries are signed with the same bundle ID and
the grabber runs from inside skhd.app/Contents/MacOS/, so TCC
bundle-keys the grant — one click, both processes covered. No
manual "add /usr/local/libexec/skhd-grabber to Input Monitoring"
step.
If the prompt didn't fire (e.g. you added .remap rules later) or
you want to install the grabber separately:
sudo skhd --install-grabberDiagnostic walk-through of every prerequisite (dext, VHIDD daemon, grabber plist + process, IPC socket):
skhd --grabber-statusskhd-grabber requires the Karabiner DriverKit VirtualHIDDevice
extension to inject HID events. The agent's install flow auto-installs
the pinned version (currently v6.14.0; sha-256 verified) the first time
you run --install-service, so most users never touch this directly.
If you'd rather install it ahead of time:
skhd --install-dext # downloads + installs the pkg, writes the
# VHIDD daemon launchd plist (or skips if
# Karabiner-Elements is already handling it)Upstream releases: https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice
After install, macOS will prompt you to approve the system extension in System Settings → General → Login Items & Extensions → Driver Extensions.
skhd --uninstall-service # removes the LaunchAgent
sudo skhd --uninstall-grabber # removes skhd-grabber + the VHIDD
# daemon launchd plist we wrote--uninstall-service prints any follow-up commands (the grabber
isn't auto-removed because it's a separate sudo step). For the
Karabiner DriverKit pkg files and the kernel-loaded dext (pqrs's
domain), run their uninstall scripts under
/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/scripts/uninstall/,
or toggle the dext off via System Settings → Login Items &
Extensions → Driver Extensions.
If you share one config across a laptop and a desktop, .remap
block-form rules targeting the laptop's built-in keyboard simply
don't fire on the desktop — --install-service and the agent both
detect that the target device isn't connected and skip the grabber
entirely on that machine. No need to install the grabber or the dext
on a machine that doesn't need them.
- Signing: setting
HIDKeyboardCapsLockDelayOverriderequires an Apple Developer ID signature. Unsigned builds fall back to a reactive workaround: the grabber reads the OS caps_lock state viaCGEventSourceFlagsStateand, when Apple's firmware-level toggle fires, injects a vhidd caps_lock toggle to flip it back. Works cleanly in practice (no LED flash); seesrc/grabber/HidSeize.zigfor the rationale. - Coexistence with Karabiner-Elements: KE registers the same
VHIDD daemon launchd label (
org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon) viaSMAppService.daemon, so we share that piece —--install-dextdetects KE's registration and skips writing our own plist. But the grabber layer (karabiner_grabberfor KE,skhd-grabberfor us) still wants exclusive seize on the same keyboard. If you're running Karabiner-Elements, disable its grabber (sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber) before startingskhd-grabber.skhd --statusand--install-grabberflag this conflict when detected. - F-row behavior: with macOS's "Use F1, F2..." setting off (default), the grabber translates F1..F12 keyboard events to the appropriate Consumer / Apple-Vendor media events (volume, brightness, mission control, …) so the F-row stays "media keys" under seize.
This whole feature exists because of Karabiner-Elements by Takayama Fumihiko (pqrs.org). The architecture, the idea, and the runtime dependency all come from there — skhd.zig is reusing pqrs's lower-level work to plug "QMK-style remapping" into a config format more skhd users already know.
Karabiner-Elements is the comprehensive keyboard customizer for macOS: a polished GUI, complex modifications, simultaneous keys, parameterized rules, an event viewer, profiles, a giant community library of rule presets, and years of refinement. If you want a turnkey keyboard remapper with a UI, install Karabiner-Elements — it's the right tool. skhd.zig's grabber path is intentionally narrower:
- One config format — your existing
.skhdrcplus a few new directives. No JSON, no GUI, no separate rule-set system. - Tap-hold + layer holds + 1:1 remaps + device scoping. That's it. Anything outside that is still standard skhd hotkey territory (event-tap based, no grabber needed).
- No menu bar app, no preferences pane, no event viewer. Diagnostics live in
skhd --statusandskhd --grabber-status.
If you need anything more sophisticated than the four directives in this README, Karabiner-Elements is unequivocally the better choice.
From Karabiner — architecture and runtime infrastructure:
- Karabiner-DriverKit-VirtualHIDDevice — the kernel-side system extension + userland helper daemon that lets us inject synthesized HID events on modern macOS. Apple removed kernel extension support; pqrs's DriverKit replacement is, as far as we know, the only practical route for HID injection on Big Sur and later. We auto-install the pinned version via
skhd --install-dext, share the launchd registration if Karabiner-Elements is already there, and never modify their code. Apache-2.0 licensed; the .pkg ships under pqrs's signing identity. - The architectural pattern — userland agent talks to a root daemon, which seizes HID via IOHIDManager and injects through DriverKit. Karabiner-Elements pioneered this approach on macOS; skhd-grabber is a smaller config-driven implementation of the same shape.
- HID-standard key naming —
caps_lock,non_us_backslash,lctrl, etc. (layout-independent physical positions, distinct from the macOS virtual-keycode names skhd uses elsewhere) — Karabiner uses the same identifiers, so cross-referencing their docs for which name maps to which physical key is direct.
From QMK — tap-hold parameters and defaults:
We deliberately do not use Karabiner's complex modifications JSON dialect (verbose, camelCase, to_if_alone / to_if_held_down / etc.). skhd users come from a .skhdrc background and want a config that reads like the rest of skhd. We follow QMK firmware's tap-hold model instead: snake_case keywords, the same parameter set you'd put in a QMK config.h, and the same defaults.
timeout↔ QMKTAPPING_TERM(default 200ms).permissive_hold↔ QMKPERMISSIVE_HOLD.hold_on_other_key_press↔ QMKHOLD_ON_OTHER_KEY_PRESS.retro_tap↔ QMKRETRO_TAPPING.
If you've configured a custom keyboard in QMK, you already know how every knob in .taphold behaves — they're direct ports. QMK's docs/tap_hold.md is the canonical long-form reference for why each parameter exists and what edge cases it solves; our parser implements the same semantics rule-for-rule.
skhd-grabber and Karabiner-Elements both want exclusive HID seize on the same keyboard, so running both grabbers at once doesn't work. We coexist at the DriverKit daemon layer (we share the same org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon registration; if Karabiner-Elements is installed, our --install-dext detects its SMAppService registration and skips writing our own plist), but you have to pick one grabber at a time. See the Coexistence caveat above for the disable command.
If you're switching from Karabiner-Elements to skhd.zig: keep the dext, keep the daemon, just disable karabiner_grabber. If you want to go back the other way: sudo skhd --uninstall-grabber and re-enable Karabiner-Elements.
Thanks again to pqrs and the Karabiner-Elements community. None of this is novel work on our side — we're just packaging a narrow slice in a different config format.
When something isn't working, start with these:
skhd --status # one-line summary: agent, grabber, dext, TCC
skhd --grabber-status # drills into the grabber dependency chain
# (socket, dext version, IOKit match, …)--status is the fastest way to spot a missing piece (e.g. agent running
but grabber not installed, or dext loaded but not enabled).
--grabber-status is what to run when a .remap/.taphold rule isn't
firing.
Two processes, two log files:
# Agent (user-session skhd) — config parsing, event tap, hotkey dispatch.
tail -f ~/Library/Logs/skhd.log
# skhd-grabber (root LaunchDaemon) — only present if you installed the
# grabber. Captures HID seize, tap-hold timing, layer pushes, IPC traffic.
sudo tail -f /var/log/skhd-grabber.logFor unified-logging captures across both processes:
log show --last 5m --predicate 'process == "skhd" OR process == "skhd-grabber"'| Build | -V shows |
-P profiling |
|---|---|---|
| ReleaseFast (Homebrew default) | errors + warnings only | disabled (compiled out) |
| ReleaseSafe | + info | available |
Debug (zig build) |
+ debug | available with full traces |
Homebrew installs are ReleaseFast, so -V against the installed binary is
intentionally quiet. To dig into a hotkey misbehaviour, run a Debug or
ReleaseSafe build directly:
zig build run -- -V # debug logs
zig build -Doptimize=ReleaseSafe && \
./zig-out/bin/skhd -V # info logs, prod-shaped binaryVerbose mode also preserves child-command stdout/stderr (useful for
diagnosing why a : command is silent), at a small per-event cost.
skhd -o # echo every keycode + modifier the tap sees
skhd -k "cmd + shift - t" # synthesize a keypress
skhd -t "hello world" # synthesize text
skhd -r # reload running instance's configskhd -o prints macOS virtual keycodes (e.g. 0x35 for escape).
.remap/.taphold rules use HID-standard names (e.g. escape) — see
Device-aware remapping.
zig build && ./zig-out/bin/skhd -P # Debug
zig build -Doptimize=ReleaseSafe && \
./zig-out/bin/skhd -P # ReleaseSafe — closer to prodCtrl-C prints the trace summary.
zig build alloc -- -VThe event loop is allocation-free in release builds, so any allocation in the hot path during interactive use is a regression.
| Symptom | Start here |
|---|---|
| Hotkey isn't firing | skhd --status — agent running? Accessibility granted? |
| TCC granted but keys silently swallowed | tccutil reset ListenEvent com.jackielii.skhd && tccutil reset Accessibility com.jackielii.skhd, then skhd --restart-service and re-grant |
.remap / .taphold does nothing |
skhd --grabber-status — dext enabled? grabber running? device matched? |
| Caps lock LED toggles weirdly | /var/log/skhd-grabber.log; search for HIDKeyboardCapsLockDelayOverride |
| Karabiner-Elements also installed | sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber |
skhd --uninstall-service # removes the LaunchAgent
sudo skhd --uninstall-grabber # removes skhd-grabber
# (Karabiner DriverKit dext stays — see Uninstall above)
systemextensionsctl list # inspect dext activation/enabled state- Fork the repository
- Create a feature branch
- Make your changes
- Run tests:
zig build test - Submit a pull request
This project maintains compatibility with the original skhd license.