Skip to content

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 displayProfiles

Every 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 displayProfiles

Each 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 displayProfiles

Connect 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 displayProfiles

The handler returns three intents:

  1. set_layout — switch to the profile’s layout
  2. tile_space — apply the change immediately
  3. show_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,
},
}
end

Setting 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 valueEffect
nilPass through — core runs normally
Intent or intent arrayQueue additional intents, core runs
trueMark 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 decoded
end
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

Update setup to load saved profiles on startup, falling back to the hardcoded defaults:

local saved = loadProfiles()
if saved then
displayProfiles._profiles = saved
else
displayProfiles._profiles = {
docked = {
layout = "tall",
mainRatio = 0.65,
mainCount = 1,
},
laptop = {
layout = "monocle",
mainRatio = 0.5,
mainCount = 1,
},
}
end

Step 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:

Terminal window
# List profiles
hs -c 'return hs.json.encode(spoon.Shoji:cmd("profiles"))'
# Apply a profile
hs -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 = nil
end,

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 decoded
end
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 displayProfiles

When 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).

PropertyValue
Signaturefun(event: EngineEvent, shoji: Shoji)
ReturnsEngineIntent, EngineIntent[], or nil
PurposeReact to system/window events by returning intents
TimingRuns 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).

PropertyValue
Signaturefun(intent: EngineIntent, shoji: Shoji)
ReturnsSee control flow table below
PurposeIntercept, augment, or replace intent execution
TimingRuns before core handler for the intent type

Return value control flow:

Return valueEffect
nilPass through — core runs
Intent or intent arrayQueue intents, core runs
trueHandled — 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), and teardown()
  • Engine event handlers return intents that drive behavior
  • Engine intent handlers intercept intents before core runs
  • hs.json.encode/decode persist 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: