kanata-macos-setup
Use when the user asks to "set up kanata", "replace karabiner", "remap caps lock", "key remapper macOS", "kanata connect_failed", "keyboard not working after brew upgrade", or needs to configure kanat
SKILL.md.claude/skills/kanata-macos-setup/SKILL.md
GitHubContext
On macOS, kanata uses the Karabiner DriverKit VirtualHIDDevice for output.
Karabiner-Elements (the full app, via brew install --cask karabiner-elements) must stay installed — it is what provides and launches the Karabiner-VirtualHIDDevice-Daemon. There is no supported standalone-driver-only install; the daemon's LaunchDaemon plist lives inside the KE app bundle, not in /Library/LaunchDaemons. Only KE's own remapping engine (Karabiner-Core-Service and its GUI agents) gets disabled — the app itself is not removed.
Kanata must run as root via LaunchDaemon (not LaunchAgent).
Critical: kanata requires TWO components at boot:
org.pqrs.Karabiner-DriverKit-VirtualHIDDevice— the kernel dext (auto-started by the system extension framework)Karabiner-VirtualHIDDevice-Daemon— the bridge daemon, launched via the plist bundled inside/Library/Application Support/org.pqrs/Karabiner-Elements/Karabiner-Elements Privileged Daemons v2.app/Contents/Library/LaunchDaemons/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon.plist
If the daemon is missing or can't connect, kanata loops with connect_failed asio.system:NN at boot, which monopolizes the internal keyboard HID and blocks all input after login. Two different root causes produce this same symptom — see the two troubleshooting entries below; don't assume it's always the HID-conflict one.
File layout in dotfiles
| Path | Purpose |
|---|---|
kanata/kanata.kbd |
Config (XDG, stowed to ~/.config/kanata/) |
kanata-daemon/com.lucatrifilio.kanata.plist |
LaunchDaemon template used only by the manual/README bootstrap flow (sed __HOME__ + sudo tee) — the ansible role uses its own separate jinja template below, not this file |
kanata-daemon/karabiner-core-service-watchdog.sh |
Watchdog script, boots out KE Core-Service/agents every 15s (see troubleshooting below) |
ansible/roles/macos/templates/kanata.plist.j2 |
Real jinja template ansible installs to /Library/LaunchDaemons/com.lucatrifilio.kanata.plist |
ansible/roles/macos/templates/karabiner-watchdog.plist.j2 |
Jinja template ansible installs to /Library/LaunchDaemons/com.lucatrifilio.karabiner-watchdog.plist |
Add --ignore=^kanata-daemon$ to .stowrc.
The actual install path is ansible/roles/macos/tasks/main.yml (enable_kanata var) — it templates kanata.plist.j2 to /Library/LaunchDaemons/com.lucatrifilio.kanata.plist, bootstraps it, disables the KE Core-Service daemon + GUI agents listed below, and installs/bootstraps the watchdog LaunchDaemon (see below). It does not install any separate VirtualHIDDevice-Daemon plist — KE's own bundled one is used as-is.
kanata.kbd template
(defcfg
macos-dev-names-include (
"Apple Internal Keyboard / Trackpad"
)
process-unmapped-keys yes
)
(defsrc
caps f1 f2 f7 f8 f9 f10 f11 f12
)
(deflayer base
@cap brdn brup prev pp next mute vold volu
)
(defalias
cap (tap-hold 200 200 esc lctl)
)
macos-dev-names-include is a whitelist — only listed devices are intercepted.
All others (external keyboards, Glove80, etc.) are ignored automatically.
Media keys: when kanata grabs the device in exclusive mode, the Apple keyboard sends media keys as F-keys (F1, F2, F7-F12) rather than consumer HID events. They must be explicitly mapped back to consumer outputs (brdn, brup, mute, vold, volu, pp, prev, next) — transparent _ is not enough. Do NOT use process-unmapped-keys no to fix this; it breaks caps lock remapping.
Kanata LaunchDaemon
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.lucatrifilio.kanata</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/kanata</string>
<string>--cfg</string>
<string>__HOME__/.config/kanata/kanata.kbd</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/kanata.log</string>
<key>StandardErrorPath</key>
<string>/tmp/kanata.log</string>
</dict>
</plist>
Use __HOME__ as placeholder — the install step substitutes it with $HOME.
Troubleshooting: kanata stopped working after dotfiles change
Symptom: kanata smette di funzionare dopo una modifica al plist template in dotfiles (es. refactor, nuovo path, nuovi argomenti).
Cause: il plist in /Library/LaunchDaemons/ non si aggiorna automaticamente quando cambia il template nel repo — sono due file separati. Il playbook ansible va rieseguito manualmente per propagare il cambiamento.
Fix: reinstalla il plist dal template aggiornato:
cd ~/Progetti/dotfiles
sed "s|__HOME__|$HOME|g" kanata-daemon/com.lucatrifilio.kanata.plist > /tmp/kanata.plist
sudo cp /tmp/kanata.plist /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
sudo launchctl bootout system/com.lucatrifilio.kanata 2>/dev/null || true
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
sudo launchctl list | grep kanata # deve mostrare PID numerico
Troubleshooting: kanata not running after reboot (plist present but not bootstrapped)
Symptom: kanata not working after reboot; plist exists in /Library/LaunchDaemons/ but sudo launchctl list | grep kanata returns nothing. Process can only be started manually with sudo kanata ....
Cause: the plist was copied to /Library/LaunchDaemons/ but never bootstrapped into launchd (e.g. setup ran cp but not bootstrap, or the service was bootouted).
Diagnose:
sudo launchctl print system/com.lucatrifilio.kanata
# "Could not find service" = not bootstrapped
Fix:
sudo kill $(pgrep -x kanata) 2>/dev/null || true
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
sudo launchctl print system/com.lucatrifilio.kanata # should show state = running
Troubleshooting: keyboard blocked after boot (HID device conflict)
Symptom: internal keyboard works at login screen but stops after login. cat /tmp/kanata.log shows connect_failed asio.system:61 in a loop.
Cause: kanata starts before Karabiner-VirtualHIDDevice-Daemon is ready, monopolizing the HID device. This is a timing problem — the daemon exists and is compatible, it just hasn't bound its socket yet.
Diagnose:
sudo launchctl list | grep -i "pqrs\|karabiner\|virtual"
# Should show Karabiner-VirtualHIDDevice-Daemon running.
ps aux | grep -E "kanata|VirtualHIDDevice-Daemon"
Fix:
sudo launchctl bootout system/com.lucatrifilio.kanata
sudo launchctl bootout system/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon 2>/dev/null || true
sudo launchctl bootstrap system "/Library/Application Support/org.pqrs/Karabiner-Elements/Karabiner-Elements Privileged Daemons v2.app/Contents/Library/LaunchDaemons/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon.plist"
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
Note on com.apple.provenance: files created via sudo tee get this xattr and become immutable even with sudo. Workaround: write to /tmp/ first, then sudo cp to the destination.
Troubleshooting: kanata connect_failed despite daemon running — driver/kanata version mismatch (CRITICAL, easy to misdiagnose as the HID-conflict above)
Symptom: connect_failed asio.system:2 loops forever in /tmp/kanata.log, even after confirming both com.lucatrifilio.kanata and Karabiner-VirtualHIDDevice-Daemon are freshly running, even after a full reboot, even after fully removing and reinstalling Karabiner-Elements. /var/log/karabiner/virtual_hid_device_service.log shows the daemon logging bound successfully but never logs peer_connected for kanata's attempts.
Cause: brew install --cask karabiner-elements always pulls the latest driver (Karabiner-DriverKit-VirtualHIDDevice). Kanata's macOS client is version-pinned to an older driver protocol — as of kanata v1.12.0 (latest, 2026-07-05) the documented supported driver is v6.2.0. Driver v7.0.0+ introduced breaking protocol changes (removed get_status, bumped protocol version), so any driver ≥ v7 silently breaks kanata's connection with no clear error — asio.system:2 (ENOENT) rather than a version-mismatch message. Kanata's maintainer has no macOS hardware and doesn't validate this path, so this drift is not caught upstream.
Diagnose:
# Confirm daemon never logs peer_connected for the new client despite it running:
sudo tail -20 /var/log/karabiner/virtual_hid_device_service.log
# Trace what path kanata is actually trying to reach (look for "vhidd_server" vs whatever
# the daemon actually created — "vhidd_client"/"vhidd_response" dirs are a mismatch):
sudo fs_usage -w -f filesys 2>/dev/null | grep -i kanata
# Compare installed driver version against kanata's supported ceiling:
pkgutil --pkg-info org.pqrs.Karabiner-DriverKit-VirtualHIDDevice # look at "version:"
gh release view --repo jtroo/kanata --json body -q .body | grep -i "driver version"
If the installed driver version is higher than what kanata's release notes document as supported, this is the cause.
Fix: downgrade just the driver package (not the whole KE app) to the version kanata supports, via the pqrs-org standalone installer:
gh release download v6.2.0 --repo pqrs-org/Karabiner-DriverKit-VirtualHIDDevice --pattern "*.pkg" --clobber
sudo installer -pkg Karabiner-DriverKit-VirtualHIDDevice-6.2.0.pkg -target /
pkgutil --pkg-info org.pqrs.Karabiner-DriverKit-VirtualHIDDevice # confirm version: 6.2.0
# Restart daemon + kanata (no reboot or dext re-approval needed — same team ID/bundle ID):
sudo launchctl bootout system/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon 2>/dev/null || true
sudo launchctl bootstrap system "/Library/Application Support/org.pqrs/Karabiner-Elements/Karabiner-Elements Privileged Daemons v2.app/Contents/Library/LaunchDaemons/org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon.plist"
sudo launchctl bootout system/com.lucatrifilio.kanata
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
tail -f /tmp/kanata.log # should show "driver connected: true", no more connect_failed
Prevention: brew upgrade/brew reinstall --cask karabiner-elements will silently re-pull the latest (incompatible) driver. Either brew pin karabiner-elements, or re-check pkgutil --pkg-info org.pqrs.Karabiner-DriverKit-VirtualHIDDevice after any KE-related brew upgrade and re-apply the downgrade above if it moved past the version kanata's current release documents as supported.
Do NOT respond to this symptom by deleting /Applications/Karabiner-Elements.app or inventing a standalone VirtualHIDDevice-Daemon LaunchDaemon outside the KE app bundle — neither is how the real working setup is structured (see Context above), and both make the actual version-mismatch cause harder to find.
Troubleshooting: Input Monitoring / Accessibility permission lost after every kanata upgrade
Symptom: after brew upgrade kanata (or after removing/re-adding the entry), kanata crash-loops: first failed to open keyboard device(s): kanata needs macOS Input Monitoring permission, then — after re-granting that — kanata needs macOS Accessibility permission. Both permissions are required, not just Input Monitoring.
Cause: kanata ships ad-hoc signed (codesign -dv shows flags=0x20002(adhoc,linker-signed), TeamIdentifier=not set, identifier like kanata-<hash>). The identifier changes on every build/version, so TCC (which ties the grant to resolved path + code requirement) loses the permission on every upgrade — not just when the versioned Cellar path changes, but even at the same path if the binary is rebuilt. Confirmed by the kanata community (github.com/jtroo/kanata/discussions/1537): there is no workaround on the Input Monitoring side, it must be re-granted manually after every upgrade.
Diagnose:
tail -10 /tmp/kanata.log
# "needs macOS Input Monitoring permission" or "needs macOS Accessibility permission"
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT client,auth_value FROM access WHERE service='kTCCServiceListenEvent' AND client LIKE '%kanata%';"
Fix (repeat on every upgrade, unless/until brew pin kanata or a fixed re-sign identifier is adopted):
readlink /opt/homebrew/bin/kanata # resolve the real versioned path, e.g. ../Cellar/kanata/1.12.0/bin/kanata
- System Settings → Privacy & Security → Input Monitoring →
+→Cmd+Shift+G→ paste the resolved real path above (the Finder picker does not follow symlinks, it needs the versioned Cellar path, not/opt/homebrew/bin/kanata) → select → enable the toggle - Repeat the same for Accessibility (same path)
- Restart the service:
sudo launchctl bootout system/com.lucatrifilio.kanata 2>/dev/null || true
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
tail -f /tmp/kanata.log # expect "driver connected: true", "virtual_hid_keyboard_ready true"
Prevention (not yet applied): two options considered, neither implemented:
brew pin kanata— avoids automatic upgrades, permission stays valid until you unpin/upgrade yourself- Copy the binary to a fixed path and re-sign it with
codesign --sign - --identifier <fixed-id>instead of the default hash-based identifier — survives upgrades but requires re-running the copy+sign step after everybrew upgrade kanata
Troubleshooting: KE agents revive and break caps lock remapping (recurring)
Symptom: caps lock remapping stops working; kanata log loops driver connected: false / driver connected: true. Media keys may also break.
Cause: KE user agents (especially Karabiner-Core-Service-rev2) restart after a macOS update, KE reinstall, or KE app launch (even a headless open -g), grabbing the HID device before kanata. Always check both system daemon and user agents.
Diagnose:
sudo launchctl list | grep karabiner # system daemon
launchctl list | grep karabiner # user agents — Core-Service-rev2 is the main offender
tail -20 /tmp/kanata.log # loop = device conflict
Fix:
sudo launchctl disable system/org.pqrs.service.daemon.Karabiner-Core-Service
sudo launchctl bootout system/org.pqrs.service.daemon.Karabiner-Core-Service
for agent in \
org.pqrs.service.agent.Karabiner-Core-Service \
org.pqrs.service.agent.Karabiner-Core-Service-rev2 \
org.pqrs.service.agent.karabiner_console_user_server \
org.pqrs.service.agent.karabiner_session_monitor \
org.pqrs.service.agent.Karabiner-NotificationWindow; do
launchctl disable gui/$(id -u)/$agent 2>/dev/null || true
launchctl bootout gui/$(id -u)/$agent 2>/dev/null || true
done
sudo launchctl bootout system/com.lucatrifilio.kanata
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.kanata.plist
Troubleshooting: "Waiting for a connection from Karabiner-Core-Service to Karabiner-VirtualHIDDevice-Daemon" popup in KE Settings.app
Symptom: opening Karabiner-Elements.app (esp. clicking into Simple/Complex Modifications tabs) shows an infinite spinner dialog. kanata itself keeps working fine (tail /tmp/kanata.log still shows virtual_hid_keyboard_ready true) — this is cosmetic, not a functional break.
Cause: launchctl disable on Core-Service/its agents (see entry above) only sets the persistent disabled bit — it does not stop KE Settings.app from force-starting those processes directly when you open it or navigate its tabs (confirmed: /var/db/com.apple.xpc.launchd/disabled*.plist mtime does not change when the popup appears, so the app is not going through the normal launchctl enable/bootstrap path). Once Core-Service starts, it can never actually connect — kanata already holds the VirtualHIDDevice-Daemon connection exclusively — so the dialog spins forever until Core-Service is killed again.
Because the disabled bit never flips, a WatchPaths-based launchd trigger on the disabled-state file won't catch this — there's nothing to watch. The only reliable fix is a polling watchdog.
Fix: com.lucatrifilio.karabiner-watchdog — a root LaunchDaemon (ansible/roles/macos/templates/karabiner-watchdog.plist.j2 → /Library/LaunchDaemons/) that runs kanata-daemon/karabiner-core-service-watchdog.sh every 15s (StartInterval), boot-outing Core-Service + its GUI agents (same list as the entry above) if found. Installed automatically via the ansible macos role when enable_kanata. Manual bootstrap:
sudo launchctl bootout system/com.lucatrifilio.karabiner-watchdog 2>/dev/null || true
sudo launchctl bootstrap system /Library/LaunchDaemons/com.lucatrifilio.karabiner-watchdog.plist
sudo launchctl print system/com.lucatrifilio.karabiner-watchdog # state = running
cat /tmp/karabiner-watchdog.log # empty unless it actually had to boot something out
Note: an already-open, already-stuck Settings window won't refresh itself just because the watchdog kills the backend process — quit (Cmd+Q) and reopen it. Whether reopening + navigating tabs still briefly flashes the dialog before the watchdog clears it (worst case ~15s) was not confirmed by direct reproduction, only inferred from the mechanism.
Troubleshooting: media keys (volume, brightness, play/pause) not working
Symptom: pressing F1/F2/F7-F12 triggers standard F-key actions instead of media actions.
Cause: when kanata grabs the device in exclusive mode (process-unmapped-keys yes), the Apple keyboard delivers media keys as F1/F2/F7-F12 (keyboard HID page). kanata re-emits them as plain F-keys through VirtualHID, which macOS doesn't translate to media actions.
Fix: map F-keys explicitly to consumer outputs in defsrc/deflayer (see template above). Do NOT switch to process-unmapped-keys no — it restores media keys but breaks caps lock remapping.
Brewfile
brew "kanata"
cask "karabiner-elements" # REQUIRED — provides the VirtualHIDDevice-Daemon kanata depends on.
# Do not remove after install; only its Core-Service engine is disabled.
Manual steps (new machine)
- Add the resolved kanata binary (
readlink /opt/homebrew/bin/kanatafor the real Cellar path) to both System Settings → Privacy & Security → Input Monitoring and Accessibility — see the permission-loss troubleshooting entry above, both are required - Remove leftover KE entries from Input Monitoring if present
- After first install (or any
karabiner-elementscask upgrade), check the driver version per the version-mismatch entry above — the cask does not pin a kanata-compatible version
KE services reference
| Service | Keep? | Reason |
|---|---|---|
| Karabiner-Elements.app (full bundle) | YES | hosts the VirtualHIDDevice-Daemon's LaunchDaemon plist |
Karabiner-VirtualHIDDevice-Daemon |
YES | kanata output driver bridge |
Karabiner-DriverKit-VirtualHIDDevice (dext) |
YES, but version-pinned | must not exceed kanata's supported driver version (v6.2.0 as of kanata v1.12.0) |
Karabiner-Core-Service (daemon) |
NO | karabiner_grabber, conflicts with kanata for the HID device |
Karabiner-Core-Service (agent) |
NO | KE UI agent |
karabiner_console_user_server |
NO | keyboard type dialog |
karabiner_session_monitor |
NO | session handling |
Karabiner-NotificationWindow |
NO | annoying dialog |