Headspace v2.0: Simple and Headless

hammerspoon logo

I use Hammerspoon to enhance my OSX with custom UI and automations, while also creating "monotasking" deep work environments to keep me focused.

Headspace V2 is a simple Hammerspoon script that responds to URL schemes and blocks applications based on file-system tags or names.


My experiments with Raycast revealed a mighty cool feature: Raycast lets you send text as input to a Shortcut. This little UX affordance was the last domino for a long-promised project: Headspace v2.

Showing the Raycast UI passing input to a Shortcut

I've been dreaming for a long while about breaking apart Headspace. A few of you have kindly reached out, interested in the concept of managing your focus… but discovering quickly that if you can't program, use another automation tool, or don't want to do everything I do, it didn't fit. 🤷

I eventually realized that the only thing that Headspace did that other OSX automation tools didn't was block applications from launching. I decided to rewrite it without the UI, without the Toggl tracking, autolayout, or script automation features to focus on app blocking and leave the rest to you. By surfacing the controls of Headspace as URL schemes you could control your focus from whatever application you already use!

Testing Tags

The remaining question: how to give the end-user control over the blocklist without writing code? After some musing I realized that MacOS already has a system for creating collections: Filesystem Tags. Could that work? I tagged my favorite terminal emulator with coding, and threw this into the Hammerspoon console:

> hs.inspect(hs.fs.tagsGet(hs.application.find("Kitty"):path()))
2023-02-16 15:16:03: -- Loading extension: inspect
{ "coding" }

Let's go! 👏

There is a catch… if you download from the Mac App Store, you have to put in an admin password to tag that application. Worse, you can't tag any built-in mac applications. Booh. I tried to fight with SIP, but it didn't help at all. After some frustration I settled on simply allowing the user to also block by name… and we were off to the races! 🏇


I started by stripping out toggl, the UI chooser element, autolayout, and even the concept of Spaces. I wanted to focus entirely on the goal of blocking applications.

I re-used the hs.application.watcher and hs.notify (alert for letting you know something had been blocked,) but added to the m:start function some listeners for URL events:

-- Module:start() is a convention for starting a Spoon
function m:start()
  -- m.watcher fires every time an application event occurs
  m.watcher = hs.application.watcher.new(function(appName, event, hsapp)
    -- but we only care about applications launching!
    if event == hs.application.watcher.launched then
      -- this conditional is the *soul* of the program.
      if not m.allowed(hsapp) then
        -- show a nag alert
          "🛑: " .. hsapp:name(),

  -- configure the URL events and tie them to the right callbacks.
  hs.urlevent.bind("setBlacklist", m.setBlacklist)
  hs.urlevent.bind("setWhitelist", m.setWhitelist)
  hs.urlevent.bind("stopHeadspace", m.stopHeadspace)

  return self

There are corresponding tear-down functions in the m:stop() method.

I decided to greatly simplify the interface by simplifying the data-model: the rules now only support either a whitelist or a blacklist, but not both. They both are stored in hs.settings.set("headspace"), so setting a new one wipes out the old. Much easier!

For example:

function m.setWhitelist(_eventName, params)
  local list = {}

  -- parse out the tags and apps, potential params to the URL.
  if params["tags"] then
    list["tags"] = fn.split(params["tags"], ",")
  if params["apps"] then
    list["apps"] = fn.split(params["apps"], ",")

  -- set the settings variable to the parsed table
  hs.settings.set("headspace", { ["whitelist"] = list })

  -- listen for a special parameter and (if true) kill apps that match
  -- the rule based on the settings variable we just saved.
  if params["kill"] then

The tricky part is defining m.allowed(app), the soul of the application.

To be honest, I wound up in guess-driven development for a good long while. If my re-reading of my sleep deprived commit history is any indication, I neglected to set the base condition for whitelist to be "all apps are blocked" and blacklist's base condition as "all apps are allowed." Rookie mistake.

Here's where I landed after some refactoring:

m.allowed = function(app)
  -- get the tags on the filesystem for this app
  local tags = hs.fs.tagsGet(app:path())
  local name = app:name()

  -- if it has the tag `whitelisted`, allow!
  if tags and fn.contains(tags, "whitelisted") then
    return true

  if m.getWhitelist() then
    -- assume the app **is blocked** by tag and name
    local appAllowed = false
    local tagAllowed = false

    if m.getWhitelist().apps then
      -- if the apps list contains this app's name, allow!
      appAllowed = fn.contains(m.getWhitelist().apps, name)
    if m.getWhitelist().tags and tags then
      -- if there's an intersection between application tags and rule tags, allow!
      tagAllowed = fn.some(m.getWhitelist().tags, function(tag)
        return fn.contains(tags, tag)

    -- whitelist allows if the tag is allowed or the name is allowed.
    return tagAllowed or appAllowed

  if m.getBlacklist() then
    -- assume that this app is **not blocked** by tag or name
    local appBlocked = false
    local tagBlocked = false

    -- if the rule contains application names
    if m.getBlacklist().apps then
      -- if the name matches, block!
      appBlocked = fn.contains(m.getBlacklist().apps, name)

    if m.getBlacklist().tags and tags then
      -- if there's an intersection, block!
      tagBlocked = fn.some(m.getBlacklist().tags, function(tag)
        return fn.contains(tags, tag)

    -- if the app is not blocked by name or tags… true.
    return (not appBlocked) and (not tagBlocked)

  return true

Phew! That was definitely some logic I should have slept on and revisited in the morning… and indeed that's what happened. I could not see it after midnight. Funny how that works. Anyway… it works now.

After using this for a while, I realized a critical part of changing my "space" was the clearing of distractions on each step. Before, I only killed applications specified in a configuration table… which kept Hammerspoon from naively kill -9ing Finder, Hammerspoon… well everything. I re-implemented the kill parameter using a simple heuristic: kill applications that match rules and have a dock icon.

m.dockedAndNotFinder = function(app)
  return app:bundleID() ~= "com.apple.finder" and app:kind() == 1

m.killBlockedDockedApps = function()
  local dockedAndBlocked =
    fn.filter(hs.application.runningApplications(), function(app)
      return m.dockedAndNotFinder(app) and not m.allowed(app)

  fn.each(dockedAndBlocked, function(app) app:kill() end)

Image showing Things project for rewriting Headspace

Man it feels good to check off this project…

All together (and after a lot of debugging of previous versions of m.allowed(app)!) it's been working fabulously. I have been driving Headspace through the Open URLs action in Shortcuts, but you could type it into a browser, launch using Bunch, or however you automate your life1.

The latest version is available on github, let me know what you think of the concept or the code. 🙏

  1. You can also drive it manually through hs.ipc: you could call 

    -- install somewhere your user has control and on its path

    And then whenever you want to use it, you could call it directly from a bash/shell script:

    hs -c "Spoon.Headspace.setBlacklist(nil, {["tags"] = "communication,distraction"})"

  • 2023-02-17 14:55:55 -0600
    Change link in tldr

  • 2023-02-17 08:33:46 -0600
    post article

  • 2023-02-16 22:15:35 -0600
    Remove focus tag

  • 2023-02-16 16:35:55 -0600
    Add code tag

  • 2023-02-16 16:34:58 -0600
    Schedule draft for tomorrow's post