Hammerspoon Bridge for Companion
This is the example script from the post MacOS 27 broke my old VICREO setup. It is a cleaned-up version of what runs on my Macs: same structure, a shorter list of actions, no real token. Like the original, it was written entirely by Claude Code with Fable 5.1.
How to use it
If you already have a ~/.hammerspoon/init.lua, copy it somewhere safe first. If you are migrating Companion buttons, export your Companion config before changing anything.
- Install Hammerspoon (
brew install --cask hammerspoon), start it, and allow it under System Settings, Privacy & Security, Accessibility. - Paste the script below into
~/.hammerspoon/init.lua. - Replace
SECRETwith your own token. Letters and digits only, for example fromopenssl rand -hex 24. The bridge refuses to start while the placeholder is still there. - Reload: Hammerspoon menu bar icon, Reload Config.
- In Companion, create two custom variables and set their startup value:
bridge_url=http://127.0.0.1:8001/hammerspoonandbridge_token= your token. - On a button, add the action
http: GETwith the URL$(custom:bridge_url)/keynote_next?token=$(custom:bridge_token).
Test it from a terminal before touching Companion. A wrong token must answer 403, an unknown route 404:
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8001/hammerspoon/keynote_next?token=wrong'
To add your own button, add one line to the routes table and reload. The first time an action controls another app (Terminal, Finder), macOS asks for permission on the Mac's main display.
The script
-- ~/.hammerspoon/init.lua
-- Hammerspoon HTTP bridge for Bitfocus Companion (replaces VICREO Listener).
-- Companion button action: http: GET $(custom:bridge_url)/<route>?token=$(custom:bridge_token)
-- with bridge_url = http://127.0.0.1:8001/hammerspoon
require("hs.ipc") -- lets you run `hs -c 'hs.reload()'` from a terminal
-- Letters and digits only: Companion percent-encodes anything else in variables.
-- Generate one with: openssl rand -hex 24
local SECRET = "REPLACE_WITH_YOUR_OWN_48_CHARACTER_TOKEN"
local PORT = 8001
local logPath = os.getenv("HOME") .. "/.hammerspoon/bridge.log"
local function log(text)
local f = io.open(logPath, "a")
if f then
f:write(os.date("%Y-%m-%d %H:%M:%S") .. " " .. text .. "\n")
f:close()
end
end
-- AppleScript runs as a child process. hs.osascript is synchronous and would freeze
-- the whole bridge while a macOS permission dialog is waiting.
local function appleScript(script, done)
local task = hs.task.new("/usr/bin/osascript", function(code, _, err)
if code ~= 0 then log("applescript failed: " .. (err or "")) end
done()
end, {"-e", script})
if not task:start() then log("osascript did not start"); done() end
end
-- Sends a key to one app by process, like VICREO's "send keypress to process".
-- Works even when another window is in front.
local function keyToApp(bundleID, mods, key)
local app = hs.application.get(bundleID)
if not app then log("not running: " .. bundleID); return end
hs.eventtap.keyStroke(mods, key, 50000, app)
end
-- The only things a button can ask for. Nothing from the request reaches a shell.
local routes = {
-- launch apps and open URLs
app_keynote = function() hs.application.launchOrFocusByBundleID("com.apple.iWork.Keynote") end,
app_spotify = function() hs.application.launchOrFocusByBundleID("com.spotify.client") end,
url_youtube = function() hs.urlevent.openURLWithBundle("https://www.youtube.com/", "com.google.Chrome") end,
-- keys delivered to a specific app
keynote_next = function() keyToApp("com.apple.iWork.Keynote", {}, "right") end,
keynote_prev = function() keyToApp("com.apple.iWork.Keynote", {}, "left") end,
-- audio
mute_toggle = function() local d = hs.audiodevice.defaultOutputDevice(); d:setOutputMuted(not d:outputMuted()) end,
vol_50 = function() hs.audiodevice.defaultOutputDevice():setVolume(50) end,
-- system
lock = function() hs.caffeinate.lockScreen() end,
ssh_server = function(done) appleScript('tell application "Terminal"\nactivate\ndo script "ssh myserver"\nend tell', done) end,
}
-- Routes that finish later and call done() themselves. The queue waits for them.
local async = { ssh_server = true }
-- One queue: an action runs only after the previous one has finished, including
-- asynchronous ones, so two presses in quick succession cannot overlap.
local queue, busy, current, watchdog = {}, false, 0, nil
local function pump()
if busy or #queue == 0 then return end
busy = true
current = current + 1
local id, name = current, table.remove(queue, 1)
local function done()
if not busy or id ~= current then return end -- count each action once
if watchdog then watchdog:stop() end
busy = false
hs.timer.doAfter(0, pump)
end
-- safety net: one stuck action must never block every button
watchdog = hs.timer.doAfter(20, function() log("timeout: " .. name); done() end)
local ok, err = pcall(routes[name], done)
if not ok then log("error in " .. name .. ": " .. tostring(err)); done(); return end
if not async[name] then done() end
end
local function urlDecode(s)
return (s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end))
end
local function parseQuery(qs)
local params = {}
for k, v in (qs or ""):gmatch("([^&=]+)=([^&=]+)") do params[k] = urlDecode(v) end
return params
end
local lastFired = {}
local function callback(method, path)
local headers = {["Content-Type"] = "application/json"}
local ok, body, status = pcall(function()
local route, query = path:match("^([^?]*)%??(.*)$")
if parseQuery(query).token ~= SECRET then
log(method .. " " .. route .. " -> 403") -- log the route, never the token
return '{"status":"forbidden"}', 403
end
local name = route:match("^/hammerspoon/([%w_]+)$")
if not name or not routes[name] then
log(method .. " " .. route .. " -> 404")
return '{"status":"not found"}', 404
end
-- Companion retries slow requests; ignore a repeat of the same route within 0.8 s
local now = hs.timer.secondsSinceEpoch()
if lastFired[name] and now - lastFired[name] < 0.8 then
return '{"status":"debounced"}', 200
end
lastFired[name] = now
log(method .. " " .. route .. " -> queued")
-- Answer first, act afterwards. Otherwise Companion times out and presses again.
hs.timer.doAfter(0, function() table.insert(queue, name); pump() end)
return '{"status":"ok"}', 200
end)
if ok then return body, status, headers end
log("callback error: " .. tostring(body))
return '{"status":"error"}', 500, headers
end
local server
local function start()
-- Never run with the published placeholder or a token Companion would mangle.
if SECRET:find("REPLACE_WITH", 1, true) or not SECRET:match("^%w+$") or #SECRET < 32 then
log("NOT started: set SECRET to your own token (letters and digits, 32 or more)")
hs.alert.show("Companion bridge NOT started: set your own SECRET in init.lua")
return
end
if server then server:stop() end
server = hs.httpserver.new(false) -- false = plain HTTP, no Bonjour advertising
server:setInterface("127.0.0.1") -- only Companion on this Mac can reach it
server:setPort(PORT)
server:setCallback(callback)
server:start()
log("bridge started on 127.0.0.1:" .. PORT)
end
start()
-- The listener can silently lose its socket after sleep: rebind on wake,
-- and check every five minutes as a safety net.
bridgeWakeWatcher = hs.caffeinate.watcher.new(function(event)
if event == hs.caffeinate.watcher.systemDidWake then start() end
end)
bridgeWakeWatcher:start()
bridgeHealthTimer = hs.timer.doEvery(300, function()
local out = hs.execute("lsof -iTCP:" .. PORT .. " -sTCP:LISTEN -n 2>/dev/null")
if not out or out == "" then log("port not listening, restarting"); start() end
end)
What each part does
routes is the whole security model. A button can only name an entry in this table. Adding a button means adding a line here, never sending a command from Companion.
keyToApp passes the app as the last argument of hs.eventtap.keyStroke. That posts the key to that app's process, so a Keynote button still works when another window has focus.
The queue runs one action at a time. Asynchronous actions like the AppleScript one are listed in async and release the queue only when they are finished, with a 20-second safety net. For multi-step actions with delays (open an app, wait, press a shortcut), you would additionally check hs.application.frontmostApplication() before typing, so keys never land in the wrong window.
What is deliberately not in this example: a route that types text and presses Enter into whatever window is in front. It is handy for confirming prompts, and it is also exactly the button that approves the wrong thing when the wrong window has focus. If you add one, make it check the frontmost app first and keep it off pages you press in a hurry.
callback checks the token, then the route, answers Companion immediately and only then queues the action. Companion's HTTP client retries requests that take too long, and each retry would otherwise be a real second press.
start binds to 127.0.0.1, so only software on the same Mac can call the bridge. The wake watcher and the five-minute health check rebind the port if macOS drops it after sleep.
The Hammerspoon documentation for everything used here: hs.httpserver, hs.eventtap, hs.application, hs.task.
Verifying this page
This page is OpenPGP-signed. To check that the text above is unchanged and really came from me,
follow the three steps on Verify authenticity and use hammerspoon-bridge as the slug.