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.
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.
Backends: --backend
| Backend | Where a tick runs | State lives |
|---|---|---|
local (default) | your machine — the OS’s own scheduler | the project directory |
modal | the cloud — Modal fires an ephemeral Runner per tick | a 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.
Modal: --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:
| Resource | Name | What it holds |
|---|---|---|
App + modal.Cron | loop-js-<id> | the schedule and the per-tick function |
| Image | baked at add | the project code — code delivery, never State |
| Volume | loop-js-<id> | State — the work tree, .loop/, .handoff/; seeded by the first tick |
modal.Dict | loop-js-<id>-lock | the tick lock — overlap arbitration off the Volume |
modal.Secret | loop-js (one, shared) | the agents’ key — ANTHROPIC_API_KEY (else CLAUDE_CODE_OAUTH_TOKEN) |
removedeletes 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.
addharvests the key from its environment into the one sharedloop-jsSecret every Entry’s function mounts. Rotation is onemodal 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.