Guide 4: Building an extension
You will learn
- The extension lifecycle (setup and teardown)
- How to register engine event handlers that return intents
- How to intercept intents to add side effects
- How to persist extension state across Hammerspoon reloads
- How to expose extension features as actions and IPC commands
- When to use hooks, engine handlers, or full extensions
Prerequisites
Complete Guide 1 (hooks and state basics) and Guide 3 (custom actions and IPC commands).
What we’re building
A “display profiles” extension that automatically reconfigures Shoji when you dock or undock your laptop:
- Detects screen changes via an engine event handler that returns intents — not just observing the event, but driving the engine’s response
- Saves named profiles (layout, ratio, main count) like “docked” and “laptop”
- Intercepts layout changes to log transitions and show alerts
- Persists profiles to disk so they survive Hammerspoon reloads
- Exposes save/restore as actions and IPC commands
This is Shoji’s most powerful extensibility lever. Other tiling window managers let you observe events (“screen changed, here’s a signal”). Shoji lets you respond with intents that drive the engine’s behavior. Your extension doesn’t just react to screen changes — it tells the engine what to do next.
Step 1: Create the extension skeleton
Create ~/.hammerspoon/display-profiles.lua:
local displayProfiles = { name = "display-profiles",
setup = function(shoji) print("[display-profiles] loaded") end,}
return displayProfilesEvery extension is a table with a name (required string) and
optional setup and teardown functions. The setup function
receives the full Shoji instance, giving you access to state,
config, engine, layouts, and logger.
Register it in ~/.hammerspoon/init.lua:
hs.loadSpoon("Shoji")
spoon.Shoji:configure({ extensions = { require("display-profiles"), },})
spoon.Shoji:start()Reload Hammerspoon and check the console. You should see
[display-profiles] loaded. If you don’t, verify the file
path and that return displayProfiles is present.
Step 2: Define profile data
Add a data structure mapping profile names to layout settings. Hardcode two profiles to start:
local PROFILES_PATH = os.getenv("HOME") .. "/.hammerspoon/shoji-profiles.json"
local displayProfiles = { name = "display-profiles",
setup = function(shoji) displayProfiles._profiles = { docked = { layout = "tall", mainRatio = 0.65, mainCount = 1, }, laptop = { layout = "monocle", mainRatio = 0.5, mainCount = 1, }, }
print("[display-profiles] loaded with profiles: " .. "docked, laptop") end,}
return displayProfilesEach profile captures three values: the layout name, the main area ratio, and the number of main windows. These are the same parameters you’d set manually with hotkeys — the extension automates that.
Step 3: Detect screen changes with a hook
Before jumping to engine handlers, start with what you know:
a hook. In Guide 1 you used spoon.Shoji:on() — extensions
use the public extension API, shoji.ExtensionAPI.new(shoji),
which exposes api.registerHook() inside setup() after core
modules are initialized. Add a screen_changed hook that
prints the monitor count:
local PROFILES_PATH = os.getenv("HOME") .. "/.hammerspoon/shoji-profiles.json"
local displayProfiles = { name = "display-profiles",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji) displayProfiles._profiles = { docked = { layout = "tall", mainRatio = 0.65, mainCount = 1, }, laptop = { layout = "monocle", mainRatio = 0.5, mainCount = 1, }, }
local function onScreenChanged() local screenCount = #hs.screen.allScreens() print("[display-profiles] screens: " .. screenCount) end
displayProfiles._onScreenChanged = onScreenChanged displayProfiles._removeScreenHook = api.registerHook( "screen_changed", onScreenChanged ) end,}
return displayProfilesConnect or disconnect an external monitor to see it fire. The console should print the screen count each time.
This works for logging, but hooks are fire-and-forget side effects. They can’t tell the engine what to do. To actually drive Shoji’s behavior, you need an engine event handler.
Step 4: Upgrade to an engine event handler
Replace the hook with an engine event handler. This is the key
upgrade: instead of observing a screen_changed event, the
handler returns intents that the engine executes.
local Constants = require("constants")
local PROFILES_PATH = os.getenv("HOME") .. "/.hammerspoon/shoji-profiles.json"
local displayProfiles = { name = "display-profiles",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji) displayProfiles._profiles = { docked = { layout = "tall", mainRatio = 0.65, mainCount = 1, }, laptop = { layout = "monocle", mainRatio = 0.5, mainCount = 1, }, }
local function pickProfile() local screenCount = #hs.screen.allScreens() if screenCount >= 2 then return "docked", displayProfiles._profiles.docked end return "laptop", displayProfiles._profiles.laptop end
local function onScreenChanged(_event, _shoji) local profileName, profile = pickProfile()
local spaceID = hs.spaces.focusedSpace() return { { type = "set_layout", spaceID = spaceID, layoutName = profile.layout, }, { type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }, { type = "show_alert", message = "Profile: " .. profileName, }, } end
displayProfiles._onScreenChanged = onScreenChanged api.registerEventHandler("screen_changed", onScreenChanged) end,}
return displayProfilesThe handler returns three intents:
set_layout— switch to the profile’s layouttile_space— apply the change immediatelyshow_alert— tell the user which profile activated
The engine executes these in order, coalesces tile operations,
and — crucially — other extensions can react to the
set_layout intent. Compare this to the hook approach, where
the screen change was a private side effect that nothing else
could see.
Step 5: Apply profile settings
The set_layout intent changes the layout, but our profiles
also include mainRatio and mainCount. Update the handler
to set these before returning intents:
local function onScreenChanged(_event, shoji) local profileName, profile = pickProfile()
local spaceID = hs.spaces.focusedSpace() shoji.state:setMainRatio(spaceID, profile.mainRatio) shoji.state:setMainCount(spaceID, profile.mainCount)
return { { type = "set_layout", spaceID = spaceID, layoutName = profile.layout, }, { type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }, { type = "show_alert", message = "Profile: " .. profileName, }, }endSetting ratio and main count via shoji.state before returning
the tile_space intent is acceptable here because the tile
intent will immediately apply those values. The set_layout
intent still flows through the engine pipeline so other
extensions can react to it.
Step 6: Intercept intents
Engine intent handlers let you intercept, augment, or replace
existing intent execution. Register a handler for set_layout
that logs every layout transition:
local api = shoji.ExtensionAPI.new(shoji)
api.registerIntentHandler( "set_layout", function(intent, _shoji) print( "[display-profiles] layout -> " .. intent.layoutName )
-- Return additional intent to show an alert return { type = "show_alert", message = "Layout: " .. intent.layoutName, } end)This handler runs before the core set_layout execution.
By returning a show_alert intent, it chains an additional
action onto the pipeline. The core handler still runs because
we returned an intent (not true or { stop = true }).
Intent handlers can return different values to control flow:
| Return value | Effect |
|---|---|
nil | Pass through — core runs normally |
| Intent or intent array | Queue additional intents, core runs |
true | Mark as handled — skip core execution |
{ stop = true, handled = true } | Stop propagation AND skip core |
{ stop = true } | Stop other extension handlers, run core |
Step 7: Add file persistence
Lua state doesn’t survive Hammerspoon reloads. To keep profiles
across restarts, save them to disk with hs.json:
local function loadProfiles() local file = io.open(PROFILES_PATH, "r") if not file then return nil end
local content = file:read("*a") file:close()
local ok, decoded = pcall(hs.json.decode, content) if not ok or type(decoded) ~= "table" then return nil end
return decodedend
local function saveProfiles(profiles) local ok, encoded = pcall(hs.json.encode, profiles, true) if not ok then print("[display-profiles] save failed: " .. tostring(encoded)) return end
local file = io.open(PROFILES_PATH, "w") if not file then print("[display-profiles] cannot write: " .. PROFILES_PATH) return end
file:write(encoded) file:close()endUpdate setup to load saved profiles on startup, falling back
to the hardcoded defaults:
local saved = loadProfiles()if saved then displayProfiles._profiles = savedelse displayProfiles._profiles = { docked = { layout = "tall", mainRatio = 0.65, mainCount = 1, }, laptop = { layout = "monocle", mainRatio = 0.5, mainCount = 1, }, }endStep 8: Expose as actions
Register save_profile and restore_profile actions so
users can trigger them via hotkeys:
-- Inside setup():
local api = shoji.ExtensionAPI.new(shoji)displayProfiles._api = api
api.registerAction( "save_profile", function(shoji) local profileName = (#hs.screen.allScreens() >= 2) and "docked" or "laptop"
local spaceID = hs.spaces.focusedSpace() displayProfiles._profiles[profileName] = { layout = shoji.state:getSpaceLayout(spaceID), mainRatio = shoji.state:getMainRatio(spaceID), mainCount = shoji.state:getMainCount(spaceID), }
saveProfiles(displayProfiles._profiles) hs.alert.show("Saved profile: " .. profileName) end)
api.registerAction( "restore_profile", function(shoji) local profileName, profile = pickProfile()
local spaceID = hs.spaces.focusedSpace() shoji.state:setSpaceLayout(spaceID, profile.layout) shoji.state:setMainRatio(spaceID, profile.mainRatio) shoji.state:setMainCount(spaceID, profile.mainCount)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, })
hs.alert.show("Restored profile: " .. profileName) end)Bind them to hotkeys in your init.lua:
spoon.Shoji:bindHotkeys({ save_profile = { { "ctrl", "alt", "shift" }, "s" }, restore_profile = { { "ctrl", "alt", "shift" }, "r" },})Action handlers receive (shoji) and can mutate state
directly — they are user-initiated one-shot operations, not
part of the engine’s event pipeline. After mutating state,
dispatch a tile_space intent to apply the changes.
Step 9: Expose as IPC commands
Register an IPC command so shell scripts can query and switch profiles:
-- Inside setup():
local api = displayProfiles._api
api.registerCommand( "profiles", function(shoji, args) if not args or not args[1] then local names = {} for name in pairs(displayProfiles._profiles) do table.insert(names, name) end table.sort(names) return { success = true, data = { profiles = names }, } end
local profileName = args[1] local profile = displayProfiles._profiles[profileName] if not profile then return { success = false, error = "unknown profile: " .. profileName, } end
local spaceID = hs.spaces.focusedSpace() shoji.state:setSpaceLayout(spaceID, profile.layout) shoji.state:setMainRatio(spaceID, profile.mainRatio) shoji.state:setMainCount(spaceID, profile.mainCount)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, })
return { success = true, data = { applied = profileName }, } end)Test from the terminal:
# List profileshs -c 'return hs.json.encode(spoon.Shoji:cmd("profiles"))'
# Apply a profilehs -c 'return hs.json.encode(spoon.Shoji:cmd("profiles", "docked"))'IPC handlers receive (shoji, args) and must return an
IPCResult table with success and either data or error.
The success() and failure() helpers are internal to Shoji,
so construct the response table yourself.
Step 10: Write teardown
Teardown must undo everything setup created. Shoji calls
teardown in reverse load order — if extension B depends on
A, load A first so it tears down last.
teardown = function() local api = displayProfiles._api if api then -- Unregister actions api.unregisterAction("save_profile") api.unregisterAction("restore_profile")
-- Unregister IPC commands api.unregisterCommand("profiles") end
local removeHook = displayProfiles._removeScreenHook if type(removeHook) == "function" then removeHook() end
-- Clean up stored state displayProfiles._profiles = nil displayProfiles._onScreenChanged = nil displayProfiles._api = nilend,Test teardown by stopping and restarting Shoji in the console:
spoon.Shoji:stop()spoon.Shoji:start()Verify the profiles command and actions work after restart. Check that no stale handlers fire by watching the console for duplicate log messages.
Complete code
Here’s the finished extension:
-- ~/.hammerspoon/display-profiles.lua
local Constants = require("constants")
local PROFILES_PATH = os.getenv("HOME") .. "/.hammerspoon/shoji-profiles.json"
local function loadProfiles() local file = io.open(PROFILES_PATH, "r") if not file then return nil end
local content = file:read("*a") file:close()
local ok, decoded = pcall(hs.json.decode, content) if not ok or type(decoded) ~= "table" then return nil end
return decodedend
local function saveProfiles(profiles) local ok, encoded = pcall(hs.json.encode, profiles, true) if not ok then print( "[display-profiles] save failed: " .. tostring(encoded) ) return end
local file = io.open(PROFILES_PATH, "w") if not file then print( "[display-profiles] cannot write: " .. PROFILES_PATH ) return end
file:write(encoded) file:close()end
local DEFAULT_PROFILES = { docked = { layout = "tall", mainRatio = 0.65, mainCount = 1, }, laptop = { layout = "monocle", mainRatio = 0.5, mainCount = 1, },}
local displayProfiles = { name = "display-profiles",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji) displayProfiles._api = api
-- Load saved profiles or use defaults displayProfiles._profiles = loadProfiles() or DEFAULT_PROFILES
-- Pick profile based on screen count local function pickProfile() local screenCount = #hs.screen.allScreens() if screenCount >= 2 then return "docked", displayProfiles._profiles.docked end return "laptop", displayProfiles._profiles.laptop end
-- Engine event handler: return intents on -- screen change local function onScreenChanged(_event, shoji) local profileName, profile = pickProfile() if not profile then return nil end
local spaceID = hs.spaces.focusedSpace() shoji.state:setMainRatio( spaceID, profile.mainRatio ) shoji.state:setMainCount( spaceID, profile.mainCount )
return { { type = "set_layout", spaceID = spaceID, layoutName = profile.layout, }, { type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }, { type = "show_alert", message = "Profile: " .. profileName, }, } end
displayProfiles._onScreenChanged = onScreenChanged api.registerEventHandler("screen_changed", onScreenChanged)
-- Intent handler: log layout transitions api.registerIntentHandler( "set_layout", function(intent, _shoji) print( "[display-profiles] layout -> " .. intent.layoutName ) return nil end )
-- Action: save current settings as a profile api.registerAction( "save_profile", function(shoji) local profileName = (#hs.screen.allScreens() >= 2) and "docked" or "laptop"
local spaceID = hs.spaces.focusedSpace() displayProfiles._profiles[profileName] = { layout = shoji.state:getSpaceLayout(spaceID), mainRatio = shoji.state:getMainRatio(spaceID), mainCount = shoji.state:getMainCount(spaceID), }
saveProfiles(displayProfiles._profiles) hs.alert.show( "Saved profile: " .. profileName ) end )
-- Action: restore profile for current screen config api.registerAction( "restore_profile", function(shoji) local profileName, profile = pickProfile() if not profile then return end
local spaceID = hs.spaces.focusedSpace() shoji.state:setSpaceLayout( spaceID, profile.layout ) shoji.state:setMainRatio( spaceID, profile.mainRatio ) shoji.state:setMainCount( spaceID, profile.mainCount )
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, })
hs.alert.show( "Restored profile: " .. profileName ) end )
-- IPC command: list or apply profiles api.registerCommand( "profiles", function(shoji, args) if not args or not args[1] then local names = {} for name in pairs(displayProfiles._profiles) do table.insert(names, name) end table.sort(names) return { success = true, data = { profiles = names }, } end
local profileName = args[1] local profile = displayProfiles._profiles[profileName] if not profile then return { success = false, error = "unknown profile: " .. profileName, } end
local spaceID = hs.spaces.focusedSpace() shoji.state:setSpaceLayout( spaceID, profile.layout ) shoji.state:setMainRatio( spaceID, profile.mainRatio ) shoji.state:setMainCount( spaceID, profile.mainCount )
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, })
return { success = true, data = { applied = profileName }, } end ) end,
teardown = function() local api = displayProfiles._api if api then api.unregisterAction("save_profile") api.unregisterAction("restore_profile") api.unregisterCommand("profiles") end displayProfiles._profiles = nil displayProfiles._onScreenChanged = nil displayProfiles._api = nil end,}
return displayProfilesWhen to use what
The extensibility levels serve different purposes. Choose the simplest one that fits your needs:
Config hooks — simple observation, no cleanup needed. Use when you want to log events or react with a one-liner.
hooks = { layout_changed = function(spaceID, layout) print("Layout: " .. layout) end,}Runtime hooks via :on() — dynamic registration after
startup. Use when you need to toggle monitoring on and off.
local unregister = spoon.Shoji:on( "window_created", function(windowID) ... end)-- Later: unregister()Engine event handlers — return intents that drive engine behavior. Use when your extension needs to cause actions in response to events, not just observe them. This is the key upgrade from hooks.
local api = shoji.ExtensionAPI.new(shoji)
api.registerEventHandler( "screen_changed", function(event, shoji) return { type = "set_layout", layoutName = "tall" } end)Engine intent handlers — intercept, augment, or replace existing intent execution. Use when you need to react to what the engine is about to do, not to the original event.
local api = shoji.ExtensionAPI.new(shoji)
api.registerIntentHandler( "set_layout", function(intent, shoji) print("Switching to: " .. intent.layoutName) return nil -- let core run end)Full extensions — lifecycle management with setup and teardown. Use when you need to register multiple handlers across hooks, engine, actions, and IPC, or when you manage persistent state that requires cleanup.
The decision comes down to one question: does your code need to tell the engine what to do, or just observe what happened? If it needs to drive behavior, use engine handlers. If it just needs to react, hooks are simpler.
Handler API summary
Quick reference for the two engine handler types.
Event handlers
Registered via api.registerEventHandler(eventType, handler),
where api comes from shoji.ExtensionAPI.new(shoji).
| Property | Value |
|---|---|
| Signature | fun(event: EngineEvent, shoji: Shoji) |
| Returns | EngineIntent, EngineIntent[], or nil |
| Purpose | React to system/window events by returning intents |
| Timing | Runs before intent execution |
Event types match hook names: screen_changed,
window_created, window_destroyed, window_focused,
window_moved, window_visible, window_hidden,
space_changed.
Intent handlers
Registered via api.registerIntentHandler(intentType, handler),
where api comes from shoji.ExtensionAPI.new(shoji).
| Property | Value |
|---|---|
| Signature | fun(intent: EngineIntent, shoji: Shoji) |
| Returns | See control flow table below |
| Purpose | Intercept, augment, or replace intent execution |
| Timing | Runs before core handler for the intent type |
Return value control flow:
| Return value | Effect |
|---|---|
nil | Pass through — core runs |
| Intent or intent array | Queue intents, core runs |
true | Handled — skip core execution |
{ stop = true, handled = true } | Stop propagation + skip core |
{ stop = true } | Stop other handlers, core runs |
Registration timing
Handlers can be registered in setup() before the engine
is created. Shoji queues these registrations internally and
applies them when the engine initializes. No special
handling is needed — api.registerEventHandler and
api.registerIntentHandler work at any point during setup.
What you learned
- Extensions have a
name,setup(shoji), andteardown() - Engine event handlers return intents that drive behavior
- Engine intent handlers intercept intents before core runs
hs.json.encode/decodepersist state across reloads- Actions and IPC commands extend Shoji’s surface area
- Teardown must undo everything setup created
Common mistakes
Returning true when you meant to pass through:
In an intent handler, returning true marks the intent as
handled and skips core execution. If you return true from a
set_layout handler, the layout never changes. Return nil
to let core run, or return additional intents to chain.
Confusing event handlers with intent handlers:
Event handlers react to what happened (screen changed,
window created) and return intents. Intent handlers react to
what the engine is about to do (set layout, tile space) and
can intercept or augment that action. They have different
signatures: events get (event, shoji), intents get
(intent, shoji).
Mutating state directly in engine event handlers:
Setting shoji.state:setSpaceLayout() in an event handler
works, but bypasses the intent pipeline. Other extensions
can’t react to a layout change they never saw. Return a
set_layout intent instead, and use direct state mutation
only for values that don’t have corresponding intents (like
mainRatio and mainCount).
Forgetting to unregister actions in teardown:
If teardown doesn’t call api.unregisterAction(),
restarting Shoji produces “action already registered” errors
because built-in action cleanup doesn’t cover custom actions
from extensions that skip teardown.
Not testing teardown:
Stop Shoji (spoon.Shoji:stop()), verify the extension’s
side effects are gone (no console output from handlers, no
stale IPC commands), then restart. Many bugs hide in teardown.
Common errors
“extension must have a non-empty string ‘name’ field”:
Your extension table is missing the name field or it’s not
a string. Every extension needs name = "something".
“action already registered: save_profile”: You restarted
Shoji without proper teardown, or the extension is registered
twice. Check that teardown calls api.unregisterAction and that
the extension isn’t listed twice in extensions = { ... }.
“Shoji is not started”: You called an IPC command via
hs -c before spoon.Shoji:start() completed. Make sure
Shoji is started before using IPC.
“Unknown command: profiles”: The extension hasn’t loaded
yet or teardown removed the command. Check that the extension
is in your config and that setup ran successfully (look for
the load message in the console).
Challenge
Add a window_created engine event handler that checks the
new window’s app against a table of per-app layout preferences
(e.g., Terminal -> "tall", Safari -> "monocle"). If the
app has a preference and the current layout differs, return a
set_layout intent and a tile_space intent. This
demonstrates app-aware layout switching driven entirely through
the intent pipeline.
Hint: use hs.window.get(event.windowID) to look up the
window, then :application():bundleID() to identify the app.
Next steps
You’ve completed the Extending Shoji series. You now have the tools to build any behavior on top of Shoji’s intent-driven engine.
For reference material and more examples:
- Extensions recipe — complete extension patterns (menu bar status, focus flash, display presets)
- Hooks reference — all hook types and their signatures
- Architecture overview — how the engine, intents, and modules connect