Headspace: Block All The Things!

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.

JG wrote:

As far as I know, Headspace blocks the opening of applications via the hyper key, right? I was wondering if there is a way to block the opening of applications at all?

Pretty much as soon as I had finished the series on Hammerspoon, I knew I wanted to take the Headspace project further. At that point, it would block opening or switching to an application via the Hyper shortcuts I had defined earlier, but if you opened an application via Finder, Spotlight, or another launcher like Alfred… I could get around my little system.

My fingers started building the new neural pathways to get around my own boundaries immediately. So it's time to… BLOCK ALL THE THINGS! * ahem *

I had actually taken a couple stabs at the universal blocking before I finished the blog post. I had a pretty good idea that I'd be using the hs.application.watcher to track what applications had launched, but I could not get it to work.

After a lot of back and forth and some debugging, I eventually realized that I was tripping over Lua's garbage collection. I had instantiated the watcher, but in the local context of a lua module1. This meant that after the function had been called first time, the watcher's object was garbage collected and destroyed.2 I eventually "hoisted" the watcher out to the global namespace like this:

local module = {}
-- ...
if module.watcher_enabled then
  module.watcher = hs.application.watcher.new(function(app_name, event, hsapp)
    if event == hs.application.watcher.launched then
      local app_config = module.config.applications[hsapp:bundleID()]

      if not allowed(app_config) then
          "Blocked " .. hsapp:name(),
          "Current headspace: " .. hs.settings.get("headspace").text,
-- ...
return module

Another advantage to this migration is that now headspace.lua is not dependent at all on hyper.lua. You can trigger the Headspace UI any way you like. I have thought about rewriting it as an official Hammerspoon Spoon, but I am not sure if anyone really wants that.

While I was working on it, I added another feature: you can now define a teardown function for a space. The teardown function fires when the space is exited, and I use it to "undo" things. For instance, when I enter my Communication space, I filter Things.app to the set of to-dos related to communicating. When I leave Communication, I want to see my normal "Today" view, and that's easy enough to implement.

config.funcs.agendaFor = {
  setup = function()
  teardown = function()

I've been using the new Headspace for the past month or so, and it has been really stable. I may end up choosing to use a non-native notification method eventually, just as I tend to turn on Do Not Disturb mode, which effectively blocks Headspace from telling me what it's doing. As it is, I haven't made any major changes to it in a while, mostly just aesthetic changes to the time tracker and adjusting the blocking lists for various spaces.

If you want to give Headspace a try, you can! Here is a minimum viable init.lua for starting to tinker and build your own setup.

  1. Download the following files to your Hammerspoon directory (usually ~/.hammerspoon):

  2. You'll need to set up your toggl secrets in a .secrets.json in that same Hammerspoon folder. Use this as a guide:

      "toggl": {
        "key": "wouldntyouliketoknow",
        "projects": {
          "communications": "<communications id>",
          "deep": "<deep id>",
  3. Here's a minimimum viable init.lua, together with some commentary on what is going on:

    -- load in all secrets
    local secrets = require('secrets')
    -- instantiate the config table that will be passed to the headspace module
    config = {}
    -- define some applications to block... yours will be different, here's just a place to start.
    config.applications = {
      ['org.mozilla.firefox'] = {
        bundleID = 'org.mozilla.firefox',
      ['com.tinyspeck.slackmacgap'] = {
        bundleID = 'com.tinyspeck.slackmacgap',
        tags = {'communication'}
      ['com.apple.iChat'] = {
        bundleID = 'com.apple.iChat',
        tags = {'communication', 'distraction'}
    -- instantiate tables for headspace spaces
    -- instantiating them early means that you can use `table.insert` to add
    -- more later.
    config.spaces = {}
    config.funcs = {}
    -- I store the API id of my toggl projects in a the `.secrets.json`
    config.projects = hs.settings.get("secrets").toggl.projects
    -- setup spaces
    -- I recommend having at _least_ two... one for shallow and one that blocks
    -- distractions for "deep" work. Take what I have here and make it your
    -- own.
    table.insert(config.spaces, {
      text = "Deep",
      subText = "Work deeply on focused work",
      image = hs.image.imageFromAppBundle('com.apple.finder'),
      toggl_proj = config.projects.deep,
      blacklist = {'distraction', 'communication'},
      funcs = 'deep'
    config.funcs.deep = {
      setup = function()
      teardown = function()
    table.insert(config.spaces, {
      text = "Communication",
      subText = "Intentionally engage with Slack and Email.",
      image = hs.image.imageFromAppBundle('com.tinyspeck.slackmacgap'),
      whitelist = {'communication'},
      launch = {'communication'},
      toggl_proj = config.projects.communication,
      funcs = 'agendaFor'
    config.funcs.agendaFor = {
      setup = function()
      teardown = function()
    -- instantiate headspace
    local headspace = require('headspace')
    -- enable the watcher to start the blocking
    -- start the module's setup
    -- bind the Space Choosing UI to a key.
    -- Here I'm just using `⌘βŒ₯+k`.
    hs.hotkey.bind({'cmd', 'alt'}, 'k', nil, headspace.choose)

That's it! I did some smoke testing on my own system to make sure that this works, but I am (most likely) forgetting something. I realize that this is still really confusing, so if you want to give this a whirl, please reach out and I'll be happy to help you. πŸ™

You can always take more inspiration from my ever evolving configuration at my github.

  1. You can see the change here

  2. The symptom of this problem was the blocking function would execute correctly once, then stop working. This was because after the module was loaded, the GC would destroy the watcher object. 

  • 2022-06-08 11:31:29 -0500
    Rename articles

  • 2020-09-28 09:20:43 -0500
    Change repo name

    I decided to change the name of my hammerspoon configuration repo to
    `hammerspoon-config` to avoid confusion... it's not the actual
    hammerspoon executable, it's a configuration for that executable.

  • 2020-08-14 15:42:21 -0500
    Forgot to add tags... duh

  • 2020-08-07 15:28:50 -0500
    Adjust the time

  • 2020-08-07 15:27:33 -0500
    New post: Block all the things!