Run background tasks on a MacBook that's mostly closed and asleep. The laptop wakes itself every 5 minutes, picks up pending work from a Slack channel, runs your command, and goes back to sleep.
("Clamshell" is Apple's term for "laptop with the lid closed" — the mode this tool is built around.)
The Slack channel itself is the queue, and anything that can post to it enqueues work — you typing a message, or a Slack Workflow forwarding one in. Reactions (⏳/✅/❌) are the state. No database, and nothing of yours runs anywhere but the laptop.
runner(the only binary, on your laptop) — invoked every 5 minutes bylaunchd+pmsetwake. If the latest task-channel message carries no⏳/✅/❌reaction yet, it spawns$COMMANDdetached and exits.- A Slack Workflow (no code, no host, no app-level token) — watches the channels you pick and forwards matching messages into the task channel. That is the entire ingest path: work fans in from anywhere into one channel, with no service of yours to run.
flowchart TD
F["you typing in the task channel"] -->|"post a message"| Q["task-queue channel · the queue"]
A["a matching message in a watched channel"] --> S["Slack Workflow · runs inside Slack"]
S -->|"forwards it"| Q
subgraph Mac["MacBook · mostly closed-lid and asleep"]
W["launchd + pmset · wake every 5 min"] --> R["runner · short-lived"]
end
Q -.->|"latest message unhandled?"| R
R -->|"if yes → spawn detached"| C["$COMMAND · your handler · caffeinate -i"]
C -->|"do the work, then react ⏳ → ✅ / ❌"| Q
go build -o bin/runner ./cmd/runnerCross-compile with env vars alone — no toolchain to install. The runner is Unix-only, since it relies on POSIX session APIs:
GOOS=darwin GOARCH=arm64 go build -o bin/runner-darwin-arm64 ./cmd/runner
GOOS=linux GOARCH=amd64 go build -o bin/runner-linux-amd64 ./cmd/runnerAt https://api.slack.com/apps:
-
Create New App → from scratch.
-
OAuth & Permissions → Bot Token Scopes:
channels:historychat:writereactions:write
Who uses what:
channels:history→ the runner;reactions:write+chat:write→ your$COMMANDhandler. If your queue channel is private, usegroups:historyinstead ofchannels:history. -
Install to Workspace → copy Bot Token →
SLACK_BOT_TOKEN(xoxb-...). -
Create a channel that will act as the queue (e.g.
#task-queue). Copy its ID →SLACK_TASK_CHANNEL(C0123...). -
Invite the bot into the task channel — and only the task channel:
/invite @your-bot-name
No Socket Mode, no app-level token, no Event Subscriptions, and the bot never needs to join the channels you send work from. The token lives on your laptop and nowhere else.
A "task" is just a message in the task channel. The next runner cycle picks up whatever is unhandled and fires $COMMAND, and nothing is special about who posted it or how — so the simplest task is one you type in yourself.
To pull in work from other channels, use a Slack Workflow. It runs inside Slack, so there is no host to keep alive and no second copy of your token.
In Workflow Builder → New → Build Workflow:
- Choose an event → "When a message is posted." Pick the channels to watch (up to 20) and add a keyword condition.
- Add Step → "Send a message" → the task channel, with the message-text variable as the content.
- Finish Up → Publish. Nothing fires until it is published.
For recurring work, build the same thing with the "On a schedule" trigger instead.
The runner wakes every 5 minutes, so that is the floor for processing. Whatever arrives in between piles up and clears on the next wake, since the handler drains every pending message on each run.
- Keywords cannot be @mentions. Slack stores a real mention as
<@U0123ABC>, so a keyword typed as@your-botmatches nothing — the display name never appears in the message text. If you want tagging the bot to be the trigger, use the raw<@U0123ABC>form as the keyword. Otherwise pick a plain codeword. - Keywords are ANDed. Every keyword you add has to appear. One over-specific keyword silently blocks everything else.
- Leave the task channel out of the watched list. A workflow that watches the same channel it posts to will trigger itself forever.
- Don't prefix the forwarded text. If your handler parses the message for a tag, a hardcoded prefix in the message step gets parsed as the tag instead of the one the user typed. Send the variable on its own.
One more default worth knowing: Advanced Filters exclude bot/agent messages and thread replies — adjust them if you want another app's posts to trigger the workflow.
Linux / Windows: coming soon.
sudo install -m 0755 bin/runner /usr/local/bin/clamshell-runner./scripts/setup-sudoers.shPrompts for your sudo password once, then installs /etc/sudoers.d/clamshell-pmset allowing pmset schedule wake * without a password. All other pmset subcommands still require a password.
./scripts/setup-launchd-ready.shCreates ~/.clamshell-taskq/, writes the run.sh wrapper and the LaunchAgent plist (after checking that the runner binary is installed and the sudoers rule works). It does not write .env — you copy that in yourself in the next step. The schedule is not active yet.
Write a .env in the repo (see .env.example for the format), then copy it into the runner's directory:
cp .env ~/.clamshell-taskq/.env
chmod 600 ~/.clamshell-taskq/.envThe file must define:
SLACK_BOT_TOKEN=xoxb-...
SLACK_TASK_CHANNEL=C0123456789
COMMAND="/usr/bin/caffeinate -i /usr/local/bin/python3 /Users/me/handlers/main.py"
./scripts/setup-launchd-start.shLoads the LaunchAgent. The plist has RunAtLoad=true, so launchd fires the runner immediately, which kicks off the wake chain (each run.sh invocation registers the next wake before exiting). From this point the runner repeats every 5 minutes.
Under the hood, run.sh wraps each cycle in caffeinate -i and arms several pmset wakes bracketing the next 5-minute mark (roughly -10s to +10s around it), so a short, unstable dark wake is less likely to drop the chain.
| Path | Owned by | Purpose |
|---|---|---|
/etc/sudoers.d/clamshell-pmset |
setup-sudoers.sh |
NOPASSWD for pmset schedule wake and pmset -a disablesleep, nothing else |
~/.clamshell-taskq/.env |
you (step 4, cp) |
your tokens + $COMMAND |
~/.clamshell-taskq/run.sh |
setup-launchd-ready.sh |
wrapper: caffeinate the cycle → load env → run runner → hold/release SleepDisabled → re-arm the next wake(s) |
~/Library/LaunchAgents/com.clamshell-taskq.runner.plist |
setup-launchd-ready.sh |
RunAtLoad + StartCalendarInterval at :00, :05, …, :55 |
~/.clamshell-taskq/launchd.{out,err}.log |
launchd | launchd-captured runner output |
pmset -g sched # next wake scheduled?
launchctl list | grep clamshell-taskq.runner # LaunchAgent registered?
pmset -g | grep SleepDisabled # 1 only while a task is running
tail -f ~/.clamshell-taskq/launchd.{out,err}.loglaunchctl unload ~/Library/LaunchAgents/com.clamshell-taskq.runner.plist
rm ~/Library/LaunchAgents/com.clamshell-taskq.runner.plist
sudo rm /etc/sudoers.d/clamshell-pmset
sudo rm /usr/local/bin/clamshell-runner
sudo pmset schedule cancelall
sudo pmset -a disablesleep 0 # restore normal sleep
# rm -rf ~/.clamshell-taskq # also wipes env/logsA closed MacBook wakes only for short maintenance windows — long enough for the runner, too short for most tasks. So run.sh sets the kernel's SleepDisabled flag whenever $COMMAND is running, and clears it when nothing is running:
pmset -g | grep SleepDisabled # 1 while a task runs, 0 otherwiseThis is re-evaluated on every cycle, so the flag is never left set for more than one. It is what the pmset -a disablesleep entry in the sudoers rule is for.
The runner fires once on the latest unhandled message; your command owns the whole loop. Reactions in the channel are the only state, and the runner can fire again at any time — so write the handler to be safe to re-run: idempotent and crash-safe. The proven shape has three phases:
- Collect pending — newest to oldest, stop at the first handled message. Page back through
conversations.history. A message is handled if it already carries any of⏳/✅/❌; the moment you hit one, everything older is done too (FIFO) — stop. Skip join/leave and other system messages by matching their specificsubtypevalues — do not skip on the mere presence of asubtype. Workflow-posted messages arrive withsubtype: "bot_message", so a blanket skip silently drops every task the Workflow delivers. - Claim everything with
⏳first. Before doing any work, add⏳(hourglass_flowing_sand) to every collected message. Now the next runner cycle sees them as handled and won't double-grab them. - Process oldest first, and remove
⏳last. For each: do the work, post the result in-thread, add the terminal reaction (✅white_check_markon success,❌xon failure), then remove⏳. Always reach a terminal reaction — catch your errors and mark❌rather than letting a message fall through.
Two rules make it crash-safe:
- Remove
⏳last. If the process dies right after the terminal reaction or just before removing⏳, the message still shows a handled reaction (⏳, or✅/❌), so the next cycle skips it — no duplicate work or replies. A brief⏳+✅overlap is normal. - Make every Slack call idempotent. A re-run touches the same message again, so swallow the "already done" errors:
already_reactedwhen adding,no_reaction/message_not_foundwhen removing,cannot_reply_to_message/thread_not_foundwhen replying.
And one rule that keeps the queue converging:
- Never silently skip a message the runner counts as pending. The runner looks at reactions and nothing else. Any message your handler drops without leaving a reaction stays unhandled forever, so the runner keeps spawning
$COMMANDevery 5 minutes while the handler keeps finding nothing to do. Either process it and react, or keep your skip rules narrow enough that it never happens.
Sketch (Python, slack_sdk):
RUNNING, DONE, FAILED = "hourglass_flowing_sand", "white_check_mark", "x"
HANDLED = {RUNNING, DONE, FAILED}
pending = collect_pending(client, channel) # newest→oldest, stop at first HANDLED, skip only system subtypes
for msg in pending: # claim all up front
add_reaction(client, channel, msg["ts"], RUNNING)
for msg in reversed(pending): # oldest first
ts = msg["ts"]
try:
do_work(msg)
except Exception as e:
post_thread(client, channel, ts, f"failed: {e}")
add_reaction(client, channel, ts, FAILED)
remove_reaction(client, channel, ts, RUNNING) # ⏳ comes off last
continue
post_thread(client, channel, ts, "done")
add_reaction(client, channel, ts, DONE)
remove_reaction(client, channel, ts, RUNNING) # ⏳ comes off lastadd_reaction / remove_reaction / post_thread here are thin wrappers that ignore the "already done" errors above, so a re-run never crashes on a message it already touched.
launchd does not inherit your shell PATH, so use absolute paths for the interpreter and the script. Wrap the command with caffeinate -i — after a pmset wake the Mac is in dark wake with a short idle timer (often under a minute), so without caffeinate a long-running command can get cut off when macOS idles back to sleep mid-task.
COMMAND="/usr/bin/caffeinate -i /usr/local/bin/python3 /Users/me/handlers/main.py"
| What | Where |
|---|---|
launchd-captured runner output |
~/.clamshell-taskq/launchd.{out,err}.log |
Your $COMMAND's stdout/stderr |
~/.clamshell-taskq/logs/<timestamp>.log |
MIT — see LICENSE.