Skip to Content

Somehow AutoHotKey is kinda good now

I love Autohotkey so much that it keeps me on Windows. It’s the best GUI automation tool out there. Here’s a shortcut that opens my current browser tab in the Wayback Machine:

#HotIf WinActive("ahk_exe firefox.exe")

By comparison, the official extension takes four files to do the same thing. Four files!1

But I come here to bury AHK, not to praise it. In January AutoHotKey v2 got released and it’s wildly backwards incompatible with v1. I had to change maybe 20% of my code to get it working again. And I got off easy, I don’t have any third party scripts!

The trauma of Python replacing print x with print(x) is legendary. Couldn’t they have updated AHK without breaking everything?


You see, AHK v1 was very, very bad.

Why AHK was bad

Implicit Strings

So imagine you just got started with AHK and want to show a message box. You boot up v1 and write

MsgBox Hello world!

In v1 you don’t have to surround strings with quotes. It means less syntax— easier for normies— up until the point you want to include a variable. In most languages you’d write something like "Hello " . name but AHK will take that a little too literally:

Fortunately you can still %interpolate% the variable:

name = Jeff
MsgBox Hello %name%!

Now what if you want to show the name in ALLCAPS? Easy, just do this:

name = Jeff
tmp := StringUpper(name)
MsgBox Hello %tmp%!

Surprise! The hacked on interpolation syntax isn’t real interpolation and anything besides a single variable name is a syntax error. This implicit-stringness plagued all the commands, many of which took variables and strings. In WinWait y, y, y the first two y’s are the string “y” while the last y is the value of the variable y.

v2 got rid of implicit strings, so now you instead do this:

name := "Jeff"
MsgBox(Format("Hello {1}!", StrUpper(name)))

I removed six different tmp hacks.

Inconsistent function/command syntax

My sample above isn’t actually valid AHKv1. It calls StringUpper like a function, but it’s actually a command. Since it’s a command, you instead write:

name = Jeff
StringUpper, name, tmp
MsgBox Hello %tmp%!

Yes, StringUpper is implicitly defining a new variable, yes you have to assign the output if you want to do anything with it, yes v1 had regular functions too and the whole distinction is pointless.2 It’s just that functions were added circa 2005, so all of the existing commands stayed commands for backwards compatibility.

In v2 everything’s just a function. tmp := StrUpper(name) works fine. There’s still functions like MouseGetPos that assign output to its arguments but at least you now have to explicitly pass in references, like MouseGetPos &xpos, &ypos.

Other command/function problems

How do you check if x is equal to 10? IfEquals x, 10. How do you check if a keypress k isn’t in “aeiou”? IfNotInString k, aeiou.3

There were nineteen special if commands.

v1 got a generalized function if in 2009, but beginners would regularly use the command ifs instead of the function if. Thirteen years later v2 disarmed this footgun once and for all.

Error level

v1 had a global value called ErrorLevel, which roughly mimics return codes from shell programs. If you tried calling SoundPlay but it couldn’t play the sound, AHK would set ErrorLevel to 1.

Now there’s an obvious problem with this, which is that it was global state changed by everything. You could never be 100% sure that the ErrorLevel you read came from the command you expected it. v1 did keep a separate ErrorLevel for each thread so at least it was mostly thread safe. Mostly but not completely: thread interrupts would truncate ErrorLevel strings longer than 127 characters.

You may be asking why ErrorLevel could even be a string, and it’s because v1 was terrible. While SoundPlay would only set 0 or 1, SoundGet would set ErrorLevel to

  • “Invalid Control Type or Component Type”
  • “Can’t Open Specified Mixer”
  • “Mixer Doesn’t Support This Component Type”
  • [etc]

In fact you can stuff whatever arbitrary thing you want in ErrorLevel, and at some point v1 abandoned the pretence that it was for “errors”. For example, calling StringReplace would set the ErrorLevel to the number of substrings it replaced.

v2 just uses try/catch blocks.

The goddamn object hash tables

This one has a special place in hell. v1 didn’t have proper hash tables; you instead defined a new object where the fields were your “keys”. In AHK, then and now, all identifiers are case insensitive. So the “hash table” {a: 1, A: 2} is equivalent to {a: 2}.

While small in comparison to the other problems, this one had the extra twist of being completely undocumented. You were just supposed to know that because commands weren’t case sensitive, hash keys weren’t either. I wasted so much time debugging this.

v2 still doesn’t have case sensitive identifiers but it does have a dedicated Map class which behaves as it should.

Miscellaneous terriblenesses

  • Most errors would create anomalous behavior: invalid array lookups returned empty values, as would referencing missing variables.
  • Instead of taking function callbacks, commands like SetTimer and OnClick took labels. Then they’d invoke a goto. This was exactly as bad as you think.
  • Just kidding, it was worse. goto could take dynamically computed labels and it could jump from inside a function to outside.
  • Multiline hotkeys had to end with a return statement, while single-line ones didn’t. Not a deal-breaker, just annoying.
  • v1 ignored any script setup or global assignments after the first defined hotkey, even if that hotkey was in an included file.

AHKv2 is worth it

So with all those issues, you’d probably wonder why anybody would bother. The language was terrible, now it’s passable. But passable is a very low bar, and there’s plenty of good languages out there already.

The thing is, even when it was a terrible language, AHK was still worth using. Nothing else filled or even cared about its niche: modifying the behavior of other programs. Normally, if I want to extend a program, I need to use its plugin system. That means learning:

  1. The provided APIs
  2. The capabilities, limits, and idiosyncrasies of the system
  3. The entire development environment needed to write plugins in whatever language
  4. How to actually deploy the plugin, which always takes way more time than I’d hope.

And that’s if there’s a plugin system in the first place; there is no way to extend the desktop Spotify app short of writing a new client from scratch.

That’s why AutoHotKey is so useful. It can target windows, find pictures, and send input, which is enough to jury rig my own “plugins” regardless of what the program actually supports. v1 may have sucked but the alternative was not being able to do that. And now the language is actually okay! Life is great!

v2 in action

I run formal methods workshops. FM tools have unfamiliar syntax and semantics to programmers, and many have terrible error messages. To keep classes from getting bogged down in people having syntax errors, I go through a list of “checkpoints” for each spec. We start with a blank file and modify it to checkpoint 1, then modify it to checkpoint 2, etc. If someone’s spec won’t compile, they can just run a diff to see their mistake.

I like this approach but had two problems I needed to fix. First, people need to know which checkpoint we’re on, and they must be able to find out without disrupting the class. Second, if I don’t follow the checkpoints exactly, the diffs won’t show people what they got wrong.

I solved both of these with AHK. The script shows the current checkpoint on my shared screen, the checkpoint content on my private screen, and switches to the next checkpoint when I press 6 on the numpad. Here’s what it looks like:

And here’s the whole program:

Show code
#SingleInstance Force

Controller := Gui("AlwaysOnTop Resize","Workshop Spec Controller")
Display := Gui("ToolWindow AlwaysOnTop +Owner" Controller.Hwnd,"Workshop Spec",)
Display.MarginY := 5
Display.Add("Text", "section", "Current Spec:")
SpecNum := Display.Add("Text", "W230 vSpecNum", "")

SpecNameCtrl := Controller.AddComboBox("vName", ["example1", "example2", "etc"])
                Controller.AddEdit("vSpecNumber yp")
SpecNumberCtrl := Controller.AddUpDown("Range1-20", 1)

cSpecText := Controller.AddEdit("r40 vSpecText xm w300 -WantReturn")
cSpecText.SetFont("s12", "consolas")
UpdateSpecBtn := Controller.Add("Button", "Default w0", "")

SpecNumberCtrl.OnEvent("Change", UpdateSpecTracker)
UpdateSpecBtn.OnEvent("Click", UpdateSpecTracker)
Display.OnEvent("Close", CloseAll)
Controller.OnEvent("Close", CloseAll)

Display.Show("W230 x60 y1200")
Controller.Show("x2700 y500")

set_text(text, num) {
    specname := Format("{1}__{2:02}.tla", text, num)
    SpecNum.Text := specname
    file := "path\to\specs"  text  "\" specname
    if FileExist(file) {
        spec := FileRead(file)
        cSpecText.Value := spec
    } else {
        cSpecText.Value := file

UpdateSpecTracker(ctrl, info) {
    set_text(SpecNameCtrl.text, SpecNumberCtrl.value)

CloseAll(*) {

NumpadRight::ControlSend "{up}", "Edit2", "Workshop Spec Controller"
NumpadLeft::ControlSend "{down}", "Edit2", "Workshop Spec Controller"

Two GUIs, communicating state, two global hotkeys, less than 50 lines of code. AHK is wonderful.

If you’re on Windows you can download AHKv2 here or via winget install AutoHotkey.AutoHotkey. Mac users may be able to get similar benefits with a mix of Hammerspoon and Shortcuts (which I’m very jealous of). Feel free to email me if you have questions about AHK— I love helping other devs get started.

Thanks to Predrag Gruevski for feedback. If you liked this, come join my newsletter! I write essays once a week.

  1. I mean it’s doing a lot of other stuff too, but it’s stuff I don’t need. My five-minute script solves my needs just fine. [return]
  2. No, you can’t use normal functions in a variable interpolation, either. [return]
  3. And of course, if you wanted to check that the string "k" wasn’t in the variable aeiou, it’d be tmp = k, ifNotInString tmp, %aeiou%. [return]