Skip to main content
loop cron installs a schedule into a real scheduler and never runs one itself — loop.js owns no scheduler and no daemon. An installed schedule is an Entry; when it fires, that tick is a Trigger that runs loop run in the project directory. A tick is a scheduler firing; a Run is one execution of loop run — a tick may skip or expire without running, so the two are counted apart.
loop cron add "<cron-expr>" --until <settled|forever>
                              install a schedule that runs `loop run` in this dir
loop cron list                show installed schedules with ids and lifetimes
loop cron remove <id>         remove a schedule

  --until settled               the entry removes itself at the Loop's first settle — capped by
                                --max-runs/--expires in case it never settles
  --until forever               keep until `loop cron remove` — each tick on a settled Loop
                                re-judges it through the Verify gate; caps opt in
  --max-runs <n>                at most n runs of `loop run` (settled default: 3; forever: none)
  --expires <duration>          self-remove this long after install — 45s, 90m, 36h, 7d
                                (settled default: 24h; forever: none)
  --backend <local|modal>       where to install (default: local)
  --token-id <id>               the Modal token, for --backend modal; else MODAL_TOKEN_ID
  --token-secret <secret>       …and its other half; else MODAL_TOKEN_SECRET, or `modal token set`

Lifetime: --until

An Entry declares its lifetime at add--until settled or --until forever, no default; an add without --until is refused with an error naming both. --until settled — the Entry removes itself at the first of: the Loop settling (ok or give-up; the settling tick removes its own Entry), its --max-runs-th run of loop run (default 3), or --expires after install (default 24h) — a tick past the expiry removes the Entry instead of running. On settled the caps are always on — resizable, never removable — because they bound the path where no Verdict ever comes. --until forever — the Entry stays until loop cron remove. Each tick on a settled Loop re-judges it through the Verify gate, which is what lets a time-dependent Goal (“today’s report exists”) go stale and re-open daily. The same caps opt in as safety bounds; a settled exit never removes a forever Entry — only a cap or remove does. --expires takes a duration: a whole number with a unit — 45s, 90m, 36h, 7d.
loop cron add "*/30 * * * *" --until settled                              # gone at the first settle (capped)
loop cron add "*/30 * * * *" --until settled --max-runs 10 --expires 7d   # same, caps resized
loop cron add "0 8 * * *" --until forever                                 # stays until removed
loop cron add "0 8 * * *" --until forever --expires 30d                   # standing, safety bound opted in

Backends: --backend

BackendWhere a tick runsState lives
local (default)your machine — the OS’s own schedulerthe project directory
modalthe cloud — Modal fires an ephemeral Runner per ticka Modal Volume
local installs into the OS’s own scheduler, chosen by platform: crontab on Linux and other unix, launchd on macOS (cron there skips ticks while the machine sleeps and sits behind Full Disk Access), and Task Scheduler (schtasks) on Windows.
loop cron add "0 8 * * *" --until forever --backend modal
add deploys one Modal App per Entry, carrying a modal.Cron on your expression; each tick fires an ephemeral Runner that runs loop run against the Volume. You bring your own Modal token — pass --token-id/--token-secret, set MODAL_TOKEN_ID/MODAL_TOKEN_SECRET, or have run modal token set; loop.js runs no OAuth of its own. What an Entry deploys, and what each part holds:
ResourceNameWhat it holds
App + modal.Cronloop-js-<id>the schedule and the per-tick function
Imagebaked at addthe project code — code delivery, never State
Volumeloop-js-<id>State — the work tree, .loop/, .handoff/; seeded by the first tick
modal.Dictloop-js-<id>-lockthe tick lock — overlap arbitration off the Volume
modal.Secretloop-js (one, shared)the agents’ key — ANTHROPIC_API_KEY (else CLAUDE_CODE_OAUTH_TOKEN)
The rules that follow from that split:
  • remove deletes the Entry, never its Volume — removing a schedule never destroys State. (The lock Dict goes with the Entry.)
  • The Secret is created only when absent, never overwritten. add harvests the key from its environment into the one shared loop-js Secret every Entry’s function mounts. Rotation is one modal secret create loop-js --force … — no Entry redeploys.
  • Overlap is skip-only: a tick that finds a fresh claim in the Dict prints one line and exits 0; a stale claim is taken over, same as the engine’s own Lock. Volume commits are last-write-wins, which is why the lock lives in the Dict and only State rides the Volume.
  • A tick runs with Modal’s maximum Function timeout (24h) — on this backend the platform ceiling is the outer guard on Run duration.
  • On --until settled, the settling tick stops its own App — the Entry removes itself in the cloud exactly as it does locally.

list and remove

list shows one line per Entry — <id> <expr> <dir> <lifetime>, the lifetime in plain words such as until-settled max-runs=3 expires=24h or forever. remove <id> deletes the Entry; it exits 1 if no Entry has that id.
$ loop cron list
3f9a1c2e  0 8 * * *  /home/dev/my-loop  until-settled max-runs=3 expires=24h
$ loop cron remove 3f9a1c2e
removed 3f9a1c2e