Extensions
Extensions add features to Shoji without modifying core. They hook into lifecycle events, register actions, and access state through the Shoji instance.
Extension anatomy
Every extension is a table with a name and optional setup
and teardown functions:
local myExtension = { name = "my-extension",
setup = function(shoji) -- Called when Shoji starts. Use `shoji` to access -- state, config, and modules. end,
teardown = function() -- Called when Shoji stops. Clean up timers, -- canvases, menubars, and hooks here. end,}Register extensions in your config:
spoon.Shoji:configure({ extensions = { myExtension },})Public extension API
Use the public ExtensionAPI to register hooks, actions,
commands, and engine handlers without requiring internal
modules. The API is available on the Shoji instance inside
setup().
local statusExt = { name = "status-ext",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji)
api.registerHook("space_changed", function() print("[status-ext] space changed") end)
api.registerAction("status_ping", function(_shoji) hs.alert.show("pong") end) end,}Engine events and intents
Extensions can hook directly into the engine with events and intents.
Event handlers return intents for the engine to execute. Intent handlers can
return true to mark an intent as handled, or return more intents to queue.
local Constants = require("constants")
local engineHooks = { name = "engine-hooks",
events = { window_created = function(event, _shoji) if not event.spaces then return nil end
local intents = {} for _, spaceID in ipairs(event.spaces) do table.insert(intents, { type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_DEBOUNCED, }) end return intents end, },
intents = { show_alert = function(intent, _shoji) hs.alert.show("[Shoji] " .. intent.message) return true end, },}Stable engine API
Extensions can rely on a small, stable engine surface through
ExtensionAPI:
api.registerEventHandler(type, handler)api.registerIntentHandler(type, handler)shoji.engine:dispatch(event)shoji.engine:dispatchIntent(intent)
Handlers should return intents instead of mutating shoji.state directly.
local Constants = require("constants")
local engineApi = { name = "engine-api",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji)
api.registerEventHandler("space_changed", function(event) return { type = "tile_space", spaceID = event.spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, } end) end,}Intent semantics
Core intents are validated against intent_registry.lua. Missing required
fields are logged and the intent is skipped. Unknown intent types are allowed
for extensions.
Intents should be idempotent: repeated execution should not break state.
Side effects belong in intent execution (engine or executor), not in event
handlers. Event handlers should build intents, not mutate shoji.state
directly.
Short-circuiting intent handlers
Intent handlers can stop further handler execution by returning
{ stop = true }. To also prevent core execution, include
handled = true.
local api = shoji.ExtensionAPI.new(shoji)
api.registerIntentHandler("cycle_layout", function(_intent) return { stop = true, handled = true }end)To stop other extensions but still run the core handler:
local api = shoji.ExtensionAPI.new(shoji)
api.registerIntentHandler("cycle_layout", function(_intent) return { stop = true }end)Menu bar status indicator
Display the current layout name in the menu bar, updated on layout or space change. Click opens the command palette.
local menubarStatus = { name = "menubar-status",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji) local menubar = hs.menubar.new()
local function update() local spaceID = hs.spaces.focusedSpace() local layout = shoji.state:getSpaceLayout(spaceID) menubar:setTitle(layout) end
menubar:setClickCallback(function() api.executeAction("open_command_palette") end)
-- Store references for teardown menubarStatus._menubar = menubar menubarStatus._removeLayoutHook = api.registerHook( "layout_changed", update ) menubarStatus._removeSpaceHook = api.registerHook( "space_changed", update ) update() end,
teardown = function() local removeLayout = menubarStatus._removeLayoutHook if type(removeLayout) == "function" then removeLayout() end
local removeSpace = menubarStatus._removeSpaceHook if type(removeSpace) == "function" then removeSpace() end
if menubarStatus._menubar then menubarStatus._menubar:delete() menubarStatus._menubar = nil end end,}Focus indicator flash
Flash a colored border around a window when it receives focus.
local BORDER_COLOR = { red = 0.4, green = 0.7, blue = 1.0, alpha = 0.8 }local BORDER_WIDTH = 4local FLASH_DURATION = 0.2
local focusFlash = { name = "focus-flash",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji) local canvas = nil local timer = nil
local function flash(windowID) -- Clean up previous flash if canvas then canvas:delete() canvas = nil end if timer then timer:stop() timer = nil end
local win = hs.window.find(windowID) if not win then return end
local f = win:frame() canvas = hs.canvas.new({ x = f.x - BORDER_WIDTH, y = f.y - BORDER_WIDTH, w = f.w + BORDER_WIDTH * 2, h = f.h + BORDER_WIDTH * 2, }) canvas:appendElements({ { type = "rectangle", action = "stroke", strokeColor = BORDER_COLOR, strokeWidth = BORDER_WIDTH, roundedRectRadii = { xRadius = 8, yRadius = 8 }, }, }) canvas:level(hs.canvas.windowLevels.overlay) canvas:show()
timer = hs.timer.doAfter(FLASH_DURATION, function() if canvas then canvas:delete() canvas = nil end end) end
focusFlash._flash = flash focusFlash._canvas = function() return canvas end focusFlash._timer = function() return timer end focusFlash._removeFocusHook = api.registerHook( "window_focused", flash ) end,
teardown = function() local removeHook = focusFlash._removeFocusHook if type(removeHook) == "function" then removeHook() end local canvas = focusFlash._canvas and focusFlash._canvas() if canvas then canvas:delete() end local timer = focusFlash._timer and focusFlash._timer() if timer then timer:stop() end end,}Display-aware presets
Automatically switch layouts based on monitor count: BSP when
docked (2+ screens), Tall when laptop-only. Uses
screen_changed to detect monitor changes and
space_initialized to set defaults for new spaces.
local displayPresets = { name = "display-presets",
setup = function(shoji) local api = shoji.ExtensionAPI.new(shoji)
local function layoutForScreenCount() local screens = hs.screen.allScreens() if #screens >= 2 then return "bsp" end return "tall" end
local function applyPresets() local layout = layoutForScreenCount() local allSpaces = hs.spaces.allSpaces() for _, spaces in pairs(allSpaces) do for _, spaceID in ipairs(spaces) do shoji.state:setSpaceLayout(spaceID, layout) end end shoji.engine:dispatchIntent({ type = "retile_all" }) end
local function initSpace(spaceID) local layout = layoutForScreenCount() shoji.state:setSpaceLayout(spaceID, layout) end
displayPresets._applyPresets = applyPresets displayPresets._initSpace = initSpace displayPresets._removeScreenHook = api.registerHook( "screen_changed", applyPresets ) displayPresets._removeInitHook = api.registerHook( "space_initialized", initSpace ) end,
teardown = function() local removeScreen = displayPresets._removeScreenHook if type(removeScreen) == "function" then removeScreen() end
local removeInit = displayPresets._removeInitHook if type(removeInit) == "function" then removeInit() end end,}Teardown checklist
Extensions must undo everything setup() created. Common
items to clean up in teardown():
- Hooks: Call the unregister function returned by
api.registerHook(...) - Timers: Stop with
timer:stop() - Canvas objects: Delete with
canvas:delete() - Menubar items: Delete with
menubar:delete() - Hotkeys: Delete with
hotkey:delete()
Keep references to all created resources so teardown() can
find and remove them.