Hooks
Hooks run custom code when specific events occur. Use them for logging, integrations, or extending Shoji’s behavior. This page focuses on patterns and examples. For the canonical list of hook names and arguments, see Hooks reference.
At a glance
- Hook names and arguments live in the Hooks reference
- Use
hooks = { event = fn }and arrays for multiple callbacks - Callbacks run in
pcalland are guarded against recursion - Examples below show common patterns
Registering hooks
Via configuration
Pass hooks through configuration:
spoon.Shoji:configure({ hooks = { layout_changed = function(spaceID, layout, prevLayout) print("Layout changed to: " .. layout) end, },})At runtime
Register hooks dynamically with spoon.Shoji:on():
spoon.Shoji:on("after_tile", function(spaceID) print("Tiled space: " .. spaceID)end)This is useful for adding hooks after Shoji has started, or from other Spoons.
Multiple callbacks
Multiple callbacks for the same event:
spoon.Shoji:configure({ hooks = { after_tile = { function(spaceID) print("Tiled space " .. spaceID) end, function(spaceID) hs.alert.show("Retiled!") end, }, },})Hook list
For a complete list of hook names and arguments, see Hooks reference.
Error handling
Callbacks are wrapped in pcall, so errors won’t crash Shoji. Errors log to
the Hammerspoon console.
Recursion guard
Hooks have a depth limit to prevent infinite loops. If a hook triggers an action that fires the same hook, the chain is blocked at depth 10 (the 10th nested callback does not execute).
Examples
Layout-specific wallpaper
local wallpapers = { tall = "~/Pictures/tall.jpg", wide = "~/Pictures/wide.jpg", monocle = "~/Pictures/monocle.jpg",}
spoon.Shoji:configure({ hooks = { layout_changed = function(_spaceID, layout, _prevLayout) local wallpaper = wallpapers[layout] if wallpaper then hs.screen.mainScreen():desktopImageURL("file://" .. wallpaper) end end, },})Log window events
spoon.Shoji:configure({ hooks = { window_created = function(windowID) local win = hs.window.get(windowID) if win then print("[+] " .. (win:title() or "untitled")) end end, window_destroyed = function(windowID) print("[-] Window " .. windowID .. " closed") end, },})Auto-float specific apps
local autoFloatApps = { ["com.apple.finder"] = true, ["com.apple.Preview"] = true,}
spoon.Shoji:configure({ hooks = { window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local app = win:application() if app and autoFloatApps[app:bundleID()] then spoon.Shoji.state:setWindowFloating(windowID, true) end end, },})Alert on retile
spoon.Shoji:configure({ hooks = { after_tile = function(spaceID) hs.alert.show("macOS Space " .. spaceID .. " retiled", 0.3) end, },})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
spoon.Shoji.state:setWindowFloating(windowID, true)
when you always want floating.
Forgetting nil guards on hs.window.get() return
values:
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.
Calling Shoji actions from hooks without considering recursion: A hook that calls a Shoji action may trigger another hook, creating a chain. Shoji guards against infinite recursion (depth limit of 10), but complex chains may still behave unexpectedly.
Registering hooks via configure() after start():
configure() replaces existing hooks. Use :on() for
runtime registration to avoid overwriting hooks set during
initial configuration.
Common errors
“attempt to index a nil value”: Calling methods on a
nil window from hs.window.get(). Add a nil check
before using the window object.
“Invalid hook type: window_create (missing d),
windowCreated (wrong case). See the
Hooks reference for valid names.
Hook doesn’t fire: Check the hook name spelling and
registration timing. Hooks registered via configure()
after start() replace earlier hooks — use :on() for
additive registration.