MacOS 27 broke my old VICREO setup. I moved 140 Stream Deck buttons with AI to Hammerspoon.
Before macOS 28 makes the decision for you.
What This Post Is About
The trigger: macOS 27 came out yesterday. The free VICREO Listener I have been running for years is an Intel-only build, and on macOS 27 it no longer starts until you install Rosetta 2. Apple has also announced that macOS 27 is the last release with full Rosetta support. With macOS 28 next year, that old build is simply done.
The problem: VICREO is behind a lot of my Stream Deck buttons. 140 actions on 119 buttons across three Bitfocus Companion instances, most of them on a Stream Deck XL in my studio. Rebuilding them by hand in the Companion UI would have been an evening of clicking and a week of finding the ones I got wrong.
The solution: A small HTTP bridge inside Hammerspoon, called by Companion's built-in http: GET action. Claude Code took a Companion JSON export, replaced every VICREO action with the matching HTTP call, and I imported the result once.
For the record: I did not write a single line of code for this. Not the Lua, not the Python, not the test commands. Claude Code running Fable 5.1 did the whole job without issues. I made the decisions, typed a password and clicked the dialogs.
If you run Companion on a Mac with VICREO, you have about a year. This post shows the route I took and the parts that bit me.
What VICREO Does
VICREO comes in two pieces. VICREO Hotkey is a Companion module. VICREO Listener is a small app on the Mac that waits for commands from that module on a TCP port and then does things: press a key, type a string, send a key combination to a specific app, run a shell command.
That is exactly what makes it popular. A Stream Deck button can open Keynote, jump to slide 3, launch ATEM Software Control, or open an SSH session in Terminal. My buttons did all of that, plus a whole Calculator page, volume keys and a set of "open app, then snap the window with Magnet" combos.
Why Hammerspoon Is the Better Option for Me
Updating VICREO would have been the obvious move. Current versions are universal builds and run natively on Apple silicon. I looked at it and decided against it anyway.
The free tier gates features behind a Pro licence, and "send a string" was one of the things I could never get a straight answer on. The old Listener also had an action-ordering problem on my setup: type yes, press Enter, and every now and then Enter arrived first. And a listener whose main feature is "run whatever shell command arrives on this port" is not something I want running on the machine that drives my production switcher.
Hammerspoon is free and open source, and it has been actively maintained for more than ten years. You write a few lines of Lua in ~/.hammerspoon/init.lua, and it talks to macOS for you: key presses, windows, audio devices, launching apps, and a built-in HTTP server. Install it with brew install --cask hammerspoon or from the releases on its website, start it once, allow Accessibility in System Settings. No licence tiers.
The difference that matters most to me: in VICREO, the button decides what runs on my Mac. In Hammerspoon, the Mac decides. The bridge only knows a fixed list of named actions. A button can ask for app_keynote or keynote_next, and if the name is not in the table, nothing happens. No command text from the network ever reaches a shell.
It also fixed the ordering bug for free. One HTTP call per button, one Lua function per action, and that function types yes and then presses Enter. In that order. Always.
The Bridge in Two Minutes
Companion cannot talk to Hammerspoon directly. What connects the two is hs.httpserver, a module that ships with Hammerspoon itself. No plugin, no Spoon, nothing to download from a third party. One call to hs.httpserver.new() gives you a small web server running inside the Hammerspoon app, and every request that arrives is handed to a Lua function you write. Hammerspoon's own documentation warns that running an HTTP server is potentially dangerous, which is why mine only listens on 127.0.0.1 and checks a token.
On the Companion side nothing new is needed either. Its built-in Generic HTTP connection sends a request like this:
http://127.0.0.1:8001/hammerspoon/keynote_next?token=<secret>The Lua function checks the token, looks up keynote_next in its table, answers Companion right away and then sends the right arrow key to Keynote.
The full script, ready to paste into ~/.hammerspoon/init.lua, is on its own page: Hammerspoon Bridge for Companion. It has the token check, the route table, the queue, the per-app key presses and the sleep/wake handling, with a short explanation of each part. Answering first matters. Companion's HTTP client retries when a response takes too long, and before I changed that, one button press ran the action three times.
The action types map over from VICREO without much drama:
| VICREO action | Hammerspoon |
|---|---|
open -a 'Keynote' (shell) | hs.application.launchOrFocusByBundleID |
open -a 'Google Chrome' https://... | hs.urlevent.openURLWithBundle |
| send keypress to process | hs.eventtap.keyStroke(mods, key, delay, app) |
| single key, combination, trio | hs.eventtap.keyStroke into the focused app |
osascript -e 'set volume ...' | hs.audiodevice |
open Terminal with ssh | AppleScript run through hs.task |
That third row was the one I cared about. VICREO's "send keypress to process" delivers a key to one specific app, whether it has focus or not. Hammerspoon does the same when you pass the app as the last argument of keyStroke. My Google Slides keys still go to Chrome, and the Calculator page still types into Calculator, even when another window is in front.
One Token, Two Variables, Every Button
This is the part I would do first if I started over.
Companion has custom variables, and the HTTP module resolves them in the URL field. So every single button in my setup uses the same URL:
$(custom:bridge_url)/<route>?token=$(custom:bridge_token)Each Companion instance gets two variables. bridge_url holds the address of the Mac that button should talk to. bridge_token holds that Mac's secret. Set the startup value, not just the current one, or it is gone after the next restart.
That buys three things.
Pages become portable. Nothing machine-specific lives inside a button any more, so I can copy a page from one Companion instance to another and it works. Before this, my MacBook had a page copied from the Mac mini with fifteen buttons still pointing at the Mac mini's port and token. They had done nothing for weeks, and I had not noticed.
Changing the token touches two places: the variable and init.lua. Not 140 buttons.
And if the token ever leaks, I rotate it in two minutes. That is not hypothetical. While debugging a failed request, the secret once ended up in a Claude Code transcript. Same day, new secret, one variable edited, one line in Lua changed, Hammerspoon reloaded. If it had been baked into every button URL, I would probably have told myself it was fine. That kind of "probably fine" ages badly.
One rule I learned the hard way: use letters and digits only. Companion percent-encodes a variable when it inserts it into a URL. My first secret contained &, + and #, arrived on the Mac as a longer string full of %26, and every press was rejected while the decoded value matched perfectly. openssl rand -hex 24 gives you 48 characters that survive the trip.
Letting Claude Code Rewrite the JSON Export
Companion 5 has no API for editing actions. The HTTP remote control can press buttons and set variables, and that is it. Editing Companion's SQLite database while it runs is a good way to end up with a corrupted config.
What Companion does have is a full export: Settings, Import / Export, Export. The file ends in .companionconfig, but you can choose the format, and JSON is one of the options. Pick it. JSON is exactly what an LLM reads and writes best, and it lets a script prove what changed. The export contains everything: pages, buttons, actions, connections, custom variables, triggers. Mine was 16 MB.
So the plan was simple. Export, have Claude Code rewrite the JSON, import once, check with a fresh export.
Back up before you start. Keep that first export untouched in a safe place, and never let the script write over it. It is your way back: if the import goes wrong, you import the original again and you are exactly where you started. Companion also keeps its own daily database backups, but a full export you made yourself, five minutes before the change, is the one you actually want.
And treat that file like a password file. "Everything" includes the credentials of your Companion connections, think of a home automation token or a webhook URL, and after the migration also your bridge token. Do not commit it to a repository and do not upload it anywhere. When an AI agent works on it, tell it to inspect structure and key names only and never print values. That is how I ran it.
Step 1: Inventory
Before touching anything, Claude Code read the export and listed every action that belonged to the VICREO connection, page by page, button by button. 140 actions on 119 buttons, seven action types: shell commands (mostly open -a and volume), "send keypress to process" (all aimed at Chrome for Google Slides), single keys and special keys (the Calculator page), key combinations (Magnet shortcuts, Cmd+W, paste), and one "get mouse position" nobody could explain.
That list became the specification. Every row got a route name in the Hammerspoon table, or the migration did not start.
Step 2: The rewrite
Claude Code wrote a Python script that takes the export and produces a new one. Each VICREO action is replaced in place by an HTTP GET with the variable URL. Everything around it stays exactly where it was: Companion's own wait actions, page changes, ATEM macros.
Some buttons were several VICREO actions in a row: open ATEM Software Control, wait, Cmd+R, wait, a, wait, Enter. Or open Slack, wait, then Ctrl+Alt+K for Magnet. Those became one named route each on the Hammerspoon side, which is why 140 VICREO actions turned into 120 HTTP calls.
A new HTTP action in the export looks like this:
{
"type": "action",
"definitionId": "get",
"connectionId": "<id of the http connection>",
"options": {
"url": {"value": "$(custom:bridge_url)/keynote_next?token=$(custom:bridge_token)", "isExpression": false},
"header": {"value": "", "isExpression": false},
"result_stringify": {"value": true, "isExpression": false}
},
"disabled": false,
"upgradeIndex": 2
}upgradeIndex has to match the HTTP module version on the Companion you import into. I did not trust a guess there. I updated the module on my MacBook, exported one real HTTP action from it and let Claude Code copy that shape.
Step 3: Make the script refuse to guess
This is where the time went, and it was worth it. Three checks run before the script writes a single byte.
No unmapped actions. An app name that is not in the table, a URL it has not seen, a key code it does not know: the script stops and names the page, the button and the option. No silent drops. That caught home Assistant (an app that was not even installed, so it became a browser route) and Sounddesk versus SoundDesk on two different buttons.
Nothing else changed. The script compares the old and the new JSON tree and lists every path that differs. Allowed are exactly the action lists that contained VICREO, the removed VICREO connection and the two new variables. Anything else, and it refuses. Mine: 126 changed paths, 126 allowed.
Every route exists in the Lua. The script reads the route names out of init.lua and rejects any button that asks for one the bridge does not know. A typo becomes an error message on my desk, not a dead button during a live show.
Step 4: Import once, then trust nothing
The import dialog offers two modes: "Import, Resetting only Selected Components" and "Full Reset & Import", with a hint that the full reset is "generally the safer option".
Not in this case. The export does not contain the Settings page. A full reset would have wiped those settings, including the ones other devices use to talk to that Companion. So: everything ticked, "Resetting only Selected Components".
That mode has its own catch. It does not delete a connection that is missing from the file. The old VICREO connection survived the import as an orphan, still trying to reach a listener that no longer exists. It had to be deleted by hand.
Which leads to the one rule I would underline: verify with a fresh export, never with the file you imported. The file you imported shows what you asked for. A fresh export shows what Companion actually did. Mine showed 0 VICREO actions, 120 HTTP calls, all using the variables, and one connection too many.
Then I pressed one button of every kind and watched the bridge log. One log line per press, no duplicates. The Calculator showed 77 after two presses of 7.
Two Things Claude Code Changed After a Second Opinion
Before I approved the plan, I had Claude Code ask OpenAI's Codex for the strongest failure mode we might have missed. Not "what do you think", which reliably returns "looks sound", but "what would break".
It found a real one. A button like "open Slack, wait, press Ctrl+Alt+K" returns success whether or not Slack is actually in front when the keys land. A fixed delay proves nothing. And two such buttons pressed within a second can mix their keystrokes.
So the bridge got one queue: every action waits until the previous one has finished. And it got a focus check: before typing anything into the front window, it confirms the expected app is really in front, and otherwise logs focus-mismatch and types nothing.
The first live test made that check look brilliant and my test plan look silly: every combo aborted. The Mac mini's screen was locked, and on a locked Mac nothing is in front. App launches, URLs, volume and the per-app keys worked fine regardless. That is the correct behaviour, but it is worth knowing before you stand in front of a locked Mac pressing buttons.
The Permission Dialog That Froze Everything
The SSH buttons open Terminal through AppleScript. The first time, macOS asks whether Hammerspoon may control Terminal. Expected.
Not expected: Hammerspoon's built-in hs.osascript.applescript waits for the script to finish, and while that permission dialog is open, the whole of Hammerspoon waits with it. Every other button was dead for twenty seconds. Running AppleScript as a separate osascript process through hs.task fixed it.
Quick Reference
# install Hammerspoon, reload the config from a shell
brew install --cask hammerspoon
hs -c 'hs.reload()' # needs require("hs.ipc") in init.lua
# does the bridge answer? wrong token must give 403
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8001/hammerspoon/keynote_next?token=wrong'
# a token Companion will not mangle
openssl rand -hex 24The button URL, on every Companion instance and every button:
$(custom:bridge_url)/<route>?token=$(custom:bridge_token)Lessons Learned
- macOS 27 is the last release with full Rosetta 2 support. Check now which of your Stream Deck helpers are Intel-only, not next autumn.
- Replace "run any command" with a fixed list of named actions. The Mac decides what runs, not the button.
- Put the bridge address and the token into two Companion custom variables. Pages become portable, and a leaked token is a two-minute fix.
- Tokens are letters and digits only. Companion percent-encodes variables in URLs.
- Answer Companion's HTTP request before you run the action. Its client retries, and every retry is a real press.
- Export your Companion config as JSON. It is complete, an LLM handles it well, and rewriting it with a script beats 140 manual edits, as long as the script refuses unknown actions and proves nothing else changed. It also contains your connection credentials, so keep it private and never let an agent print its values.
- Copy the exact action shape from a real export of the target module version instead of guessing it.
- Do not use "Full Reset & Import" with an export that has no Settings, and delete leftover connections by hand afterwards.
- Back up your export before you change anything, and verify afterwards with a fresh export, never with the file you imported.
- Run actions through one queue and check focus before typing. A success response means the request arrived, not that the keys landed in the right window.
- Never run AppleScript synchronously inside Hammerspoon. One permission dialog can freeze every button.
Written the week macOS 27 came out. The Stream Deck XL works again, and it no longer depends on a translation layer with an expiry date.
This post was written with the help of Claude Code. All of the code, the Lua bridge, the rewrite script and every test, was written by Claude Code with Fable 5.1. I wrote none of it. The decisions are my own.
OpenPGP-signed. Verify authenticity