Skip to content

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:

  1. Logs when Shoji starts
  2. Logs window creation with app names and titles
  3. Shows an alert when a specific app opens
  4. Auto-floats small dialog windows
  5. 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: ~ — zsh

Why 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 = 500
local 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 = 500
local 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 = 500
local 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 changes
local 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 = { ... } inside configure()) are registered during startup. They are cleared and re-registered when you call configure() 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 = 500
local 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 changes
local 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 (not toggleWindowFloating())
  • window_created fires before tiling, so state changes take effect immediately
  • Multi-argument hooks like layout_changed pass 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
return
end

Hooks 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 later
spoon.Shoji:on("window_focused", myCallback)
-- GOOD: stored for later cleanup
local 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=2

Hint: 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.