macOS 14 and later · menu bar app · one config file
True hotkeys for macOS.Nothing less, nothing more.
One plain‑text file. Any key becomes a command: a shell script, Python, AppleScript, a menu item, a keystroke. And it fires only when the app or window you named is in front.
One text file in git, a key per line. Diff it, sync it with your dotfiles, copy a line from a colleague.
Context aware
The same key does a different job in every app and window, with an else for everywhere else.
Runs anything
Shell in your login shell, Python, AppleScript, a menu item by name, a keystroke into any app.
Native speed
A Swift event tap. The key is handled before the app in front even sees it. No Electron, no polling.
How it worksNo macro editor. No drag and drop. A text file you already know how to edit.
A key, a space, a command.
Everything else is optional. Add a context when the same key should do different things in different apps. Save, reload, press.
01
Write a line
Modifiers are one symbol each. ^ control, ! option, * shift, # command. After a space comes the action, a method, a shell command or AppleScript.
02
Add a context
By bundle id, by window title, by process. One if on the line keeps the key to that app, and every other app still gets its key.
03
Save, reload
Reload from the menu, or bind reloadConfig() to a key. A line with a mistake is named with its line number, the rest loads.
// 01 write a line^!crunApp("Google Chrome")
^!#t ~/bin/timesheet.py start
// 02 add a context, Option S opens the page source, only in Chrome!sifcurrentAppChrome() sendKeys("command-option-u")
// 03 save, then reload from a key#f10reloadConfig()
Everything a hotkey tool should do. In one file.
Built for people who live in a terminal, an IDE and a browser and want the keyboard to do the walking.
Config as code
hk, a small format made for hotkeys: a key and its action on one line, // comments, NAME = value macros, no quotes around names, no colons. Lives in Application Support, so it goes into git and travels with your dotfiles. No settings UI, ever.
Context aware
currentAppChrome(), currentApp("com.google.Chrome", "com.apple.Safari"), currentWindowContains("Inbox"), currentProcessEquals("scrcpy"). The same key, a different job in every app, with else if and else for the rest.
Run anything
Shell commands run in your login shell, so your PATH, brew and virtualenvs just work. AppleScript on the line or as a script under it. open an app, a file, a URL.
Send keystrokes
sendKeys("control-comma"). Give a Mac app the shortcut it never had, remap Home and End per app, chain keys after a script.
Menu items by name
openMenuItem("Google Chrome", "View", "Developer", "View Source"). Any menu of any app, three levels deep, no screen coordinates, no recording of clicks.
Modifier‑only hotkeys
⌥⇧ alone switches the keyboard layout, English and Spanish or any pair, the way it does on Windows. Any two or more modifiers make a hotkey alone. displayAvailableKeyboardInputSources() lists the layout ids.
Mouse buttons
rightclick, middleclick, mouse4 and mouse5 are hotkeys like any key, with modifiers and contexts. The side buttons can switch tabs in Chrome and stay themselves everywhere else.
Forward or consume
forward runs your action and still lets the key through to the app. consume swallows it even when nothing ran, #q consume alone retires a key. Per hotkey, no surprises.
Steps and delays
The lines under a key run one after another, a script finishes before the next line starts, and delay(0.5) waits before the rest. Save, wait, reload, in four lines.
Search and capture
searchSelection("url") looks up the selected text on Google or any site, %s in the url stands for it, and the clipboard keeps what it had. screenshot() saves a webp and copies its name, screenshotToClipboard() skips the file, sleepNow() sends the Mac to sleep.
Developer contexts
Chrome, Terminal, Finder, VLC, GIMP, WebStorm, Fleet, Android Studio, the iOS Simulator, the Android Emulator and mc recognised out of the box, any other app by its bundle id. displayActiveWindowInfo() prints 14 facts about the front window, bundle id, process, pid, both title readings, memory and bounds, copy the bundle id from it into currentApp("...").
Mistakes stay small
A line that does not read is named with its line number and the reason, and every other line loads. A key written twice is named too. Convert config to .hk turns a hotkeys.yml from an earlier version into hk and checks it reads the same.
Native and light
Swift and a CGEvent tap, not Electron. The key is handled before the app in front even sees it. No polling loop, no dock icon, nothing to notice until you press a key.
Private
No account, no telemetry, no cloud. Your config never leaves the Mac. A free copy never calls the license server at all, and the update check sends no system profile.
Free to start
50 hotkeys free, no clock. A license unlocks the rest on three Macs, and updates arrive in‑app or through Homebrew.
ExamplesReal lines from real configs. Copy, paste, change the app name.
What people actually put in the file.
Five corners of one config. The full reference lives in the docs.
CHROME = currentAppChrome()
BROWSER = currentApp("com.google.Chrome", "com.apple.Safari")
// Option S opens the page source, only in Chrome!sif CHROME sendKeys("command-option-u")
// the JavaScript console, and the developer tools three menu levels deep!jif CHROME sendKeys("command-option-j")
!dif CHROME openMenuItem("Google Chrome", "View", "Developer", "Developer Tools")
// F5 reloads the page the Windows way, in Chrome and in Safarif5if BROWSER sendKeys("command-r")
// the selected text on Google, from any app^!gsearchSelection("https://www.google.com/search?q=%s")
Keys without a matching context pass straight through, so Option S keeps doing its normal thing in every other app.
TERMINAL = currentAppTerminal()
// Home and End move along the line, the way they do on Linuxhomeif TERMINAL sendKeys("control-a")
endif TERMINAL sendKeys("control-e")
^backspaceif TERMINAL sendKeys("control-w") // the word before the cursor// F2 renames in the Finder, the way it does on Windowsf2ifcurrentAppFinder() sendKeys("return")
// a Terminal in the folder of the Finder window, Terminal itself from anywhere else^!tifcurrentAppFinder() appleScripttell application "Finder"set p to POSIX path of (target of front window as alias)end telltell application "Terminal"activatedo script "cd " & quoted form of pend tellelserunApp("Terminal")
Every key here carries a context, so Home, End and F2 keep their usual job everywhere else.
// apps on Control Option and Control Command, out of the way of the shortcuts the apps bring^!crunApp("Google Chrome")
^!mrunApp("Mail")
^!nrunApp("Notes")
^#orunApp("System Settings")
// Option Shift alone flips English and Spanish, the way Alt Shift does on Windows!*switchKeyboardLayout("com.apple.keylayout.US", "com.apple.keylayout.Spanish")
// which app is this, its bundle id and window title, ready for a context^!#\displayActiveWindowInfo()
Put the apps you use all day on keys and stop hunting the dock.
adb = ~/Library/Android/sdk/platform-tools/adb
EMULATOR = currentWindowAndroidEmulator()
SIMULATOR = currentAppSimulatorIos()
// hardware keys for the emulator, no mouse trip to the side barescif EMULATOR adb -e shell input keyevent KEYCODE_BACK
f1if EMULATOR adb -e shell input keyevent KEYCODE_HOME
// one key for both, the app switcher of the emulator and of the simulatorf3if EMULATOR adb -e shell input keyevent KEYCODE_APP_SWITCH
elseif SIMULATOR sendKeys("control-shift-command-h")
// the mouse too, the right button is back, the middle button homerightclickif EMULATOR adb -e shell input keyevent KEYCODE_BACK
middleclickif EMULATOR adb -e shell input keyevent KEYCODE_HOME
Context by window title needs Screen Recording, macOS hides titles otherwise. Hotkeys asks once, only if your config uses it.
// shell, in your login shell, so ~/bin and the venv are on PATH^!#t ~/bin/timesheet.py start
^!#u pbpaste | ~/bin/shorten.py | pbcopy
// AppleScript on the line, or the script on the lines under itf4appleScriptdisplay dialog "Hello world"f9appleScripttell application "Finder"make new Finder window to folder "Downloads" of homeend tell// the lines under a key run one after another, delay waits before the rest^!ssendKeys("command-s")
delay(0.5)
~/bin/backup-notes.sh
// the file that edits itselff10openConfig()
#f10reloadConfig()
Anything that works in Terminal works here. Same shell, same PATH, same profile.
Free for 50 hotkeys. Cheap for all of them.
The free tier has every feature, a license key lifts the 50 hotkeys limit on up to three Macs. No account, the key arrives by email.
Hotkeys checks for updates about once a day and installs them in place. Homebrew users can also run brew upgrade --cask hotkeys, and brew uninstall --zap --cask hotkeys removes the app and everything it wrote.
It needs the Accessibility permission, that is what lets it see and send keystrokes. Screen Recording is asked only if your config uses window‑title contexts, displayActiveWindowInfo() or a screenshot method.
Hotkeys Lite is the App Store edition. Sandboxed and permission‑free, it fires apps, files and scripts from a key. Sending keystrokes, opening menu items, searching the selection, screenshots, sleep, mouse buttons, forward and modifier‑only hotkeys need the app above, and its first launch carries your Lite config over for you.
QuestionsEverything else is in the docs or one email away.
Why does it need Accessibility?
That permission is what lets an app see keystrokes meant for other apps and send its own. Every hotkey tool asks for it. Screen Recording is asked for separately and only if your config uses a window‑title context, displayActiveWindowInfo() or a screenshot method, because macOS hides other apps' window titles and screen contents without it.
Can one key do a different job in every app?
That is what contexts are for. A context is a built‑in method that answers yes or no about the app in front. Write it after if and the action runs only when the answer is yes, else if asks about the next app and else covers everywhere else. Here F5 reloads the page in Chrome, gets new mail in Mail and brings up Chrome anywhere else.
Hotkeys reads the file when it starts and when you choose Reload in the menu bar. Saving alone changes nothing, there is no file watcher. Give both a key and you never touch the menu again: f10 openConfig() to open it, #f10 reloadConfig() to reload it. A line with a mistake is named in a dialog with its line number, and every other line loads.
How do I find out what an app is called?
Bind displayActiveWindowInfo() to a key and press it while the app is in front. A selectable panel prints 14 facts about the window in front, and three of them, bundle id, process name and window title, are exactly the strings a context takes, as in currentApp("com.google.Chrome"). Twelve of the fourteen need no permission.
Does the app in front still receive the key?
Not once your hotkey ran: when a command started or a context held, the key is consumed and the app never sees it. If the key has contexts and none of them hold, it passes through untouched. Two keywords on the line of the hotkey or right under it change that, forward lets the key through after your action, consume swallows it even when nothing ran.
Does Hotkeys work in password fields?
Yes. While a password field, Terminal's Secure Keyboard Entry or a password manager holds secure input, macOS keeps keystrokes away from every watching app and most hotkey tools go quiet. Hotkeys keeps firing: every combination with command or control, option combos included, plus modifier‑only keys. The menu bar icon turns into a lock and names the app holding it. Switch on the optional password field helper and F‑keys, esc, tab, delete and the arrows work there too. The one exception is a key that types a character pressed without command or control, macOS gives those to nothing but a keyboard driver.
Does it phone home?
No accounts, no analytics, no crash reporters. A free copy never contacts the license server at all. An activated one validates with Polar on activation and about once a week afterwards, sending four fields and no hardware fingerprint, and keeps working for thirty days offline. The update check fetches a 778 byte file and sends no system profile.
Can I move my license to another Mac?
A key activates on three Macs. Open About on the old Mac, click License... and choose Deactivate, and the slot is free again. Lost the email? Your keys and invoices are in the customer portal.
Where is the config file?
~/Library/Application Support/hotkeys/hotkeys.hk. The Edit config menu item opens it, and a commented template is written on first run. A hotkeys.yml from an earlier version keeps loading until you choose Convert config to .hk in the menu.
Is there a trial?
The free tier is the trial: 50 hotkeys with every feature, methods and contexts included, no clock. Buy when you run out of keys.
Something is broken, or I want a refund.
Write to [email protected] or open an issue on GitHub. Most problems are a one‑line config fix and get answered the same day. Refunds go through Polar, just ask.
Nothing less, nothing more.
50 hotkeys free, the rest for the price of a coffee.