4lun.net
007 · 2026-08-13
Light Light Dark Dark Auto

Scheduling unattended Claude Code sessions with launchd

There are a couple of recurring chores that Claude Code handles well end to end for me, triaging the last 48 hours of production errors and doing a weekly dependency update pass, but they only happened when I remembered to kick them off. Recently moved them onto a schedule on an always-on machine, so by the time I'm at a desk there's a push notification and a draft PR waiting instead of a to-do.

Claude Code does have scheduled tasks built in, but currently they're tied to a session and held in memory, and recurring ones expire after 7 days, so they're not really usable for standing jobs. Ended up just using the OS scheduler: launchd in my case (the always-on machine is a Mac), or systemd user timers plus loginctl enable-linger if you're on Linux.

Each routine is a folder

A routine is a folder with two files in it: routine.conf (shell-sourceable) and prompt.md, the brief, written so it needs no other context.

# routines/log-triage/routine.conf
AT=07:10
ON=daily            # daily | weekdays | mon,thu
PROJECT=myapp       # which repo to work on
PERMISSION_MODE=acceptEdits
ALLOWED_TOOLS="..." # per-routine tool allowlist
# ENABLED=0         # parks the routine without deleting it

A small bootstrap script generates one LaunchAgent per folder (AT/ON become StartCalendarInterval) and cleans up after itself: delete or disable a folder and the next bootstrap run unloads and removes its plist. Adding a job is a new folder, two files, rerun the bootstrap.

When launchd fires, a runner script claims a fresh isolated workspace for the project (I use git worktrees with their own containers, but anything giving a clean checkout works), spawns the session detached in tmux, and appends a line to a log: timestamp, routine, spawned/skipped/failed.

If the previous run's session or workspace still exists it logs skipped and stops, so a stale run blocks its own routine rather than quietly piling up copies of itself.

Keeping it on a leash

The sessions run with --permission-mode acceptEdits plus a small per-routine allowlist of tools, enough to edit files and run what the job actually needs. If one tries something outside that, it doesn't die and it doesn't get waved through, the session just stalls on the permission prompt. With Remote Control connected that prompt reaches my phone, so worst case I wake up to a question.

Alerting is part of the prompt rather than the scheduler: each brief ends with "send a push notification and leave your output as a draft PR". If there's nothing in the morning, either nothing fired or nothing was found, and the log says which.

Two launchd behaviours worth knowing

  • It fires at local wall-clock time, so AT=07:10 means 07:10 through DST changes, no UTC arithmetic.
  • Without RunAtLoad, a fire missed while the machine was off doesn't run late, it just waits for the next match. Fine for an always-on box, worth rethinking if yours isn't.

Early days, but so far it's been working nicely. The interesting work still happens interactively, this just stops the recurring stuff depending on me remembering it exists.