executable markdown on rails
A routine is a single markdown document — frontmatter for schedule,
prose for judgment, fenced sh blocks for mechanics.
bin/routine runs the blocks deterministically; when one
fails, the document itself becomes the prompt: claude receives the error in full
context, repairs state, and the runner retries the block once. Every scheduler —
launchd locally, a CI cron, a human at a shell — is a dumb tick that ends at the
same routine run.
try { blocks, in order } # zero-LLM happy path — cheap, fast, CI-safe
catch { claude("handle $error") } # the doc's prose is the handler's context
Layout — a routines home, and the tool
The documents live in a routines home —
$ROUTINE_HOME, default ~/.routines —
and this repo is only the tool.
~/.routines/<name>.md # the routine: frontmatter + prose + ```sh blocks ~/.routines/.state/<name>/ # lock/, last-run.json, runs.jsonl, logs/<ts>-<pid>.log ~/.routines/.state/tick.log # the sweep's journal — one line per tick bin/routine # the only runner — bash 3.2-safe, zero-dep routines/ # this repo's own routines — the CI convention .github/workflows/routines.yaml # generated by `routine sync-ci` docs/ # this page + its figures
curl'd copy of bin/routine — without breaking launchd, because the agent points at the runner, not at a checkout.ROUTINE_HOMEis the only anchor —ROUTINE_DIR(documents) andROUTINE_STATE(run state) derive from it and are overridable individually;installpins all three into the launchd agent, so re-run it after moving any.- Test seams —
ROUTINE_AGENTS_DIR,ROUTINE_LAUNCHCTL,ROUTINE_CLAUDE,ROUTINE_NOW, andROUTINE_PMSETredirect launchd, claude, the clock, and the dark-wake probe — the seams the test suite runs through.
The document — $ROUTINE_HOME/<name>.md
Documents and run state live in a routines home —
$ROUTINE_HOME, default ~/.routines,
with state under its .state/; this repo is only the tool,
so personal routines never enter it and the runner can move without breaking launchd.
One file per routine. Frontmatter is flat
key: value, parsed with awk — no yq, no yaml library;
one pair of surrounding quotes is stripped, CRLF tolerated.
Only sh / bash fences execute,
sequentially, each under bash -e; other fences are
material for the prose. Fences follow CommonMark (``` or
~~~, 3+ long, indent 0–3 stripped, closed only by the same
character at least as long). A block that needs an asset writes or references
sibling paths.
--- schedule: 30 7 * * 1-5 # 5-field cron, evaluated in UTC; omit ⇒ on-demand only timeout: 600 # seconds for the whole routine requires: GITHUB_TOKEN # env vars that must be non-empty on_error: claude # claude (default) | fail --- # Morning brief Prose is not decoration — it is the context the error-handler inherits: intent, traps, judgment calls. Write it for the agent that arrives when a block below has just failed. ```sh gh api notifications --paginate > "$ROUTINE_STATE_DIR/inbox.json" ``` ```sh claude -p "summarize inbox.json …" # a block may itself call claude — an agent step on rails ```
try / catch — the failure contract
The happy path never calls a model. The document is the handler's context precisely because it was written before anything broke.
- blocks run in order; each block's output is appended to the run log under a block delimiter
- each block's exit code is the truth — nothing interprets it
on_error: claude- the runner invokes
claude --model opus --permission-mode=auto --no-session-persistence -pwith the full document, the failing block, and its captured output, cwd = the routines home, env carryingROUTINE_NAME,ROUTINE_STATE_DIRandROUTINE_HOMEas the blocks see them - instruction: running unattended; diagnose and repair state; do not continue the routine yourself
- never start a final-message line with
needs input:orfailed:— the Stop hook would open an unkeyed thread per run — but use the stable thread key the document names
- the runner re-executes the failing block once — pass ⇒ continue to the next block; fail ⇒ the routine exits with that block's code
- one repair attempt per block, no loops
- no claude binary or auth (bare CI) ⇒ behaves as
on_error: fail, with the reason logged
- The agent never steers control flow — the deterministic retry is the verdict, never the agent's exit code.
Due-ness — derived, never matched
A routine is due when
next-fire-after(last started run, schedule) ≤ now; a
missing last-run.json means due. Cron evaluates in UTC —
one clock for launchd, CI, and the shell.
| one evaluator buys | why |
|---|---|
| CI correctness under delayed or skipped ticks | compare against the last run, not "does now match the expr" |
| launchd catch-up after sleep | free — the same comparison, no wake hook |
routine run --due as the universal entry | every scheduler is a dumb tick |
next-fire-after
is a minute-scan forward from the last run (horizon eight years, so Feb 29 recurs;
dead months skip to their 28th) — dumb, correct, sub-second. Day-of-month and
day-of-week follow cronie: OR when both are restricted, AND when either opens with
* (*/2 included).
Schedulers are dumb ticks
Neither scheduler knows a routine's schedule. Both wake up, ask the one evaluator what is due, and exit.
com.routines.dueroutine installrenders one agent;routine uninstallboots it outStartInterval60 +RunAtLoadtrue, running/bin/bash -lc "exec <runner> run --due"withPATHand theROUTINE_HOME/ROUTINE_DIR/ROUTINE_STATEanchors stamped (XML-escaped) — nocd, the runner enters the routines home itselfRunAtLoaddoubles as login and wake catch-up
routine sync-ciemits.github/workflows/routines.yamlinto the repo it is invoked in- cron every 15 min +
workflow_dispatch;ROUTINE_HOME= that repo'sroutines/dir, restoreroutines/.statefromactions/cache, fetchbin/routineif absent,run --due, save cache - the save runs under
if: always()—actions/cachesaves only on success, so one red routine would roll back every other routine's state - cache eviction just makes everything due once — the idempotency contract absorbs it
routine run frontmatter → lock (mkdir .state/<name>/lock, pid inside; live pid ⇒ 75, dead ⇒ reap, no pid ⇒ held for a minute)
→ requires gate (exit 78, loud list of missing vars — recorded only by the holder)
→ cd $ROUTINE_HOME (blocks and the catch run there; a missing home ⇒ 64)
→ watchdog (bash-native; SIGTERM to the block's group, SIGKILL after grace; a block or agent started past the deadline TERMs itself; exit 124, and a catch that outlives the deadline skips its retry)
→ HUP/INT/TERM (129/130/143) TERM the block's group, KILL it after two seconds, before the record and the lock release
→ blocks under the try/catch contract
→ last-run.json {started, started_epoch, finished, exit, duration, degraded}, written on every path (trap) and appended to runs.jsonl
→ the failing block's exit passes through
The figure
One execution path, end to end: the tick, the gate, the lock, the blocks, and the single catch that repairs and retries.
Verbs
Six verbs — the whole surface. Every one of them ends at the same runner.
| verb | duty |
|---|---|
| run <name> | run one document through the wrapper — gate, lock, timeout, try/catch |
| run --due | the universal entry — run every due routine; silent when none are |
| status | frontmatter × last-run join: schedule, due?, last exit, duration |
| install | render and load the single launchd tick agent |
| uninstall | boot the agent out and remove its plist |
| sync-ci | (re)write ./.github/workflows/routines.yaml in the invoking repo |
Exit codes and contracts
| code | meaning |
|---|---|
| 64 | unknown routine, or usage |
| 75 | lock held by a live run |
| 78 | requires missing |
| 124 | timeout — the watchdog fired |
| else | the failing block's own code, passed through |
- Blocks are idempotent — catch-retry, cache eviction, and tick overlap can all double-fire; the lock serializes, idempotency absorbs.
- bash 3.2-safe, zero-dep —
bin/routineis one script with no jq, yq, or GNU timeout; claude is needed only when a catch fires or a block calls it. $ROUTINE_HOME/.state/is never committed — CI persistsroutines/.statethroughactions/cacheonly.
Scale: the runner is ~700 lines of bash plus two small generators (plist, workflow).
Migration — a follow-on, not this build
Existing ad-hoc routines port one per document: a scheduled shell command inlines as a single block; an unattended-claude prompt becomes the document's prose plus a claude block, with git tracking its drift once it lives in the repo. A prose procedure paired with a mechanics script is already this shape — the standing proof of the format.
Rejected alternatives
routine.conf+run.shper routine dir (the first sketch) — superseded by the executable-markdown directive: the document unifies script and prompt; two files with implicit coupling become one artifact.- cron →
StartCalendarIntervalper-routine plists — punctual and power-optimal, but the translator (ranges/steps expanding into dict arrays) is the largest complexity sink in the system, drifts as a second schedule dialect, and still needs a separate catch-up mechanism. A 60-second tick that reads two files and exits is negligible. - "does now match the expr" due-check — breaks under delayed Actions ticks and machine sleep; derived due-ness handles both with the same code.
since 2026-08-12 · initial design — user sketch + executable-markdown/try-catch directive · since 2026-08-16 · ROUTINE_HOME decoupling — documents and state leave the framework repo