Personal Hammerspoon configuration for macOS automation — window management, Slack status, encrypted scratchpad, GIF search, and more.
| Module | Purpose | Hotkey |
|---|---|---|
window_manager |
Rectangle-style window tiling with fraction cycling (1/2, 1/3, 2/3) | Ctrl+Alt+Cmd + arrows/F/Home/End |
scratchpad |
Encrypted markdown editor synced via iCloud | Ctrl+Alt+S |
gif_finder |
GIF search via Klipy API with favorites and recents, copies URL to clipboard | Ctrl+Alt+G |
slack_status |
Auto-updates Slack status based on WiFi network; manual overrides and custom status via menu | — |
hyperduck |
Monitors iCloud file for URLs sent from iPhone, opens them on Mac | — |
battery_indicator |
Shows remaining battery time in menu bar | — |
screen_blur |
Full-screen blur overlay for privacy (downsample trick via sips) |
Ctrl+Alt+B |
stt |
Local speech-to-text via parakeet-mlx daemon with optional LLM post-processing, audio tones, media pause/resume, and transcription history viewer | fn+Space (toggle) / fn+Shift (hold) / Ctrl+Alt+H (history) |
clipboard_history |
Clipboard history with search, auto-skips password manager entries, 30-day retention | Ctrl+Alt+V |
mouse_grid |
Keyboard-driven mouse (Mouseless-style): full-screen hint grid for click, right/double click, drag & drop, and scrolling; element hints mode (Shortcat-style, via the Accessibility API); free mode for smooth relative cursor movement | Tap left Cmd (grid) / Double-tap left Cmd (hints) / Tap left Alt (free) |
unified_menu |
Combines Slack Status, Hyperduck, Scratchpad, Screen Blur, and Clipboard History into a single menubar item | — |
| Shortcut | Action |
|---|---|
| Ctrl+Alt+Cmd+Left | Tile window left (cycles 1/2 → 1/3 → 2/3) |
| Ctrl+Alt+Cmd+Right | Tile window right (cycles 1/2 → 1/3 → 2/3) |
| Ctrl+Alt+Cmd+Up | Tile window top (cycles 1/2 → 1/3 → 2/3) |
| Ctrl+Alt+Cmd+Down | Tile window bottom (cycles 1/2 → 1/3 → 2/3) |
| Ctrl+Alt+Cmd+F | Maximize window |
| Ctrl+Alt+Cmd+Home | Move window to previous display |
| Ctrl+Alt+Cmd+End | Move window to next display |
| Ctrl+Alt+S | Toggle scratchpad |
| Ctrl+Alt+G | Toggle GIF finder |
| Ctrl+Alt+B | Toggle screen blur overlay (also dismisses on click or any keypress) |
| fn+Space | Toggle speech-to-text recording (press to start, press again to stop and paste) |
| fn+Shift | Hold-to-talk speech-to-text (hold both to record, release to stop and paste) |
| Ctrl+Alt+H | Toggle STT transcription history viewer |
| Ctrl+Alt+V | Toggle clipboard history viewer |
| Tap left Cmd | Toggle mouse grid overlay (quick press+release of left Cmd alone) |
| Double-tap left Cmd | Element hints mode (Shortcat-style: labels on clickable UI elements) |
| Tap left Alt | Toggle free mouse mode (relative cursor movement, no overlay) |
Note: Home = Fn+Left and End = Fn+Right on Mac keyboards.
Type a cell's two characters (first char = row, a–z top-to-bottom; second char = column, keyboard rows qwert/asdfg/zxcvb left-to-right). Cells are wide horizontal rectangles (26 rows × 15 columns). Then pick a precision point in the subgrid shown inside the cell (qwert / asdfg / zxcvb, laid out spatially) or press Space for the cell center. A hint toast at the bottom of the screen shows the available keys at every step. Modifiers held on the final key choose the action:
| Key | Action |
|---|---|
| subgrid key / Space | Left click |
| Shift + final key | Right click |
| Ctrl + final key | Double click |
| Alt + final key | Move cursor only (no click) |
| Cmd + final key | Arm drag (mouse down; overlay stays up — next selection drops) |
| Hold final key | Nudge: cursor jumps to the point and arrows/h/j/k/l move it in small steps (Shift = bigger); releasing the key performs the action (modifiers apply at release) |
| Backspace | Undo one selection level (or cancel a nudge) |
| Tab | Move overlay to next screen |
, |
Scroll mode: h/j/k/l or arrows scroll, Shift = faster, Esc exits |
| Esc | Dismiss overlay (cancels an armed drag) |
Shortcat-style element hints: the focused window's accessibility tree is scanned for actionable elements (buttons, links, text fields, checkboxes, menu items…) and each one gets a short yellow label. Typing a label's characters filters the hints live (non-matching ones dim out); completing a label performs the action at that element's center. No screenshots involved — element positions come straight from the macOS Accessibility API.
| Key | Action |
|---|---|
| label chars | Filter hints / act when a label is completed |
| Space | Search mode: type the element's text (title/label/value) to find it |
| Shift + final char | Right click |
| Ctrl + final char | Double click |
| Alt + final char | Move cursor only (no click) |
| Cmd + final char | Arm drag (mouse down; hints reappear — next label drops) |
| Backspace | Un-type one label character |
| Esc | Exit hints mode (cancels an armed drag) |
While searching, matching elements are outlined instead of labelled and typing goes to the query:
| Key | Action |
|---|---|
| any text | Filter elements by their accessibility text (case-insensitive substring) |
| Tab / Shift+Tab | Select next / previous match (red outline) |
| Enter | Act on the selected match — same modifiers as above (Shift right, Ctrl double, Alt move, Cmd drag) |
| Backspace | Delete a query character (on an empty query: back to labels) |
| Esc | Back to label mode (Esc again exits hints) |
Notes:
- Scans the focused window of the frontmost app. A "scanning…" toast shows while the (asynchronous) traversal runs; very large windows are capped at 400 hints.
- Chromium browsers (Chrome, Arc, Brave, Edge, Vivaldi) and Electron apps (Slack, Discord, VS Code, Notion) hide web/page content from the accessibility tree by default. The module temporarily enables the relevant accessibility attribute (
AXEnhancedUserInterface/AXManualAccessibility) while hints are up and restores it on exit, since leaving it on can make window snapping glitchy. The first scan in these apps may take one extra ~200ms rescan while the tree populates. The app lists are configurable (enhancedUIApps/electronApps). - Single-tap left Cmd while hints are up switches to the grid; tap left Alt to switch to free mode.
Moves the real cursor with the keyboard — no grid overlay, just a hint toast and a soft glow around the screen edges so it's obvious the mode is active (the glow follows the cursor across monitors). Exits on Esc or after 10s of inactivity. Tapping left Cmd switches to the grid; tapping left Alt while the grid is up switches to free mode.
| Key | Action |
|---|---|
| h / j / k / l | Move cursor left/down/up/right (hold; diagonals work) |
| Shift (held) | Move faster (4×) |
| Ctrl (held) | Move slower (0.25×, precision) |
| Space | Left click (mode stays active) |
| Shift+Space | Right click |
| Ctrl+Space | Double click |
| Cmd+Space | Drag toggle — press to grab, move, press Space again to drop |
| The 4 keys right of N | Scroll left/up/down/right (Shift = faster) — matched by physical position, so m , . / on US, m , . - on Spanish ISO; the toast shows the keys for your layout |
| Esc | Exit free mode (releases a held drag) |
Webview modules (gif_finder, slack_status, scratchpad, stt, clipboard_history) store their HTML, CSS, and JS in separate files under html/:
html/
gif_finder/ — GIF search UI
slack_status/ — Custom status form
scratchpad/ — CodeMirror markdown editor
stt_history/ — Transcription history viewer
clipboard_history/ — Clipboard history viewer
Each directory contains index.html, style.css, and script.js. At runtime, html_loader.lua reads these files and inlines the CSS/JS into the HTML before passing it to hs.webview:html().
- Install Hammerspoon
- Grant Accessibility permissions when prompted (System Settings → Privacy & Security → Accessibility)
Store secrets in the macOS Keychain — they are never saved in code.
# Slack API token (xoxp-...)
security add-generic-password -a "$USER" -s "slack-status-token" -w "YOUR_TOKEN"
# Klipy GIF search API key (https://partner.klipy.com)
security add-generic-password -a "$USER" -s "klipy-api-key" -w "YOUR_API_KEY"
# Mistral API key for STT post-processing (optional — omit to disable)
security add-generic-password -a "$USER" -s "mistral-api-key" -w "YOUR_API_KEY"The speech-to-text module requires a local Python daemon running parakeet-mlx:
cd ~/.hammerspoon/stt-daemon
uv sync
uv run stt_daemon.pyTo run as a background service via launchd:
cp ~/.hammerspoon/stt-daemon/com.local.stt-daemon.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.local.stt-daemon.plistLogs are written to ~/Library/Logs/stt-daemon.log.
When a Mistral API key is present in the keychain, transcribed text is sent through the Mistral API to remove filler words, fix punctuation/capitalization, and apply light grammar corrections. The pill overlay shows a purple "Polishing..." spinner during this step. If the API call fails or times out (10s), the raw transcription is pasted instead.
Configuration options in init.lua:
stt.init({
llm_api_key = mistralApiKey,
-- llm_model = "mistral-small-latest", -- model to use
-- llm_system_prompt = "...", -- custom prompt
-- llm_api_url = "https://api.mistral.ai/v1/chat/completions", -- API endpoint
-- llm_timeout = 10, -- seconds before fallback
})The API uses the OpenAI-compatible chat completions format, so other providers (OpenRouter, Groq, Together, etc.) work by changing llm_api_url, llm_model, and llm_api_key.
Each transcription is appended to an iCloud-synced history file at ~/Library/Mobile Documents/com~apple~CloudDocs/STT/history.txt. Entries include a UTC timestamp, the raw transcription, and the LLM-polished version (if different). The history file grows indefinitely — clean up manually via Finder if needed.
The daemon also saves each recording to a temporary WAV file in /tmp/ before transcription. On success, the WAV is automatically deleted. On failure (transcription error, daemon crash), the WAV is preserved for debugging or manual recovery.
By default, the STT module plays subtle macOS system sounds at key moments:
- Tink — recording starts
- Pop — recording stops
- Glass — transcription/polishing complete
It also pauses any currently playing media (Spotify, Music, YouTube, etc.) when recording starts and resumes it when recording stops. Media state detection uses media-control, which must be installed via Homebrew:
brew tap ungive/media-control && brew install media-controlBoth features can be disabled in init.lua:
stt.init({
play_tones = false, -- disable notification sounds
pause_media = false, -- disable media pause/resume
})The scratchpad encryption key is generated automatically on first use. To copy it to another Mac:
# Export from source Mac
security find-generic-password -a "hammerspoon" -s "scratchpad-encryption-key" -w
# Import on target Mac
security add-generic-password -a "hammerspoon" -s "scratchpad-encryption-key" -w "PASTE_KEY_HERE"The scratchpad, Hyperduck, and GIF Finder modules store files in iCloud Drive:
- Scratchpad:
~/Library/Mobile Documents/com~apple~CloudDocs/Scratchpad/scratchpad.txt - Hyperduck:
~/Library/Mobile Documents/com~apple~CloudDocs/Hyperduck/inbox.txt - GIF Finder:
~/Library/Mobile Documents/com~apple~CloudDocs/GifFinder/favorites.jsonandrecents.json - STT:
~/Library/Mobile Documents/com~apple~CloudDocs/STT/history.txt— append-only transcription history - Clipboard History:
~/Library/Mobile Documents/com~apple~CloudDocs/ClipboardHistory/history.json— clipboard entries (30-day retention)
Hyperduck requires an iPhone Shortcut that appends timestamped URLs (timestamp|url format) to the inbox file. URLs older than 7 days are automatically purged.
After making changes, reload the config:
- Menu bar: Click the Hammerspoon icon → Reload Config
- Console: Open Hammerspoon console and press Cmd+Shift+R