routines · design

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
The whole control flow. Everything below is detail on these two lines.

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
Personal routines never enter the public tool repo, and the repo can move — or be replaced by a curl'd copy of bin/routine — without breaking launchd, because the agent points at the runner, not at a checkout.

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
```
The schedule, the judgment and the mechanics in one artifact — script and prompt stop drifting apart.

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.

① try
  • 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
② catch — on_error: claude
  • the runner invokes claude --model opus --permission-mode=auto --no-session-persistence -p with the full document, the failing block, and its captured output, cwd = the routines home, env carrying ROUTINE_NAME, ROUTINE_STATE_DIR and ROUTINE_HOME as 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: or failed: — the Stop hook would open an unkeyed thread per run — but use the stable thread key the document names
③ retry
  • 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
④ degrade
  • no claude binary or auth (bare CI) ⇒ behaves as on_error: fail, with the reason logged

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 buyswhy
CI correctness under delayed or skipped tickscompare against the last run, not "does now match the expr"
launchd catch-up after sleepfree — the same comparison, no wake hook
routine run --due as the universal entryevery 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.

launchd — com.routines.due
  • routine install renders one agent; routine uninstall boots it out
  • StartInterval 60 + RunAtLoad true, running /bin/bash -lc "exec <runner> run --due" with PATH and the ROUTINE_HOME/ROUTINE_DIR/ROUTINE_STATE anchors stamped (XML-escaped) — no cd, the runner enters the routines home itself
  • RunAtLoad doubles as login and wake catch-up
CI — one static workflow
  • routine sync-ci emits .github/workflows/routines.yaml into the repo it is invoked in
  • cron every 15 min + workflow_dispatch; ROUTINE_HOME = that repo's routines/ dir, restore routines/.state from actions/cache, fetch bin/routine if absent, run --due, save cache
  • the save runs under if: always() — actions/cache saves 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 shared wrapper — every verb that runs a routine runs it through this.

The figure

One execution path, end to end: the tick, the gate, the lock, the blocks, and the single catch that repairs and retries.

One execution path through the runner as a transit map — launchd tick, CI cron and a shell converge on run --due and run <name>; then frontmatter, lock, requires gate, timeout, blocks, last-run.json, exit code and status; the catch loop from blocks through claude to a single retry, and a dashed bypass to the exit code when still failing.
One execution path, end to end. Source: Claude Design canvas routines figures, board execution-path.

Verbs

Six verbs — the whole surface. Every one of them ends at the same runner.

verbduty
run <name>run one document through the wrapper — gate, lock, timeout, try/catch
run --duethe universal entry — run every due routine; silent when none are
statusfrontmatter × last-run join: schedule, due?, last exit, duration
installrender and load the single launchd tick agent
uninstallboot the agent out and remove its plist
sync-ci(re)write ./.github/workflows/routines.yaml in the invoking repo

Exit codes and contracts

codemeaning
64unknown routine, or usage
75lock held by a live run
78requires missing
124timeout — the watchdog fired
elsethe failing block's own code, passed through

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

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