skhd.zig is a Zig port of the macOS hotkey daemon that also remaps keys
Simple Hotkey Daemon for macOS, ported from skhd by asmvik
At a glance
- What is it?
- skhd.zig is a Zig rewrite of the skhd hotkey daemon for macOS that keeps the original .skhdrc format, adds hotkey sequences, and ships a root grabber daemon so tap-versus-hold rules and layer holds can work below the level an event tap can reach. It ships as a signed .app bundle and needs Accessibility, Input Monitoring and a sudo-installed Karabiner DriverKit package.
- Who is it for?
- Mac users who already keep a .skhdrc file get an easy upgrade path here, and keyboard tinkerers who want caps_lock to act as escape on tap and ctrl on hold get a mechanism the original event-tap model cannot provide. It is the wrong tool for anyone who does not want to grant Accessibility and Input Monitoring, run a root LaunchDaemon, or install the Karabiner DriverKit package.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 93 days ago.
- What is it written in?
- Mainly Zig, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The configuration format survives the port to Zig
skhd.zig is a port of skhd, the macOS hotkey daemon, rewritten in Zig. The compatibility claim is specific: the implementation is described as fully compatible with the original skhd configuration format, so existing `.skhdrc` files work without modification. That matters more than the language change, because a hotkey daemon is only useful once your muscle memory and your configuration file agree. The port adds on top of the original set: process groups, command definitions through `.define`, key forwarding and remapping, and improved error reporting. Two extra documents sit at the repository root alongside the code, SYNTAX.md for the directive grammar and CHANGELOG.md for the release history, with docs/, tools/, scripts/, testdata/ and a `taphold_test.skhdrc` fixture alongside. The build is driven by `build.zig` and `build.zig.zon`, which is the usual Zig project layout, so building from source means using the Zig toolchain rather than a package manager.
v0.2.0 binds chord sequences inside a 300ms budget
The newest feature lets a run of chords fire one action, but only when the chords arrive in order within a time budget. The project documentation gives the default as 300ms and names the setting that changes it, `.sequence_timeout`. The canonical example binds two quick Cmd-Q presses to quit Chrome while a single press does nothing:
# Two quick Cmd-Q presses to quit Chrome; a single press does nothing.
cmd - q, cmd - q [
"Google Chrome" | cmd - q
]The comma separates the chords of the sequence, and the bracket body carries the action, here a process-scoped command. The project states that every action form works on a sequence: commands, forwarding, unbound or passthrough, mode activation and process lists. That last point is the useful one for anyone porting a config, since a sequence is not limited to the simple case above and can carry the same kinds of payload that a single binding can.
A shorter binding can act as the prefix of a longer one
Sequence support has a wrinkle that the project documentation explains with a comparison to Vim's `timeoutlen`: a shorter binding can also serve as the prefix of a longer sequence, and it fires when the longer sequence does not complete. The waiting happens only in applications where a longer match applies, which is the detail that makes this usable rather than annoying. The example pairs a global binding with an application's own shortcut:
cmd - k, cmd - k [
"Google Chrome" | cmd - k # …but double-tap sends Chrome its own Cmd-K
]The documentation pairs that with a plain `lcmd - k` binding that focuses the window to the north, so outside Chrome the chord acts immediately with no waiting. Inside Chrome, the first press waits, and a second press within the budget hands the chord to Chrome itself. The cost of this design is latency inside the apps where the longer pattern applies, and the benefit is that one chord can carry two meanings depending on context rather than being bound twice.
The grabber daemon exists because an event tap cannot see every key
The most interesting part of the project arrived in v0.1.0 and goes beyond hotkeys into keyboard remapping. The project documents a system-level grabber daemon that enables remapping of input the user-session event tap cannot reach. There are three tiers. A `.remap caps_lock [device <alias>] : escape` line is an instant one-to-one swap applied through `hidutil`, with no daemon involved. The harder case is `.remap key [device <alias>] { tap: ..., hold: ... }`, which distinguishes a tap from a hold on the same key, such as caps_lock tapped as escape and held as control. The documentation is explicit that this needs `skhd-grabber`, a root LaunchDaemon that seizes the keyboard at the IOKit and HID level via the Karabiner DriverKit, and that it is required for caps-lock-class rules and modifier-as-hold rules that `hidutil` silently drops. A third tier lets `hold` target a skhd mode instead of a key, so holding a key activates a layer for the duration of the hold.
Installing the app bundle and registering the service
Two installation paths are documented. Homebrew is the easy one, and after that a single command registers the launch agent and sets up the permissions:
brew install jackielii/tap/skhd-zig
skhd --install-serviceThe documentation describes what `--install-service` actually does: it registers the agent, auto-installs the Karabiner DriverKit package if the config contains tap-hold rules, prompts for the grabber install via sudo, and pops the Accessibility and Input Monitoring dialogs in sequence, one click each. Pre-built tarballs exist for both architectures, `skhd-arm64-macos.tar.gz` for Apple Silicon and `skhd-x86_64-macos.tar.gz` for Intel, with the Intel build cross-compiled from the arm64 runner. A manual install extracts the tarball, moves `skhd.app` into `/Applications` and runs the same service install from inside the bundle. The project also warns that anyone on 0.0.17 or earlier has work to do: version 0.0.18 switched to an `.app` bundle to keep accessibility permissions working on macOS Tahoe, and a one-time re-grant is required.
A caps_lock to escape and ctrl config, end to end
The worked example lives in `~/.config/skhd/skhdrc` and shows the three pieces working together:
# 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
}The device block scopes the rules to one keyboard by vendor and product ID, so a single config can behave differently on each machine, and the README tells you to replace those IDs with your own, found through `skhd --grabber-status` or System Information. The fourth piece of the example rebinds the number row while the layer is held, with `fn_layer < 1 | f1` and the following lines. After saving, `skhd --restart-service` applies the config, and the expected result is that tapping caps_lock sends escape while holding it gives you ctrl.
Checking what is installed, and taking it back out
The verification commands are worth knowing before anything breaks. `skhd --status` shows the agent, grabber, dext and TCC state in a single command, and `skhd --grabber-status` drills into the grabber-side dependency chain. TCC is the part that trips people up on macOS, since the Accessibility and Input Monitoring permissions are granted to an application, not to a binary, which is the reason for the .app bundle switch on macOS Tahoe. Rollback is documented in the same breath: `skhd --uninstall-service` removes the service and prints follow-up instructions, including `sudo skhd --uninstall-grabber` for anything still left on disk. That second step matters, because a root LaunchDaemon and a DriverKit package outlive the agent if you stop at the first command. The project also links a larger real-world configuration from the author's own machine, which turns a MacBook built-in keyboard into the behaviour of a Keebio Convolution running custom QMK firmware, with a matching QMK keymap linked alongside it.
MIT licensed, with releases landing every few weeks
The licence is MIT, so the source can be read, modified and redistributed under its terms, and a team that dislikes the .app bundle approach can fork the build. The project is not archived. Its recent release history shows v0.1.10 on 2026-06-24, v0.1.11 on 2026-07-12 and v0.2.0 on 2026-07-20, and the last push to the repository was on 2026-07-22, two days after the v0.2.0 tag. The cadence of roughly one release every two to three weeks in that window is visible in the tags, though the three releases are too few to establish a pattern to rely on. The bigger upgrade cost for adopters is not the binary but the permissions model: moving from a plain event-tap daemon to a root-level grabber means new install steps, new macOS dialogs and, for anyone on 0.0.17 or earlier, a re-grant of accessibility permissions after the .app bundle switch.
Editorial conclusion
Mac users who already keep a .skhdrc file get an easy upgrade path here, and keyboard tinkerers who want caps_lock to act as escape on tap and ctrl on hold get a mechanism the original event-tap model cannot provide. It is the wrong tool for anyone who does not want to grant Accessibility and Input Monitoring, run a root LaunchDaemon, or install the Karabiner DriverKit package. Before committing, check `skhd --status` on an existing machine and read the .skhdrc grammar in SYNTAX.md, because the tap-hold directives are new and only partly covered by the README.
Frequently asked questions
Will my existing .skhdrc file work with skhd.zig?
The documentation states the implementation is fully compatible with the original skhd configuration format, so existing .skhdrc files work without modification. The port adds new directives such as .remap, .device and .sequence_timeout alongside the original syntax.
What permissions does skhd.zig need on macOS?
The install step pops the Accessibility and Input Monitoring dialogs, one click each, and the grabber path additionally prompts for a sudo install of a root LaunchDaemon and auto-installs the Karabiner DriverKit package when your config has tap-hold rules. skhd --status reports the agent, grabber, dext and TCC state in one place.
How do I make caps_lock send escape when tapped and control when held?
Use a .remap block with a tap line and a hold line, scoped to a device if you want it limited to one keyboard. The documentation notes that rules of this kind need the skhd-grabber daemon, because hidutil silently drops caps-lock-class and modifier-as-hold rules.
How do I remove skhd.zig from my Mac?
Run skhd --uninstall-service to remove the launch agent. The documentation says that command prints follow-up instructions, including sudo skhd --uninstall-grabber, for anything still left on disk.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/jackielii-skhd-zig)Was this useful?