Skip to content

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:

  1. A “focus or launch” action — press one key to focus an app’s window or launch it if not running
  2. A “workspace preset” action — set layout, ratio, and main count in one keypress (e.g., “coding mode”: tall, 65%, 1 main)
  3. 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) -- false

Actions 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)
end

Reload 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 fails
local 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)
end
end
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:

Terminal window
hs -c 'return "hello"'
# hello

Try a built-in Shoji command:

Terminal window
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:

ActionsIPC commands
Signaturefun(shoji)fun(shoji, args)
ReturnsnothingIPCResult table
Invoked byhotkeys, Luashell, Lua
Argumentsnonestring 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:

Terminal window
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:

Terminal window
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:

Terminal window
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 factory
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)
end
end
Actions.registerAction(
"focus_terminal",
focusOrLaunch("com.apple.Terminal")
)
Actions.registerAction(
"focus_browser",
focusOrLaunch("com.apple.Safari")
)
-- Workspace presets
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
)
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 a fun(shoji): nil handler
  • Custom actions bind to hotkeys the same way as built-in ones
  • IPC.registerCommand(name, handler) registers a command with a fun(shoji, args): IPCResult handler
  • IPC handlers must return { success = true, data = {...} } or { success = false, error = "..." }
  • After direct state mutation, dispatch a tile_space intent 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
end
end

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