Skip to content

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)

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 = 4
local 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.