Run background tasks on a MacBook that's mostly closed-lid and sleeping. 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.)
server(long-running, on a 24/7 host) forwards Slack mentions into a single "task" channel.runner(short-lived, on your laptop) is invoked every 5 minutes bylaunchd+pmsetwake. If the latest task-channel message has no reaction yet, it spawns$COMMANDdetached and exits.
The Slack channel itself is the queue. Reactions (⏳/✅/❌) are the state. No database. No HTTP between server and runner.
flowchart TD
A["User @mentions the bot in any channel"] --> S["server · 24/7 host · Slack Socket Mode"]
S -->|"forwards the mention"| Q["task-queue channel · the queue"]
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/server ./cmd/server
go build -o bin/runner ./cmd/runnerCross-compile (Go does this with env vars only — no toolchain install needed):
# server
GOOS=linux GOARCH=amd64 go build -o bin/server-linux-amd64 ./cmd/server
GOOS=linux GOARCH=arm64 go build -o bin/server-linux-arm64 ./cmd/server
GOOS=darwin GOARCH=arm64 go build -o bin/server-darwin-arm64 ./cmd/server
GOOS=windows GOARCH=amd64 go build -o bin/server-windows-amd64.exe ./cmd/server
# runner (Unix-like only — 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.
-
Socket Mode → enable.
-
App-Level Tokens → create one with scope
connections:write→SLACK_APP_TOKEN(xapp-...). -
OAuth & Permissions → Bot Token Scopes:
app_mentions:readchat:writereactions:writechannels:history
-
Install to Workspace → copy Bot Token →
SLACK_BOT_TOKEN(xoxb-...). -
Event Subscriptions → subscribe to
app_mention. -
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 any channel you want to trigger it from:
/invite @your-bot-name
Drop the binary on a 24/7 host with env vars loaded:
cp .env.example .env
# fill in SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_TASK_CHANNEL
set -a; source .env; set +a
./bin/serverSocket Mode keeps an outbound WebSocket open, so no inbound port or public URL is needed.
Linux / Windows: coming.
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/ with a .env placeholder and the run.sh wrapper, and writes the LaunchAgent plist. The schedule is not active yet.
Write a .env in the repo (see .env.example for the shape), 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/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.
| Path | Owned by | Purpose |
|---|---|---|
/etc/sudoers.d/clamshell-pmset |
setup-sudoers.sh |
NOPASSWD for pmset schedule wake * only |
~/.clamshell-taskq/.env |
setup-launchd-ready.sh |
your tokens + $COMMAND |
~/.clamshell-taskq/run.sh |
setup-launchd-ready.sh |
wrapper: source env → run runner → re-arm next wake |
~/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?
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 pmset schedule cancelall
# rm -rf ~/.clamshell-taskq # also wipes env/logsThe runner only checks the latest message and triggers once. The command itself owns the full processing loop:
- List pending messages in the task channel via
conversations.history(paginated). Pending = no⏳,✅, or❌reaction. - For each pending message:
- Add
⏳(hourglass_flowing_sand) first — prevents the next runner cycle from re-triggering. - Do the work.
- On success → add
✅(white_check_mark). - On failure → add
❌(x), optionally reply in-thread with the error.
- Add
Sketch (Python):
for msg in list_pending(channel):
add_reaction(msg.ts, "hourglass_flowing_sand")
try:
do_work(msg)
add_reaction(msg.ts, "white_check_mark")
except Exception as e:
add_reaction(msg.ts, "x")
post_thread_reply(msg.ts, f"failed: {e}")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 |
- macOS itself does not guarantee every scheduled wake fires under every combination of (lid closed, on battery, deep sleep). M-series + macOS 26.x have rare firmware sleep hangs reported. Expect most 5-minute cycles to fire when closed-lid + sleeping; a missed cycle just means the work waits for the next one.
- Missed wakes are not lost work: the Slack channel is the queue, so the next cycle catches up on whatever piled up.
MIT — see LICENSE.