Spoons
GuideSpoons are packaged, reusable pieces of Hammerspoon configuration — a way to install someone
else's automation (or share your own) without copy-pasting code into init.js. This guide
covers installing and using a Spoon first, then how to write one, with a complete worked
example. If you haven't read the Getting Started guide yet, especially
its section on object lifecycle
and splitting a config across multiple files,
start there — everything below builds on it.
This part of Hammerspoon 2 is newer and smaller in scope than the rest of the app. One piece of
Hammerspoon 1 lifecycle is built in - init() is called automatically, same as v1's :init()
- but
start()/stop()andbindHotkeys()below are recommended patterns for Spoon authors to follow, not things the runtime calls for you.
Installing a Spoon
A Spoon is distributed as a .spoon2 bundle — a folder that macOS treats as a single
double-clickable item, the same way it treats .app. Double-clicking one (or dragging it onto
the Hammerspoon 2 icon) installs it: Hammerspoon 2 checks that it's well-formed, copies it into
Spoons/ inside your config directory (typically ~/.config/Hammerspoon2/Spoons/), and
confirms with a dialog once it's done. If a Spoon with the same name is already installed, you're
asked to confirm before it's replaced — the dialog shows both the currently-installed and the
new version so you can tell what's changing.
Installing a Spoon doesn't load it. Loading is an explicit step in your init.js, covered next.
Using a Spoon
Call hs.loadSpoon("Name"), matching the Spoon's directory name under Spoons/:
const Greeter = hs.loadSpoon("Greeter")
This throws if the Spoon is missing or malformed, so wrap it in a try/catch if you want your
config to keep going without it rather than fail to load entirely:
try {
const Greeter = hs.loadSpoon("Greeter")
Greeter.start()
} catch (e) {
console.error(`Couldn't load Greeter: ${e.message}`)
}
loadSpoon() also stores whatever it returns on hs.spoons, keyed by name, so anywhere else in
your config can reach an already-loaded Spoon without holding onto the value yourself:
hs.loadSpoon("Greeter")
// ...elsewhere...
hs.spoons.Greeter.show("Hello again")
If the Spoon exposes start()/stop() methods (see conventions
below), call start() yourself after loading — nothing calls it for you:
const Greeter = hs.loadSpoon("Greeter")
Greeter.start()
Anatomy of a Spoon
A Spoon is a directory with two required files:
Greeter/
spoon.json
init.js
spoon.json carries the Spoon's identity. All four fields are required and must be
non-empty, or hs.loadSpoon() refuses to load it:
{
"name": "Greeter",
"author": "Jane Dev <jane@example.com>",
"version": "1.0.0",
"description": "Shows a friendly notification on a hotkey or a timer."
}
You never need to repeat this information inside init.js — after a successful load,
hs.loadSpoon() sets author, description, and version directly on the object your Spoon
returns, reading them from spoon.json. This is a deliberate difference from Hammerspoon 1,
where a Spoon's Lua table had to declare obj.name/obj.version/obj.author itself, alongside
whatever else its init.lua set up — v2 gives you one source of truth instead of two that can
drift apart.
init.js is loaded through the same require() used for the rest of your config (see
Splitting a growing config into multiple files
if you haven't read that section), so the same rules apply: it gets its own private scope,
require('./lib/something.js') resolves relative to the Spoon's own directory, and __dirname
points at that directory too — useful for loading bundled assets:
const iconPath = __dirname + "/icon.png"
init.js must set module.exports to an object (or a function — functions are objects in
JS, so this works too) — hs.loadSpoon() throws if it doesn't, since that's what the injected
metadata needs to attach itself to. Keep the top level of init.js light: build the object and
return it, but avoid starting timers, watchers, or hotkeys until start() is called (or, for a
Spoon simple enough not to need a start()/stop() split at all, until the caller invokes
whatever method actually needs them). This mirrors the same lifecycle habit
that applies everywhere else in Hammerspoon 2 — something needs to keep a reference alive for as
long as it should keep running.
If the returned object has an init() method, it's called automatically — matching
Hammerspoon 1, where :init() ran the same way. Use it for setup that needs to happen once the
object exists but shouldn't wait for start() — reading a config file, computing a derived
value, and so on. this inside init() refers to the object itself, and by the time it runs,
author/description/version are already set on this, since metadata injection happens
first. A thrown exception inside init() fails the whole load: hs.loadSpoon() throws, and
nothing is stored on hs.spoons.
One subtlety worth knowing: init.js's own top level only ever runs once (require() caches
the module), but init() runs again every time hs.loadSpoon() is called for that Spoon, since
the same cached object is handed back each time. Write init() to be safe to call more than
once — or better, do true one-time setup directly at init.js's top level, and reserve init()
for things that are genuinely fine to redo.
Conventions worth following
Two further conventions are worth following for consistency with how other Spoons will likely
behave, even though — unlike init() — nothing in the runtime calls them for you:
start() / stop() for a Spoon that does anything ongoing (timers, watchers, hotkeys bound
persistently). Build the object and its configuration in init.js, but don't activate anything
until start() runs:
function start() {
if (timer) return // already started
timer = hs.timer.doEvery(3600, () => show())
}
function stop() {
if (timer) { timer.stop(); timer = null }
}
bindHotkeys(mapping) for a Spoon whose behavior a user should be able to trigger with a
hotkey, taking an object keyed by action name so the caller chooses their own keys rather than
the Spoon hardcoding them:
function bindHotkeys(mapping) {
if (mapping.show) {
hs.hotkey.bind(mapping.show[0], mapping.show[1], () => show())
}
}
// in the user's init.js
const Greeter = hs.loadSpoon("Greeter")
Greeter.bindHotkeys({ show: [["cmd", "alt"], "g"] })
Logging: there's no per-Spoon logger like v1's hs.logger — use console.log()/.error()
directly, optionally prefixing messages with the Spoon's name so they're identifiable in a
config that's loaded several Spoons:
console.log("[Greeter] started")
A complete example
A small Spoon that shows a notification on demand, optionally repeating on a timer, with a configurable message and a hotkey to trigger it immediately:
// Greeter/init.js
let message = "Hello from Greeter!"
let timer = null
function show() {
hs.notify.show("Greeter", message)
}
function setMessage(newMessage) {
message = newMessage
return module.exports // returning `this`-equivalent enables chaining
}
function init() {
// Called automatically by hs.loadSpoon(). author/description/version are already set on
// `this` by this point - safe to log or read, but this could run more than once, so avoid
// anything that shouldn't happen twice.
console.log(`[Greeter] v${this.version} initialized`)
}
function start() {
if (timer) return
timer = hs.timer.doEvery(3600, show)
console.log("[Greeter] started")
}
function stop() {
if (timer) { timer.stop(); timer = null }
console.log("[Greeter] stopped")
}
function bindHotkeys(mapping) {
if (mapping.show) {
hs.hotkey.bind(mapping.show[0], mapping.show[1], show)
}
}
module.exports = { show, setMessage, init, start, stop, bindHotkeys }
// Greeter/spoon.json
{
"name": "Greeter",
"author": "Jane Dev <jane@example.com>",
"version": "1.0.0",
"description": "Shows a friendly notification on a hotkey or a timer."
}
Using it:
// init.js
const Greeter = hs.loadSpoon("Greeter")
Greeter.setMessage("Time for a break!")
Greeter.bindHotkeys({ show: [["cmd", "alt"], "g"] })
Greeter.start()
console.log(`Loaded ${Greeter.description} v${Greeter.version} by ${Greeter.author}`)
To share this Spoon, rename the Greeter folder to Greeter.spoon2 — that's the whole
packaging step, since a .spoon2 bundle is the Spoon's directory, just with a different
extension so macOS and Hammerspoon 2 recognize it as installable. Zip it up or hand someone the
folder directly; double-clicking it runs the install flow described above.
Differences from Hammerspoon 1 Spoons, in brief
If you're coming from v1, the shape is familiar but several specifics changed:
.spoon2, not.spoon— a deliberate rename so the two aren't confused; v1 and v2 Spoons aren't interchangeable, and existing v1 Spoons won't load in v2 unless rewritten in JavaScript.spoon.json, not fields on the returned table. Required fields arename,author,version,description— v1 requiredname,author,version,license(withhomepageoptional). v2 doesn't currently check for a license field at all.init()is called automatically, same as v1. One difference worth knowing: v2'srequire()cachesinit.js's top-level code (it only ever runs once), butinit()itself runs again on everyhs.loadSpoon()call for that Spoon — see above for what that means for what you put in it.hs.spoonsis a plain namespace, not a module. v1'shs.spoonswas a real module with helpers likeresourcePath(),scriptPath(), andbindHotkeysToSpec(). v2'shs.spoonsonly holds loaded Spoons by name — use__dirname(built intorequire()) in place ofresourcePath()/scriptPath(), and seebindHotkeys()above for the closest equivalent tobindHotkeysToSpec().- JavaScript, not Lua —
module.exports = {...}instead ofreturn objat the end ofinit.lua, and no separate Spoons repository or discovery mechanism yet.
Where to go from here
The Getting Started guide covers the rest of the API a Spoon is likely
to use — hotkeys, timers, watchers, and hs.ui. Coming from Hammerspoon 1? The
migration guide covers everything else that changed.