Guide 1: Reacting to events with hooks
You will learn
- What hooks are and when they fire
- How to register hooks via configuration
- How to look up window properties from a hook callback
- How to manipulate window state from hooks
- How to use multi-argument hooks
- How to register and unregister hooks at runtime
Prerequisites
Shoji installed and running. See Installation if you haven’t set it up yet. Basic Lua knowledge (variables, functions, tables) is helpful but not required.
You do not need to complete the layout tutorials first.
What we’re building
A window activity monitor that:
- Logs when Shoji starts
- Logs window creation with app names and titles
- Shows an alert when a specific app opens
- Auto-floats small dialog windows
- Tracks layout changes across spaces
Along the way you’ll learn both ways to register hooks (configuration and runtime) and how to clean up after yourself.
Step 1: Your first hook
Hooks are callbacks that Shoji runs when something happens
--- a window opens, a layout changes, Shoji starts up. The
simplest hook is started, which fires after start()
completes.
Open ~/.hammerspoon/init.lua and add a hooks table to
your configuration:
hs.loadSpoon("Shoji")
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end, },})
spoon.Shoji:start()Reload Hammerspoon (menu bar > Reload config) and open
the console (Cmd+Alt+C).
You should see Shoji started in the output.
This is the full pattern: hooks go in the hooks table
inside configure(), keyed by event name. Shoji calls your
function when that event fires.
Step 2: React to window creation
The started hook has no arguments. Most hooks pass
context about what happened. The window_created hook
receives a windowID --- a number that identifies the
window.
To get useful information from a window ID, use
hs.window.get():
hs.loadSpoon("Shoji")
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end,
window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local title = win:title() or "untitled" local app = win:application() local appName = app and app:name() or "unknown"
print("[+] " .. appName .. ": " .. title) end, },})
spoon.Shoji:start()Reload and open a few windows. The console should show lines like:
[+] Safari: Apple[+] Terminal: ~ — zshWhy check for nil? By the time your hook runs, the
window might already be gone (the user closed it quickly,
or it was a transient splash screen).
hs.window.get(windowID) returns nil for windows that no
longer exist. Always guard against this.
Step 3: Alert on a specific app
Now let’s do something more useful: show an alert when a specific app opens a window. We’ll check the app’s bundle ID, which is a stable identifier that doesn’t change with language or updates.
hs.loadSpoon("Shoji")
local SLACK_BUNDLE_ID = "com.tinyspeck.slackmacgap"
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end,
window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local title = win:title() or "untitled" local app = win:application() local appName = app and app:name() or "unknown"
print("[+] " .. appName .. ": " .. title)
local bundleID = app and app:bundleID() if bundleID == SLACK_BUNDLE_ID then hs.alert.show("Slack window opened") end end, },})
spoon.Shoji:start()Step 4: Auto-float small windows
Some windows shouldn’t be tiled: dialog boxes, preference panels, 1Password popups. These are usually smaller than regular windows. Let’s auto-float windows below a size threshold.
This is where hooks get powerful: you can change Shoji’s behavior from inside a callback by calling the state API.
hs.loadSpoon("Shoji")
local SLACK_BUNDLE_ID = "com.tinyspeck.slackmacgap"local MIN_TILE_WIDTH = 500local MIN_TILE_HEIGHT = 400
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end,
window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local title = win:title() or "untitled" local app = win:application() local appName = app and app:name() or "unknown"
print("[+] " .. appName .. ": " .. title)
-- Alert on Slack local bundleID = app and app:bundleID() if bundleID == SLACK_BUNDLE_ID then hs.alert.show("Slack window opened") end
-- Auto-float small windows local frame = win:frame() if frame.w < MIN_TILE_WIDTH or frame.h < MIN_TILE_HEIGHT then spoon.Shoji.state:setWindowFloating( windowID, true ) print( " -> auto-floated (too small: " .. frame.w .. "x" .. frame.h .. ")" ) end end, },})
spoon.Shoji:start()Open a small dialog (e.g., Finder’s Go to Folder with Cmd+Shift+G) and check the console. You should see the auto-float message, and the window should stay at its natural size instead of being tiled.
Why setWindowFloating(windowID, true) instead of
toggleWindowFloating()? Toggle depends on the current
state. If a window is somehow already floating, toggle
would unfloat it --- the opposite of what you want. For
auto-float logic, always use the explicit setter.
Why does this work? The window_created hook fires
before the tiling pass runs. When you set the floating
flag here, the tiling pass sees the flag and skips the
window. No extra retile needed.
Step 5: Multi-argument hooks
Not all hooks pass a single argument. The layout_changed
hook passes three: the space ID, the new layout name, and
the previous layout name (or nil if there was no previous
layout).
Add this hook alongside the existing ones:
hs.loadSpoon("Shoji")
local SLACK_BUNDLE_ID = "com.tinyspeck.slackmacgap"local MIN_TILE_WIDTH = 500local MIN_TILE_HEIGHT = 400
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end,
window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local title = win:title() or "untitled" local app = win:application() local appName = app and app:name() or "unknown"
print("[+] " .. appName .. ": " .. title)
local bundleID = app and app:bundleID() if bundleID == SLACK_BUNDLE_ID then hs.alert.show("Slack window opened") end
local frame = win:frame() if frame.w < MIN_TILE_WIDTH or frame.h < MIN_TILE_HEIGHT then spoon.Shoji.state:setWindowFloating( windowID, true ) print( " -> auto-floated (too small: " .. frame.w .. "x" .. frame.h .. ")" ) end end,
layout_changed = function( spaceID, layout, prevLayout ) local from = prevLayout or "none" print( "Layout: " .. from .. " -> " .. layout .. " (space " .. spaceID .. ")" ) end, },})
spoon.Shoji:start()Cycle layouts with Ctrl+Alt+Space and watch the console. You’ll see messages like:
Layout: tall -> monocle (space 42)Layout: monocle -> bsp (space 42)The prevLayout parameter is nil only the first time a
space gets a layout (there’s no “previous” yet). After
that, it always has a value.
Step 6: Runtime hooks with :on()
So far we’ve registered hooks through configuration. But sometimes you want to add a hook after Shoji has started --- for example, to temporarily monitor something or to register hooks from another Spoon.
The :on() method registers a hook at runtime and returns
a function you can call to unregister it:
hs.loadSpoon("Shoji")
local SLACK_BUNDLE_ID = "com.tinyspeck.slackmacgap"local MIN_TILE_WIDTH = 500local MIN_TILE_HEIGHT = 400
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end,
window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local title = win:title() or "untitled" local app = win:application() local appName = app and app:name() or "unknown"
print("[+] " .. appName .. ": " .. title)
local bundleID = app and app:bundleID() if bundleID == SLACK_BUNDLE_ID then hs.alert.show("Slack window opened") end
local frame = win:frame() if frame.w < MIN_TILE_WIDTH or frame.h < MIN_TILE_HEIGHT then spoon.Shoji.state:setWindowFloating( windowID, true ) print( " -> auto-floated (too small: " .. frame.w .. "x" .. frame.h .. ")" ) end end,
layout_changed = function( spaceID, layout, prevLayout ) local from = prevLayout or "none" print( "Layout: " .. from .. " -> " .. layout .. " (space " .. spaceID .. ")" ) end, },})
spoon.Shoji:start()
-- Runtime hook: monitor focus changeslocal unregisterFocus = spoon.Shoji:on( "window_focused", function(windowID) local win = hs.window.get(windowID) if win then print("Focus -> " .. (win:title() or "?")) end end)Click between windows and you’ll see focus changes logged. When you no longer need the hook, call the unregister function:
-- Stop monitoring focus (run in the Hammerspoon console)unregisterFocus()Config hooks vs runtime hooks:
- Config hooks (
hooks = { ... }insideconfigure()) are registered during startup. They are cleared and re-registered when you callconfigure()again. - Runtime hooks (
:on()) survive reconfiguration. They persist until you call the unregister function or Shoji stops.
Complete code
Here’s the finished window activity monitor:
-- ~/.hammerspoon/init.lua
hs.loadSpoon("Shoji")
local SLACK_BUNDLE_ID = "com.tinyspeck.slackmacgap"local MIN_TILE_WIDTH = 500local MIN_TILE_HEIGHT = 400
spoon.Shoji:configure({ hooks = { started = function() print("Shoji started") end,
window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local title = win:title() or "untitled" local app = win:application() local appName = app and app:name() or "unknown"
print("[+] " .. appName .. ": " .. title)
-- Alert on specific app local bundleID = app and app:bundleID() if bundleID == SLACK_BUNDLE_ID then hs.alert.show("Slack window opened") end
-- Auto-float small windows local frame = win:frame() if frame.w < MIN_TILE_WIDTH or frame.h < MIN_TILE_HEIGHT then spoon.Shoji.state:setWindowFloating( windowID, true ) print( " -> auto-floated (too small: " .. frame.w .. "x" .. frame.h .. ")" ) end end,
layout_changed = function( spaceID, layout, prevLayout ) local from = prevLayout or "none" print( "Layout: " .. from .. " -> " .. layout .. " (space " .. spaceID .. ")" ) end, },})
spoon.Shoji:start()
-- Runtime hook: monitor focus changeslocal unregisterFocus = spoon.Shoji:on( "window_focused", function(windowID) local win = hs.window.get(windowID) if win then print("Focus -> " .. (win:title() or "?")) end end)What you learned
- Hooks are callbacks registered for specific events
- Register hooks via
configure()or at runtime with:on() - Use
hs.window.get(windowID)to look up window properties from a window ID - Always guard against
nil--- windows can disappear between the event and your callback - Use
state:setWindowFloating()to manipulate window state from hooks (nottoggleWindowFloating()) window_createdfires before tiling, so state changes take effect immediately- Multi-argument hooks like
layout_changedpass context as positional parameters :on()returns an unregister function --- store it
Common mistakes
Using toggleWindowFloating for auto-float logic:
Toggle flips the current state. If a window is already
floating (e.g., from a previous reload), toggle would
unfloat it. Use setWindowFloating(windowID, true) when
you always want floating.
Forgetting to guard against nil windows:
hs.window.get() returns nil when a window no longer
exists. Calling methods on nil crashes your hook. Always
check before using the window object:
local win = hs.window.get(windowID)if not win then returnendHooks that trigger recursion: If your hook calls a Shoji action (like retiling), that action may fire another hook, which fires another action, and so on. Shoji has a recursion guard (depth limit of 10), but your logic may still behave unexpectedly. Be careful when calling Shoji actions from hooks.
Not storing unregister handles:
When you use :on(), store the return value. Without it,
you can’t remove the hook within the current session:
-- BAD: no way to unregister laterspoon.Shoji:on("window_focused", myCallback)
-- GOOD: stored for later cleanuplocal unregister = spoon.Shoji:on( "window_focused", myCallback)Common errors
“attempt to index a nil value”: You’re calling a method
on a nil window object. Add a nil check after
hs.window.get(windowID).
“attempt to concatenate a nil value”: You’re
concatenating a value that might be nil (e.g.,
win:title()). Use or to provide a default:
win:title() or "untitled".
“Invalid hook type: window_create”: Hook names use
snake_case and are spelled exactly. Common typos:
window_create (missing d), windowCreated (wrong
case), on_started (extra prefix). See the
Hooks reference for the full list.
Hook doesn’t fire: Check that your hook name matches
exactly. Configuration hooks require configure() to be
called before start(). Runtime hooks via :on() work any
time after start().
Challenge
Add a layout_changed hook that tracks layout usage across
your session. Each time the layout changes, log the
transition (e.g., "tall -> bsp on space 3") and keep a
count of how many times each layout has been activated.
Print the counts whenever a change happens:
Layout: tall -> bsp (space 42) Layout usage: tall=5, bsp=3, monocle=2Hint: use a table outside the hook to accumulate counts,
and update it each time layout_changed fires.
Next steps
In Guide 2: Composing layouts with combinators, you’ll learn to combine built-in layouts into adaptive, purpose-specific layouts using Shoji’s layout algebra.