Skip to content

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 pcall and 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: : Typo in the hook name. Common: 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.