Guide 3: Custom actions and commands
You will learn
- How to register custom actions with
Actions.registerAction() - How to bind custom actions to hotkeys
- How to register custom IPC commands with
IPC.registerCommand() - The difference between action and IPC handler signatures
- How to compose built-in operations into higher-level actions
- How to control Shoji from the command line
Prerequisites
Complete Guide 1: Reacting to events with hooks first. This guide builds on the state manipulation concepts introduced there. Guide 2 is recommended but not required.
What we’re building
Three things that extend Shoji’s surface area:
- A “focus or launch” action — press one key to focus an app’s window or launch it if not running
- A “workspace preset” action — set layout, ratio, and main count in one keypress (e.g., “coding mode”: tall, 65%, 1 main)
- A custom IPC command — external scripts query a tailored status summary from the shell
Step 1: Call a built-in action
Before writing custom actions, try the built-in ones. Open the Hammerspoon console and run:
local Actions = require("actions")Actions.executeAction("focus_left")executeAction looks up the action by name and calls it.
It returns true if the action was found, false
otherwise:
local found = Actions.executeAction("nonexistent")print(found) -- falseActions are just functions. The registry maps names to handlers, and hotkeys call those handlers when pressed.
Step 2: Register a minimal custom action
Add a custom action in your ~/.hammerspoon/init.lua,
after spoon.Shoji:start():
local Actions = require("actions")
local ok, err = Actions.registerAction( "hello", function(_shoji) hs.alert.show("Hello from Shoji!") end)
if not ok then print("Registration failed: " .. err)endReload Hammerspoon and test from the console:
local Actions = require("actions")Actions.executeAction("hello")You should see an alert on screen.
registerAction returns two values: a boolean indicating
success, and an error string on failure. It rejects
duplicate names:
-- Second registration failslocal ok, err = Actions.registerAction("hello", fn)print(ok, err) -- false, "action already registered: hello"Step 3: Build “focus or launch”
The most requested feature from tiling WM users: press one key to focus an app, or launch it if not running.
Replace the hello action with this:
local Actions = require("actions")
local function focusOrLaunch(bundleID) return function(_shoji) local app = hs.application.get(bundleID) if app then local win = app:mainWindow() if win then win:focus() return end end hs.application.launchOrFocusByBundleID(bundleID) endend
Actions.registerAction( "focus_terminal", focusOrLaunch("com.apple.Terminal"))focusOrLaunch is a factory function. It captures the
bundleID in a closure and returns an action handler.
Register one action per app:
Actions.registerAction( "focus_browser", focusOrLaunch("com.apple.Safari"))
Actions.registerAction( "focus_editor", focusOrLaunch("com.microsoft.VSCode"))Step 4: Bind it to a hotkey
Custom actions are bound the same way as built-in ones:
spoon.Shoji:bindHotkeys({ focus_terminal = { { "ctrl", "alt" }, "t" }, focus_browser = { { "ctrl", "alt" }, "b" }, focus_editor = { { "ctrl", "alt" }, "e" },})Press Ctrl+Alt+T to focus or launch Terminal.
Step 5: Build a workspace preset
A workspace preset sets layout, ratio, and main count in one keypress. This requires direct state mutation followed by a retile.
local Actions = require("actions")local Constants = require("constants")
Actions.registerAction( "preset_coding", function(shoji) local spaceID = hs.spaces.focusedSpace() if not spaceID then return end
shoji.state:setSpaceLayout(spaceID, "tall") shoji.state:setMainRatio(spaceID, 0.65) shoji.state:setMainCount(spaceID, 1)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }) end)The three state:set* calls change internal state, but
nothing visible happens until the engine retiles the
space. That is why the final dispatchIntent with
tile_space is essential — it tells the engine to
apply the changes now.
Add a second preset for reading:
Actions.registerAction( "preset_reading", function(shoji) local spaceID = hs.spaces.focusedSpace() if not spaceID then return end
shoji.state:setSpaceLayout(spaceID, "monocle") shoji.state:setMainRatio(spaceID, 0.5) shoji.state:setMainCount(spaceID, 1)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }) end)Bind both:
spoon.Shoji:bindHotkeys({ preset_coding = { { "ctrl", "alt" }, "1" }, preset_reading = { { "ctrl", "alt" }, "2" },})Step 6: Enable IPC
IPC (inter-process communication) lets you control Shoji
from the shell. First, enable the hs command-line tool
by adding this near the top of your
~/.hammerspoon/init.lua:
require("hs.ipc")If hs is not in your PATH, run hs.ipc.cliInstall()
in the Hammerspoon console once. Verify it works:
hs -c 'return "hello"'# helloTry a built-in Shoji command:
hs -c 'spoon.Shoji:cmd("status")'This returns a table with the current space’s layout, ratio, main count, and window count.
Step 7: Register a custom IPC command
IPC handlers have a different signature from action handlers. Here is the contrast:
| Actions | IPC commands | |
|---|---|---|
| Signature | fun(shoji) | fun(shoji, args) |
| Returns | nothing | IPCResult table |
| Invoked by | hotkeys, Lua | shell, Lua |
| Arguments | none | string array |
Register a custom command that returns a workspace summary:
local IPC = require("ipc")
IPC.registerCommand( "workspace-summary", function(shoji, _args) local spaceID = hs.spaces.focusedSpace() if not spaceID then return { success = false, error = "No focused space", } end
local layout = shoji.state:getSpaceLayout(spaceID) local ratio = shoji.state:getMainRatio(spaceID) local count = shoji.state:getMainCount(spaceID) local windows = shoji.state:getWindowOrder(spaceID)
return { success = true, data = { space = spaceID, layout = layout, ratio = string.format("%.0f%%", ratio * 100), mainCount = count, windowCount = #windows, summary = string.format( "%s (%d windows, %s main)", layout, #windows, tostring(count) ), }, } end)IPC handlers must return an IPCResult table. On
success, return { success = true, data = { ... } }.
On failure, return { success = false, error = "..." }.
Forgetting to return a table causes cryptic nil errors
on the calling side.
Like registerAction, registerCommand rejects
duplicates:
local ok, err = IPC.registerCommand("workspace-summary", fn)-- false, "command already registered: workspace-summary"Step 8: Call from the shell
Reload Hammerspoon, then test from your terminal:
hs -c 'spoon.Shoji:cmd("workspace-summary")'You should see output like:
{ success = true, data = { layout = "tall", mainCount = 1, ratio = "65%", space = 1, summary = "tall (3 windows, 1 main)", windowCount = 3 }}Extract specific fields in a script:
hs -c 'return spoon.Shoji:cmd("workspace-summary").data.summary'# tall (3 windows, 1 main)Step 9: Discover available commands
The built-in list-commands command shows all commands,
including your custom one:
hs -c 'spoon.Shoji:cmd("list-commands")'The response includes all registered commands sorted
alphabetically. Your workspace-summary appears
alongside built-in commands like status, set-layout,
and focus.
Step 10: Clean up
Actions and IPC commands persist until Shoji restarts. For proper lifecycle management (important in extensions), unregister them when they are no longer needed:
local Actions = require("actions")local IPC = require("ipc")
Actions.unregisterAction("hello")IPC.unregisterCommand("workspace-summary")Both return boolean, string|nil — the same pattern as
registration. You cannot unregister built-in actions or
commands:
local ok, err = Actions.unregisterAction("focus_left")-- false, "cannot unregister built-in action: focus_left"Complete code
Here is the full ~/.hammerspoon/init.lua with all
three features:
-- ~/.hammerspoon/init.lua
require("hs.ipc")hs.loadSpoon("Shoji")
spoon.Shoji:configure({ enabled_layouts = { "tall", "wide", "monocle" },})
spoon.Shoji:start()
-- ============================================================-- Custom actions-- ============================================================
local Actions = require("actions")local Constants = require("constants")local IPC = require("ipc")
-- Focus or launch factorylocal function focusOrLaunch(bundleID) return function(_shoji) local app = hs.application.get(bundleID) if app then local win = app:mainWindow() if win then win:focus() return end end hs.application.launchOrFocusByBundleID(bundleID) endend
Actions.registerAction( "focus_terminal", focusOrLaunch("com.apple.Terminal"))
Actions.registerAction( "focus_browser", focusOrLaunch("com.apple.Safari"))
-- Workspace presetsActions.registerAction( "preset_coding", function(shoji) local spaceID = hs.spaces.focusedSpace() if not spaceID then return end
shoji.state:setSpaceLayout(spaceID, "tall") shoji.state:setMainRatio(spaceID, 0.65) shoji.state:setMainCount(spaceID, 1)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }) end)
Actions.registerAction( "preset_reading", function(shoji) local spaceID = hs.spaces.focusedSpace() if not spaceID then return end
shoji.state:setSpaceLayout(spaceID, "monocle") shoji.state:setMainRatio(spaceID, 0.5) shoji.state:setMainCount(spaceID, 1)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, }) end)
-- ============================================================-- Custom IPC command-- ============================================================
IPC.registerCommand( "workspace-summary", function(shoji, _args) local spaceID = hs.spaces.focusedSpace() if not spaceID then return { success = false, error = "No focused space", } end
local layout = shoji.state:getSpaceLayout(spaceID) local ratio = shoji.state:getMainRatio(spaceID) local count = shoji.state:getMainCount(spaceID) local windows = shoji.state:getWindowOrder(spaceID)
return { success = true, data = { space = spaceID, layout = layout, ratio = string.format( "%.0f%%", ratio * 100 ), mainCount = count, windowCount = #windows, summary = string.format( "%s (%d windows, %s main)", layout, #windows, tostring(count) ), }, } end)
-- ============================================================-- Hotkey bindings-- ============================================================
spoon.Shoji:bindHotkeys({ focus_terminal = { { "ctrl", "alt" }, "t" }, focus_browser = { { "ctrl", "alt" }, "b" }, preset_coding = { { "ctrl", "alt" }, "1" }, preset_reading = { { "ctrl", "alt" }, "2" },})What you learned
Actions.registerAction(name, handler)registers a custom action with afun(shoji): nilhandler- Custom actions bind to hotkeys the same way as built-in ones
IPC.registerCommand(name, handler)registers a command with afun(shoji, args): IPCResulthandler- IPC handlers must return
{ success = true, data = {...} }or{ success = false, error = "..." } - After direct state mutation, dispatch a
tile_spaceintent to apply changes - Both APIs reject duplicate names and return
boolean, string|nil
Common mistakes
Forgetting the shoji parameter in action handlers:
The handler signature is fun(shoji): nil. If you write
function() ... end instead of
function(shoji) ... end, you cannot access state or
the engine inside the handler. Use _shoji if you do
not need it.
Not returning an IPCResult from IPC handlers:
Action handlers return nothing, but IPC handlers must
return a table with success and either data or
error. Forgetting the return causes the hs CLI to
print nil and callers cannot check result.success.
Not dispatching a retile after state manipulation:
Calling state:setSpaceLayout() or
state:setMainRatio() changes internal state but does
not trigger a retile. Always dispatch a tile_space
intent after direct state mutations to apply the changes
visually.
Calling bindHotkeys before registerAction:
Hotkey binding resolves action names at bind time. If
the action is not registered yet, the key binds to
nothing. Register actions first, then bind hotkeys.
Common errors
“action already registered: my_action”: You called
registerAction with a name that already exists.
This happens when reloading Hammerspoon without Shoji
restarting first. Custom actions registered after
start() are not automatically cleaned up on reload.
Stop and start Shoji, or call
Actions.unregisterAction() before re-registering.
“Shoji is not started”: You called
hs -c 'spoon.Shoji:cmd(...)' but Shoji has not been
started. Make sure spoon.Shoji:start() runs in your
init.lua before using IPC.
“command handler must be a function”: You passed
something other than a function to
IPC.registerCommand(). Check that the second argument
is a function, not the result of calling a function.
“Unknown command: workspace-summary”: The command was
not registered. Verify that IPC.registerCommand()
runs after spoon.Shoji:start() and that the
registration succeeded (check the return value).
Challenge
Build a “rotate preset” action that cycles through three workspace presets on each keypress: coding, reading, and presenting. Use a closure to track the current index:
local function makePresetRotation(presets) local index = 1
return function(shoji) local preset = presets[index] local spaceID = hs.spaces.focusedSpace() if not spaceID then return end
shoji.state:setSpaceLayout(spaceID, preset.layout) shoji.state:setMainRatio(spaceID, preset.ratio) shoji.state:setMainCount(spaceID, preset.count)
shoji.engine:dispatchIntent({ type = "tile_space", spaceID = spaceID, priority = Constants.TILE_PRIORITY_IMMEDIATE, })
hs.alert.show("Preset: " .. preset.name) index = (index % #presets) + 1 endendDefine your presets and register the action:
local presets = { { name = "Coding", layout = "tall", ratio = 0.65, count = 1 }, { name = "Reading", layout = "monocle", ratio = 0.5, count = 1 }, { name = "Presenting", layout = "wide", ratio = 0.7, count = 1 },}
Actions.registerAction( "rotate_preset", makePresetRotation(presets))Each keypress advances to the next preset, wrapping back to the first after the last.
Next steps
In Guide 4: Building an extension, you will combine hooks, actions, IPC commands, and engine handlers into a full extension with lifecycle management. You will build a “display profiles” extension that automatically configures Shoji when you dock or undock your laptop.
Further reading:
- Actions reference — full list of built-in actions and defaults
- IPC reference — all built-in commands and scripting examples
- Scripting recipes — shell scripts and tmux integration