Documentation

Everything Hotkeys does is one text file, hotkeys.hk. This page is the whole manual: the file, its syntax, every built‑in method, permissions and troubleshooting.

Install

Download the dmg, drag Hotkeys to Applications, open it. Or with Homebrew:

brew install --cask hotkeys-app/tap/hotkeys

It needs macOS 14 Sonoma or later and shows up as an icon in the menu bar, there is no window and no dock icon.

Updates: Hotkeys checks about once a day and offers them in place (Check for Updates... in the menu), Homebrew users can also run brew upgrade --cask hotkeys. To remove it completely, brew uninstall --zap --cask hotkeys deletes the app and everything it wrote, and unloads the password field helper, which is a root service that would otherwise outlive the app.

Coming from Hotkeys Lite? The first launch copies your config out of the App Store container, there is nothing to move by hand, and the Lite copy is left untouched.

Permissions

Accessibility

Hotkeys asks on first launch. Open System Settings, Privacy and Security, Accessibility, and switch Hotkeys on. If Hotkeys is not in the list, press the plus button and pick /Applications/Hotkeys.app. The engine starts the moment the switch flips, no restart needed.

Screen Recording, for window titles and screenshots

macOS hides other apps' window titles and blocks screen captures unless Screen Recording is granted. Hotkeys asks for it after loading your config, and only when the config uses a window‑title context (currentWindowContains, currentWindowAndroidEmulator, currentWindowMc), displayActiveWindowInfo() or a screenshot method. Once you grant it Hotkeys relaunches itself, macOS applies that permission only to a freshly started process. If Hotkeys never appears in the Screen Recording list, add it with the plus button.

Automation

An AppleScript that tells another app what to do triggers the standard macOS consent dialog for that app, once. Nothing to configure.

Password fields

A focused password field, Terminal's Secure Keyboard Entry or a password manager switches on secure input, and macOS then keeps your keystrokes away from every other app. Most hotkey tools go silent there. Hotkeys keeps working: the menu bar icon turns into a lock, the menu names the app that holds it, and your hotkeys go on firing.

What keeps working while secure input is on:

  • Every hotkey with command or control, through the system hotkey registry. Option rides along, so !#f12 and !^k fire as usual. The key is still consumed, so #q in a password field runs your action and does not also quit the app.
  • Modifier‑only hotkeys such as !*.
  • F‑keys, esc, tab, delete, arrows, home, end, page up and down, alone or with option and shift, once the helper below is on. !f12 included.

The one exception is a key that types a character pressed without command or control, so h, !h, *h and 1. macOS hands those to nothing but a keyboard driver.

The password field helper

Optional, off until you ask for it. Menu bar, Settings..., Enable Password Field Hotkeys.... Setup walks through two approvals in System Settings, first Login Items, then Input Monitoring. Hotkeys also offers it once, after secure input got in your way.

It is a small background service that runs as root and watches the keyboards. It knows only the hotkeys in your config and reports nothing else: a letter, digit or punctuation key counts only together with command or control, checked in the app and again in the helper. It stores nothing, and it answers only Hotkeys, signed by the same developer certificate.

It cannot swallow a key, so a key it reports also reaches the password field. F‑keys and esc do nothing there, an arrow moves the caret, delete and tab do what they always do in a field, so pick those two with care. Remove it any time from the same Settings button, command and control hotkeys keep working without it.

The config file

~/Library/Application Support/hotkeys/hotkeys.hk, a plain text file. A commented template is written there on first run, and Edit config in the menu opens it.

Hotkeys Lite from the App Store reads hotkeys.hk with its 20261002 update, which is waiting for App Store review. Until it arrives Lite reads hotkeys.yml, the yml examples are in examples/yml on GitHub.

A hotkey is its name and what it does, one per line:

// Option S opens the page source, only in Chrome
!s if currentAppChrome() sendKeys("command-option-u")

^!c runApp("Google Chrome")             // Chrome comes up from any app
^!#t ~/bin/timesheet.py start           // a shell command, in your login shell
f10 openConfig()                        // this file
#f10 reloadConfig()                     // and its reload

Edit it with anything. Hotkeys reads the file when it starts and whenever you choose Reload in the menu, saving alone changes nothing. A line with a mistake is skipped and named in a dialog with its line number, every other line loads, see problems.

The free tier loads the first 50 hotkeys in the file. Everything below the 50th waits for a license.

From hotkeys.yml

Versions before 20261001 read hotkeys.yml from the same folder, and it still loads: Hotkeys reads hotkeys.hk when there is one, otherwise hotkeys.yml. With both in the folder hotkeys.yml is not read, and the dialog after each load says so.

While hotkeys.yml is in use the menu shows Convert config to .hk. It writes your hotkeys, macros and comments as hotkeys.hk, reads the result back and compares it with the yml, hotkey by hotkey. Only when both read the same does it rename hotkeys.yml to hotkeys.yml.bak, load the new file and open it. When they differ it writes nothing, and a dialog names the hotkeys that differ with their lines. Names lose their quotes and underscores, # comments become //, the macros move to the top as NAME = value lines.

It also writes nothing when hotkeys.hk or hotkeys.yml.bak is already in the folder, or while the load dialog names problems in hotkeys.yml, fix those lines first. A hotkeys.yml that links into your dotfiles stays a link: the .hk file is written next to the file it points to, and hotkeys.hk links to that. To go back, delete hotkeys.hk and rename hotkeys.yml.bak to hotkeys.yml.

  • Edit config opens hotkeys.hk in the app you chose for .hk files (Finder, Get Info, Open with, Change All), else in the app that opens .yml files, else in TextEdit. Edit config (TextEdit) opens it in TextEdit.
  • Reload re‑reads the file and re‑registers every hotkey.
  • Convert config to .hk shows while hotkeys.yml is in use, see from hotkeys.yml.
  • Disable pauses everything, handy for games and remote desktops. The item then reads Enable.
  • While secure input is on, a line at the top names the app holding it, see password fields.
  • Check for Updates...
  • About holds the license: Activate License... is its second button and the panel shows your level.
  • Settings... and Quit.

Hotkey names

A hotkey name is its modifiers, then one key or mouse button. A modifier is one symbol, or a word with a dash after it if you prefer reading:

SymbolWordsKey
^ctrl-, control-Control
!alt-, option-Option
*shift-, shft-Shift
#cmd-, command-, start-Command

^!f1, control-option-f1 and ctrl-alt-F1 are the same hotkey, writing it twice is reported. Names ignore case, and the modifiers go before the key in any order: !^f1 works, f1^! is reported.

# is Command

In hotkeys.hk # is the Command modifier and nothing else, a comment starts with //. So #k, *f1 and !* go in as they are, with no quotes and no escaping:

#k runApp("Telegram")                   // Command K
*f1 helloWorld()                        // Shift F1
cmd-shift-j runApp("Mail")              // Command Shift J, the words read the same
!* switchKeyboardLayout("com.apple.keylayout.US", "com.apple.keylayout.Spanish")
^!#l displayAvailableKeyboardInputSources()

Modifier‑only hotkeys

!* above has no key at all, it fires when Option and Shift are pressed together and released, the way Alt and Shift switch the layout on Windows. Any two or more modifiers work alone, ^# or ^!# as well. It fires only when nothing else was pressed in between, so Option Shift K still types what it always did. Not in Lite.

Mouse buttons

rightclick, middleclick, mouse4 and mouse5 are hotkey names like any key, with modifiers and contexts. The left button is never a hotkey. A click the hotkey takes never reaches the app, release included, and the action runs on the release, like a click. Where no context holds, the click goes to the app as usual. Not in Lite.

// the side buttons of the mouse switch tabs in Chrome, every other app keeps them
mouse4 if currentAppChrome() sendKeys("control-shift-tab")
mouse5 if currentAppChrome() sendKeys("control-tab")
^middleclick displayActiveWindowInfo()

The full list of key names is in the reference below.

Actions

What follows the name is what happens: on the same line, or on the lines under the name, indented. The lines under a key run one after another.

// on the line of the key
^!c runApp("Google Chrome")
^!#t ~/bin/timesheet.py start

// on the lines under the key, one after another
#f10
    sendKeys("command-s")
    delay(0.3)
    reloadConfig()

// several method calls on one line, ; between them
^!v sendKeys("command-c"); appleScript("beep")

The rule for a line: method calls like runApp("Terminal") run as built‑in methods, any other text is a shell command for your login shell. Arguments go in double quotes, with \" and \\ inside, numbers go bare, delay(0.3). A method without arguments is written name(), and several calls on one line need a ; between them.

A line can also start with a keyword: if, else if and else for contexts, appleScript and shell for scripts, forward and consume for the key itself. Indent with spaces or with tabs, not both, any depth works as long as the lines of one level line up.

One after another

The statements of a press run in order. A shell command or an AppleScript finishes before the next line starts, end a shell line with & to go on right away. delay(0.5) pauses everything after it in the press, in seconds. Shell lines next to each other are one script, so cd and variables carry over to the next line:

// save in the app, wait half a second, then commit the notes folder
^!s
    sendKeys("command-s")
    delay(0.5)
    cd ~/notes
    git commit -qam backup

Contexts

A context is a built‑in method that answers yes or no about the app or window in front. Write it after if and the action runs only when the answer is yes. Everywhere else the key goes to the app untouched:

CHROME = currentAppChrome()

// Option S opens the page source, only in Chrome
!s if CHROME sendKeys("command-option-u")

One key can do a different job in every app. else if asks the next context when the one above did not hold, else runs when none held, and only the first branch that holds runs. A chain fits on one line or takes a line per branch, and an if with nothing after its context takes the lines under it:

CHROME = currentAppChrome()
TERMINAL = currentAppTerminal()
MAIL = currentApp("com.apple.mail")

// a chain on one line, a new window in Chrome, Chrome itself anywhere else
^!c if CHROME sendKeys("command-n") else runApp("Google Chrome")

// one branch per line, the first that holds runs
f5
    if CHROME sendKeys("command-r")                // reload the page
    else if MAIL sendKeys("command-shift-n")       // get new mail
    else if TERMINAL sendKeys("command-k")         // clear the screen
    else runApp("Google Chrome")

// an if with lines under it, the page of Chrome opens in Safari
^!o
    if CHROME
        sendKeys("command-l; command-c")
        delay(0.2)
        open -a Safari "$(pbpaste)"
    else runApp("Safari")

One context per if: currentApp("com.google.Chrome", "com.apple.Safari") takes several apps, the next app goes in an else if. No parentheses around the context and no ! in front of it, put the action under else instead. Two if lines one under the other are two statements, each runs when its own context holds.

ContextTrue when
currentApp("com.google.Chrome")the app in front has this bundle id, more ids in the same call match any of them
currentAppChrome(), currentAppTerminal(), currentAppFinder()Google Chrome, Terminal, the Finder
currentAppVlc(), currentAppGimp()VLC, GIMP in any version
currentAppAndroidStudio(), currentAppWebStorm(), currentAppFleet()Android Studio, WebStorm, JetBrains Fleet
currentAppXcode(), currentAppSimulatorIos()Xcode, the iOS Simulator
currentProcessEquals("name")the process in front has this name
currentWindowContains("text")the title of the window in front contains the text, needs Screen Recording
currentWindowAndroidEmulator()an Android Emulator window is in front, needs Screen Recording
currentWindowMc()a Midnight Commander tab is in front, needs Screen Recording

Not sure what an app is called? Bind displayActiveWindowInfo() to a key and press it in the app. A selectable panel prints 14 facts about the window in front, bundle id, process name, pid, both readings of the title, layer, alpha, resident memory and exact bounds. Copy the bundle id into currentApp("..."), the process name into currentProcessEquals("..."). Twelve of the fourteen need no permission.

forward and consume

Once a command started or a context held, the press is done and the app in front never sees the key. When no if held and nothing ran, the key goes through untouched. Two keywords change that, on the line of the hotkey or on a line right under it, one of them per hotkey:

  • forward runs your action and still lets the key through.
  • consume never lets the key through, also when no if held and nothing ran.

Hotkeys Lite cannot pass a key on, forward needs Hotkeys.

// run the action and still let the key through, the app saves as usual
#s
    forward
    ~/bin/backup-notes.sh

// Command Q never quits an app by accident
#q consume

Macros

A macro names text you would otherwise repeat: NAME = value at the start of a line, anywhere in the file, and it works in the whole file. The name is replaced as a whole word in actions, contexts, scripts and other macros, never in hotkey names.

CHROME = currentAppChrome()
TERMINAL = currentAppTerminal()
python = /opt/homebrew/bin/python3
adb = ~/Library/Android/sdk/platform-tools/adb

!s if CHROME sendKeys("command-option-u")
home if TERMINAL sendKeys("control-a")
^!2 python ~/bin/open-task.py
f2 if currentWindowAndroidEmulator() adb -e shell input keyevent KEYCODE_APP_SWITCH

The convention: a macro for a context in capitals, named after what it tests, CHROME, TERMINAL, and a macro for a command in lower case, named after the command it replaces, python, adb. A macro may use other macros. Keywords, method and context names, true, false and key names in lower case like esc or home are taken, ESC works, macros are case sensitive. A macro defined twice is reported, the first one works.

A value of several lines goes on the lines under NAME =:

save_all =
    sendKeys("option-command-s")
    delay(0.3)

#f10
    save_all
    reloadConfig()

Shell commands

Text that is not a method call goes to your login shell, $SHELL -l -c, which reads .zprofile and .zshenv, so the PATH, Homebrew, pyenv and virtualenvs they set up are all there. .zshrc is not read, an alias or function defined only there is missing. Chain with && or ;, use ~, call scripts in ~/bin by name.

^!#t ~/bin/timesheet.py start
^!m pbpaste | ~/bin/shorten.py | pbcopy
^!d open ~/Downloads
^!g open -a "Google Chrome" https://mail.google.com

Shell text that hk would read otherwise, a shell if, the word else or // after a space, goes on the lines under the keyword shell. There every line is taken as written, never read as method calls, and only a line that starts with // is a comment. Short text with no // in it can follow shell on its line.

// a shell if after shell, on the line
^!l shell if [ -d ~/logs ]; then open ~/logs; fi

// or a script on the lines under shell
^!b shell
    cd ~/projects/site
    if git pull -q; then ./deploy.sh; fi

AppleScript

The keyword appleScript takes the rest of its line, or with nothing after it the lines under it, as they are written. The script finishes before the next statement starts.

// one line after appleScript
f4 appleScript display dialog "Hello"

// the script on the lines under appleScript, as written
f9 appleScript
    tell application "Finder"
        make new Finder window to folder "Downloads" of home
    end tell

// after a context, only while the Finder is in front
^!t if currentAppFinder() appleScript
    tell application "Finder"
        set p to POSIX path of (target of front window as alias)
    end tell
    tell application "Terminal"
        activate
        do script "cd " & quoted form of p
    end tell

// one line of AppleScript among method calls
f3 sendKeys("command-c"); appleScript("beep")

Scripts that use System Events to press keys or click buttons rely on the Accessibility permission Hotkeys already has, nothing extra to grant.

Comments

A comment starts with // at the start of a line or after a space, and runs to the end of the line. // right after other text, as in https://, and // inside quotes are no comment.

// Chrome
!s if CHROME sendKeys("command-option-u")   // a comment at the end of a line
^!g open https://www.google.com             // the // of https:// is no comment
// !j if CHROME sendKeys("command-option-j")   a hotkey switched off

f9 appleScript
    // a comment of the config, not part of the script
    tell application "Finder" to activate -- an AppleScript comment, it stays

// a macro works in the whole file, above its line too
CHROME = currentAppChrome()

# is the Command modifier, never a comment. A line like # todo is reported with the fix, comments start with //. To switch a hotkey off, put // in front of its line and of each line under it. Inside a script a line that starts with // is a comment of the config, while -- in AppleScript and # in a shell script stay part of the script.

Problems

A line that does not read is skipped together with the lines under it, and every other line loads, so a typo never takes the rest of your keys with it. After each load a dialog lists the hotkeys written twice, then the skipped lines, each with its line number and the reason, and last the lines that load but were probably meant differently. The usual mistakes come from other formats:

WrittenReported
#k: runApp("Telegram")no colon after the hotkey name in hk
"#k" runApp("Telegram")no quotes around hotkey names in hk, write #k
# todo# is the Command modifier, comments start with //
k# runApp("Telegram")modifiers go before the key, write #k
f5 if (currentAppChrome()) ...no parentheses around the condition, write if currentAppChrome()
f6 runApp(Telegram)runApp(Telegram) does not read as a method call, arguments go in double quotes or are numbers
f7 sendKeys("a") sendKeys("b")put ; between method calls

A hotkey written twice, say as cmd-k and #k, is named with both lines, and the first one works.

Built‑in methods

Every method works without a license, the free tier only limits how many hotkeys load. Hotkeys Lite from the App Store cannot drive other apps, the methods it lacks say so.

MethodWhat it does
runApp("Safari")launches the app or brings it to the front, with or without .app
runProcess("command", "name")run or raise: brings a window of the app whose name contains the second argument to the front, runs the shell command when there is none
openConfig()opens the config file in your editor
reloadConfig()re‑reads the config, same as Reload in the menu
helloWorld()shows Hello, World!, to check the setup works
displayActiveWindowInfo()a selectable panel with 14 facts about the front window, bundle id, process name, pid, both title readings, layer, alpha, memory and bounds
sendKeys("control-comma")sends a key combo to the app in front, named like hotkeys. Several in a row with a semicolon, "command-c; command-v". Not in Lite.
openMenuItem("App", "Menu", "Item")clicks a menu item by its names as the menu bar shows them, a fourth name clicks an item of a submenu. Not in Lite.
searchSelection("url")searches the selected text on a site, %s in the url stands for it, and the clipboard keeps what it had. With nothing selected it searches the text on the clipboard. Not in Lite.
screenshot(), screenshot("~/screens")drag an area or press space for a window, saves screen_yyyymmddhhmmss.webp and copies its name to the clipboard. Without a folder it goes where macOS saves screenshots, the Desktop unless changed under Command Shift 5, Options. A missing folder is created. A MacBook has no printscreen key, bind something like #f12. Not in Lite.
screenshotToClipboard()the same selection, the image goes to the clipboard, no file. Not in Lite.
switchKeyboardLayout("id1", "id2")toggles between two input sources, ids like com.apple.keylayout.US and com.apple.keylayout.Spanish
displayAvailableKeyboardInputSources()lists the ids of every enabled input source
killProcessWithWindowNameContains("text")kills the first process whose command line contains the text, so give it a long, specific string
appleScript("beep"), runAppleScript("beep")one line of AppleScript where a method call goes, among calls joined by ;. The keyword appleScript is the usual form
delay(0.5)pauses the rest of the press for the seconds, the lines after it and the statements after every if around it run later, their contexts are asked then
sleepNow()puts the Mac to sleep, or says so when pmset disablesleep does not let it. Not in Lite.
xcodeSetBookmark(1), xcodeGotoBookmark(1)numbered bookmarks in Xcode, the first keeps the file and line of the cursor under the name, the second opens it again. Not in Lite.
androidEmulatorBack(), androidEmulatorHome()adb key events, expects adb at ~/Android/android-sdk-macosx/platform-tools/adb. With adb elsewhere use a macro and the shell form shown under macros
currentApp(...) and the other contextslisted under contexts

A few of them in a config:

CHROME = currentAppChrome()

// the selected text on Google
^!g searchSelection("https://www.google.com/search?q=%s")

// the developer tools, three menu levels deep
!d if CHROME openMenuItem("Google Chrome", "View", "Developer", "Developer Tools")

// drag an area or press space for a window, to a webp file or to the clipboard
#f12 screenshot("~/screens")
^#f12 screenshotToClipboard()

// sleep, and the Simulator raised or started
^#esc sleepNow()
^!i runProcess("open -a Simulator", "Simulator")

Key names

GroupNames
Letters and digitsa to z, 0 to 9
Function keysf1 to f20
Arrowsleft, right, up, down, also leftarrow and friends
Navigationhome, end, pageup, pagedown
Editingspace, tab, return or enter, escape or esc, backspace for the delete key, delete, del or forwarddelete for forward delete, insert or help, printscreen which is f13
Punctuationsection, tilde or grave, minus, equal, leftbracket, rightbracket, semicolon, quote, comma, period or dot, slash, backslash, or the character itself, ^!#\. Alone at the start of a line - and = are written minus and equal
Numeric keypadnumpad0 to numpad9, numpaddecimal, numpadplus, numpadminus, numpadmultiply, numpaddivide, numpadequals, numpadclear, numpadenter
Mouse buttonsrightclick, middleclick, mouse4, mouse5

The free tier

Without a license Hotkeys loads the first 50 hotkeys in the file, with every action, method and context. A license loads the rest. There is no time limit.

License

Buy a key on the pricing page, it arrives by email and starts with HOTKEYS-. Open About from the menu, click Activate License..., paste, done, the panel now shows your level and every hotkey in the file is live.

  • One key activates three Macs. To free a slot, click License... in the same About panel, then Deactivate.
  • The key is validated on activation and about once a week after that. No connection for thirty days, the license keeps working the whole time.
  • Invoices, renewals, cancelling the yearly plan and the key itself live in the customer portal.

Troubleshooting

Nothing fires

Check the menu for Disable, if it reads Enable everything is paused. Confirm Hotkeys is switched on under Accessibility, and if you moved or reinstalled the app, switch it off and on once, macOS ties the grant to the app's path. A lock in the menu bar instead of the keyboard icon means secure input, not a broken install, see password fields.

One key never fires

macOS probably owns it. ^left, ^right, ^up belong to Mission Control, ^space to input sources, #space to Spotlight, change or disable those under System Settings, Keyboard, Keyboard Shortcuts. On a MacBook the top row sends brightness and volume, not F1 to F12, so an F‑key hotkey sees nothing. Hold fn while pressing it, the hotkey stays #f12 without fn in the name, or enable "Use F1, F2, etc. keys as standard function keys" under Keyboard Shortcuts, Function Keys. Letter and digit hotkeys never have this problem.

A context never matches

Bind displayActiveWindowInfo() and read the real bundle id, it is rarely the app's display name. Window‑title contexts need Screen Recording, see permissions.

The command works in Terminal but not from Hotkeys

Your login shell is used, so PATH comes from .zprofile and .zshenv, not from .zshrc or anything Terminal set by hand. Put the PATH lines and aliases your hotkeys need in .zprofile.

A line is reported

The dialog names the line, the reason and often the fix, see problems. The usual causes are habits of other formats: a colon after the name, quotes around it, # for a comment, method calls with no ; between them. Every other line keeps working while you fix it.

Edits to hotkeys.yml change nothing

With hotkeys.hk in the folder hotkeys.yml is not read, the dialog after each load says so. Edit hotkeys.hk, or see from hotkeys.yml to go back.

Still stuck

Write to [email protected] with the lines from your config and the output of displayActiveWindowInfo(), or open an issue on GitHub.