| name | hammerspoon |
| description | Hammerspoon macOS automation. Config in ~/.hammerspoon/. Modules: init.lua, spaces, sleepwake, urlrouter, httpserver, meetings, webcam. Keywords: hammerspoon, hs, lua, audiodevice, hotkey, caffeinate, httpserver, Stream Deck, Rectangle Pro, blueutil, AirPods, URL routing, webcam, uhubctl. Use when: editing ~/.hammerspoon/ files, debugging Hammerspoon modules, adding Hammerspoon features, checking Hammerspoon logs. |
Hammerspoon
macOS automation tool. Installed on all Darwin machines via Brewfile cask. Config lives in ~/.hammerspoon/.
Architecture
init.lua — Module loader. Loads hs.ipc (enables the hs CLI to talk to the running instance), then conditionally loads modules that exist on disk (yadm alternates ensure class-gated modules are only present on matching machines).
constants.lua — Shared hardware and tool path constants used across modules (uhubctl, blueutil, USB hub/port assignments, AirPods address).
spaces.lua##class.Work — Applies the Rectangle Pro External display layout on every Space switch.
sleepwake.lua##class.Work — Caffeinate watcher for sleep/wake/unlock events. Manages Stream Deck USB power, BusylightHTTP, nginx, and Elgato Control Center. Exports displaysleep() for use by other modules.
urlrouter.lua##class.Work — URL-based browser router (replaces Choosy). Hammerspoon is registered as the default HTTP/HTTPS handler via duti. Routes zoom.us/j/ and zoom.us/my/ links to Zoom.app, *.slack.com links to Slack.app, everything else to Chrome. Slack archive URLs are converted to slack://channel deep links (see Slack deep linking below) so the desktop app navigates to the message/thread instead of just focusing.
meetings.lua##class.Work — Auto-switches audio to AirPods when Zoom launches (connects via blueutil if needed), pauses Spotify, quits eqMac, powers on webcam USB. On Zoom exit: restores previous audio device, resumes Spotify, relaunches eqMac hidden, quits Camo Studio, powers off Elgato Wave USB port and webcam USB port. Uses a single hs.application.watcher (stored in M._watcher and return M so it isn't GC'd) keyed on webcam.apps; seeds activeMeetings from running apps on load so a reload mid-meeting doesn't fire a spurious start.
webcam.lua##class.Work — Powers the YoloCam S3 USB port on/off via uhubctl. Exports webcam.on() and webcam.off(). Powers off on Hammerspoon load (unless Zoom is running). Called by meetings.lua and sleepwake.lua.
httpserver/ — Modular HTTP server on localhost:18990. Sub-modules each return a table of { actionName = handlerFn } that get merged into a single dispatch table.
httpserver/init.lua — Server skeleton. Parses query params via hs.http.urlParts, loads sub-modules, dispatches on ?action=.
httpserver/triggers.lua##class.Work — lock and sleep actions for Home Assistant (Work only, depends on sleepwake).
httpserver/notify.lua — notify action for native macOS notifications via hs.notify. Maps event types to sounds/subtitles. Click callback focuses iTerm2 and selects the originating tmux pane.
Key behaviours
| Event | Actions |
|---|
| Space switch | Apply Rectangle Pro layout |
systemWillSleep | Kill BusylightHTTP, power off Stream Deck USB |
screensDidSleep | Power off Stream Deck USB |
screensDidUnlock | Cycle Stream Deck (async), restart nginx, reopen BusylightHTTP, restart Elgato Control Center, apply Rectangle Pro layout (2s delay); power off webcam if Zoom not running |
hammerspoon://displaysleep URL | Power off Stream Deck, lock screen, sleep display after 2s |
HTTP ?action=lock | Lock screen (Work only) |
HTTP ?action=sleep | Same as displaysleep URL handler (Work only) |
HTTP ?action=notify | Send native macOS notification with click-to-focus (all machines) |
Any http/https URL opened | Route to Zoom.app (meeting links) or Chrome (everything else) — replaces Choosy (Work only) |
| Zoom launched | Power on webcam USB, connect AirPods via blueutil, switch audio output, pause Spotify, quit eqMac (Work only) |
| Zoom terminated | Power off webcam USB, restore previous audio output, resume Spotify, relaunch eqMac hidden, quit Camo Studio, power off Elgato Wave USB (Work only) |
Notify action
Used by ~/.config/opencode/notifier/notify.sh to deliver OpenCode notifications. The shell script handles the grace period and tmux @opencode-waiting check, then delegates to Hammerspoon via curl.
Query params: event, message, title, pane (tmux pane ID).
Event-to-sound mapping (in httpserver/notify.lua):
| Event | Sound | Subtitle |
|---|
complete | Glass | Session Complete |
subagent_complete | Pop | Subagent Complete |
error | Basso | Error |
permission | Ping | Permission Required |
question | Purr | Question |
Network topology (Home Assistant → laptop)
Home Assistant
→ http://gitlab-macbookpro:18989/trigger?action=lock → nginx → Hammerspoon :18990
→ http://gitlab-macbookpro:18989/trigger?action=sleep → nginx → Hammerspoon :18990
→ http://gitlab-macbookpro:18989?action=currentpresence → nginx → BusylightHTTP :8989
hs.httpserver only accepts localhost connections, so nginx is required as a reverse proxy for external access.
Stream Deck integration
The Stream Deck "lock" button should open the URL hammerspoon://displaysleep (configured to open with Hammerspoon). This replaces the previous Ctrl+Cmd+Q + pmset displaysleepnow approach which had timing issues with USB wake events.
USB hub locations
CalDigit TS4 USB2 hub (M.usbHub = "2-1.1.2")
Verify with: uhubctl --location 2-1.1.2
| Port | Constant | Device |
|---|
| 1 | (Elgato Wave hub) | USB 2.0 Hub (ganged) |
| 2 | streamDeckPort | Elgato Stream Deck XL |
The Elgato Wave microphone is on a sub-hub at port 1.
CalDigit TS4 USB3 hub (M.usbHub3 = "2-2.4.1")
Verify with: uhubctl --location 2-2.4.1
| Port | Constant | Device |
|---|
| 2 | webcamPort | YoloCam S3 (46f8:0855) |
If the hub layout changes, update constants.lua.
Rectangle Pro
URL scheme is rectangle-pro:// (not rectanglepro://). Layout is triggered via:
open -g "rectangle-pro://execute-layout?name=External%20display"
Slack deep linking (urlrouter)
Opening a Slack web archive URL (https://<ws>.slack.com/archives/<CHANNEL>/p<TS>) in the Slack desktop app via openURLWithBundle only focuses the app — it does not navigate to the message. Chrome works because Slack's web redirect page builds a proper slack:// deep link server-side (it knows the team ID). urlrouter.lua##slackDeepLink reproduces this:
- Parses subdomain, channel ID, and packed timestamp from the archive URL.
- Converts the packed
p<TS> to a message ts by inserting a . before the last 6 digits (p1773236141113359 → 1773236141.113359).
- Maps the workspace subdomain to a team ID via the
slackTeams table and appends &team=<TEAM_ID> — required; without team, Slack only focuses and does not navigate (verified). GitLab workspace (gitlab) team ID is E03N1RJJX7C (Enterprise Grid).
- Preserves
thread_ts from the query string for threaded replies.
- Produces
slack://channel?id=<CHANNEL>&message=<TS>&team=<TEAM>[&thread_ts=<TS>].
Find a workspace's team ID in ~/Library/Application Support/Slack/storage/root-state.json (workspaces[*].team_id → domain). Add new workspaces to the slackTeams map. Non-archive Slack URLs (sign-in, SSO — see exclude) fall through to the raw URL.
Related files
~/.config/yadm/bootstrap.d/901-configure-hammerspoon-firewall.sh##os.Darwin,class.Work — Adds Hammerspoon to macOS firewall allowlist (Work only; Personal machines only use localhost)
~/.config/yadm/bootstrap.d/941-open-hammerspoon-at-login.sh##os.Darwin — Launches Hammerspoon at login (all Darwin machines)
~/.config/yadm/bootstrap.d/940-open-apps-at-login.sh##os.Darwin,class.Work — Other Work-only login items (Hammerspoon removed from here)
~/.config/opencode/notifier/notify.sh — OpenCode notifier script that calls Hammerspoon's notify endpoint
~/.config/yadm/config_templates/nginx/servers/localhost.conf — nginx reverse proxy config
~/.config/yadm/scripts/defaults.sh##os.Darwin — Registers Hammerspoon as default HTTP/HTTPS handler via duti (for urlrouter)
~/.config/mise/conf.d/work.toml##class.Work — system:fix task (manual fallback with sudo powers)
~/.config/yadm/scripts/run-checks.zsh##class.Work — Health checks for Hammerspoon, nginx, Busylight, Stream Deck
Debugging / Logs
Hammerspoon logs to an in-memory console. Read it from the CLI with:
hs -c "hs.console.getConsole()"
This requires hs.ipc (loaded in init.lua). The output includes all hs.logger messages and extension load traces. There are no on-disk log files by default.
API documentation
Use Context7 (/hammerspoon/hammerspoon.github.io) for Hammerspoon API reference and guides — do not guess API signatures. It maps to the Hammerspoon docs site and has broader coverage than the repo entry. Fallback: https://www.hammerspoon.org/docs/
Development workflow
- Edit Lua files in
~/.hammerspoon/
- Run
stylua to format (enforced by pre-commit)
- Reload config:
hs -c "hs.reload()" (or use run-in-tmux-pane if hs needs shell environment)
- Check logs:
hs -c "hs.console.getConsole()"
hs.task objects must be stored in module-level variables to prevent garbage collection
Gotchas
hs.task, hs.timer, and hs.*.watcher objects are garbage-collected if nothing reachable references them — the underlying process/timer/watcher is then killed/disabled. In a side-effect-only module (no return), a top-level local watcher = hs.application.watcher.new(...) referenced by nothing is collected once the chunk finishes. Store such objects in a returned module table (e.g. M._watcher) and return M, mirroring webcam.lua. This was the root cause of meetings.lua silently not pausing/resuming Spotify on Zoom launch/quit — the local watcher was collected.
- Nuance (verified): a running
hs.timer whose own callback closes over the timer variable (e.g. to call timer:stop()) survives GC even when assigned only to a function-local that has gone out of scope — Hammerspoon's C side strongly references active timers, and the self-referential closure keeps the Lua object alive. So audio.lua's setDockedAudioOutput local-timer pattern is fine and does not need module-level retention.
- Debugging note: timer/watcher callbacks created ad-hoc via
hs -c must log with hs.logger/log.i to appear in hs.console.getConsole(); bare print() from such callbacks is not reliably captured, which can falsely look like the timer was GC'd.
webcam.apps keys must be the app's current localized name as reported by hs.application.watcher/hs.application.get/hs.application.runningApplications (a:name()), not a bundle ID or an old name. The Zoom meeting app reports Zoom (bundle ID us.zoom.xos) — an earlier zoom.us key never matched, so the meetings.lua watcher's if not webcam.apps[name] then return end guard silently dropped every Zoom launch/quit event (no Spotify pause/resume, no webcam power). Match on the exact name Zoom, not a substring — ESDZoom (Stream Deck plugin, com.elgato.zoom.sdPlugin) and Zoom Workspace Aomhost (us.zoom.aomhost) also contain "zoom" and would cause false triggers. Verify a key with: hs -c "for _,a in ipairs(hs.application.runningApplications()) do local n=a:name() if n:lower():find('zoom') then print(n, a:bundleID()) end end".
meetings.lua must derive meeting active-state from the launched/terminated event itself (activeMeetings[name] = true on launch, = nil on terminate), not by recomputing via hs.application.get(appName) on each event. hs.application.get races against process reaping: at the terminated event the dying app is often still enumerable, so a recompute keeps the state "active" and onMeetingEnd never fires; the subsequent launched then sees previouslyActive == true and skips onMeetingStart — so Spotify never pauses. The event is authoritative and race-free. Keep seeding activeMeetings from running apps only on module load (so a reload mid-meeting doesn't fire a spurious start). Verify by tailing hs.console.getConsole() for meetings: Paused Spotify after a full Zoom quit + relaunch.
hs.timer.doUntil checks the predicate before running the action — if the predicate is true immediately, the action never fires. Prefer hs.timer.doEvery with manual stop.
- Hammerspoon needs explicit Bluetooth permission in System Settings > Privacy & Security > Bluetooth to use
blueutil via hs.task.
- Lua files are formatted by
stylua via pre-commit. The hook uses language: system, so stylua must be available on PATH (e.g. via mise use -g stylua).