OpenRoutine

Docs

Everything OpenRoutine does is driven by two plain-text files: a markdown task file you own, and a config.toml that says which files run and what an agent is.

Install

macOS and Linux only; Windows is an explicit non-goal. Homebrew, PyPI, and npm all drop in the same prebuilt binary — only the cargo routes need a Rust toolchain.

brew tap soulmachine/tap https://github.com/soulmachine/openroutine
brew trust --formula soulmachine/tap/openroutine
brew install openroutine     # prebuilt binary, no Rust needed
pip install openroutine      # or from PyPI — the binary, in a wheel
npm install -g openroutine   # or from npm
cargo install openroutine    # or build from crates.io

The brew tap line carries a URL because this repo is its own tap rather than a separate homebrew-* one, and Homebrew 6 wants a third-party formula trusted before it will load it.

openroutine init             # writes a config, prints a sample task
openroutine add hello.md     # register a task file
openroutine list             # see what would run
openroutine serve            # run the scheduler in the foreground
openroutine install          # or register it as a boot service, no sudo

init writes ~/.config/openroutine/config.toml and prints a sample task to copy. Save it anywhere as markdown, then openroutine add registers it. Nothing else to configure.

First five minutes

What openroutine init prints, and what to do with it.

Wrote config …/config.toml

A task is one markdown file. A starter, to save as hello.md:

---
name: hello
description: Say hello, nightly
cron: "0 9 * * *"
---

Say hello, then stop. This is a sample task — edit it freely.

Try:  openroutine add hello.md          # register it
      openroutine list                  # what would run
      openroutine run hello --dry-run   # what it would do
      openroutine serve                 # start the scheduler

Nothing fires until a daemon is running. `openroutine install`
registers one with your service manager, without sudo.

run --dry-run is the one to reach for first. It renders the fully resolved execution plan — command line, environment, working directory, upcoming fire times — without spawning anything. It is a read, not a run; nothing is recorded.

Task files

A task is a single markdown file, registered with openroutine add. The frontmatter is the metadata, the body is the prompt, and the file is the complete definition. Its id is derived from its frontmatter name and is unique across the machine — renaming or moving the file changes nothing; changing name: to something that derives a different id creates a new task.

---
name: hello
description: Say hello, nightly
cron: "0 9 * * *"
---

Say hello, then stop. This is a sample task — edit or delete it.

Frontmatter keys

Frontmatter keys a task file accepts. name, description, and a non-empty body are required; every other key is optional.
KeyWhat it does
nameWhat the task is called, in prose — X timeline domain hunter is fine. Its id is derived from it: lowercased, runs of whitespace become single hyphens, anything outside [a-z0-9_-] is dropped, and the ends are trimmed — giving x-timeline-domain-hunter. That id must come out 2–50 characters and contain a letter or digit. Required.
descriptionWhat the task is for. Shown in the CLI and the web UI. Required.
cronA crontab expression, e.g. "0 2 * * *". Mutually exclusive with at.
atA single moment, RFC 3339. Fires once, then the task is Completed. Mutually exclusive with cron.
catch_upRun one tick missed while the daemon was away, if it is recent enough to still be wanted. Off by default.
disabledSwitched off in the file itself — a reviewable commit rather than invisible machine state. Not scheduled at all, so it accrues no ticks and no skips — and, because it never runs, it never re-reads itself: turning it back on takes an openroutine reload.
agentWhich configured agent runs it. Falls back to default_agent.
jitterHow far the fire time may be nudged, deterministically. Accepts 2m, 30s, or a bare 0 for exact ticks.
cwdWhere the agent starts. Absolute paths are fine; relative ones resolve against the task file’s directory, which is also the default.
modelInjected through the agent template’s {model} placeholder, as a single argument.
permission_modeInjected through the {permission_mode} placeholder, as a single argument.
envEnvironment for this task, layered over the config’s own.

Durations accept 30s, 5m, 2h, 1d, or a bare number of seconds.

The body is the prompt — a task with nothing to say to its agent is refused.

A task with neither cron nor at is a one-shot — it runs once, as soon as the daemon takes it in, then shows as Completed. Editing the file re-arms it at the next openroutine reload: a completed task never runs, so it cannot notice the edit on its own.

A task re-reads its own file when it runs. The moment a run starts — scheduled, fired, or caught up — the task checks its file’s mtime, and its content digest if the mtime moved; if the definition changed, the new one is what runs. So the prompt you just fixed is the one that executes. Nothing is watched and nothing is polled: no timer, no file watcher, no other task’s file touched, and the config not re-read.

Everything else is a reload. The daemon reads its config and every registered file at startup and when openroutine reload or POST /v1/reload asks it to; add and remove ask for one themselves. A reload is what puts an edit in front of list, status, and the web UI, and what a task that is not going to run needs before it notices at all — a completed one-shot re-arms only at a reload, and a disabled: true task cannot switch itself back on, because neither ever runs.

At the fire moment, a definition that changed into something that cannot answer the tick withdraws it rather than running it: one that no longer loads, one that now says disabled: true, and one that renamed itself all skip the run and wait for a reload, since identity is a registry-level change. A cron tick still runs even when the new schedule no longer contains it — it was legitimately due when it was planned — while a one-shot whose at: moved before it arrived is skipped and re-armed at the moment it now names. Each is recorded as a skip with reason definition-changed.

on_failure and tz are declared in the v1 schema but not yet honoured. OpenRoutine warns about them by name rather than calling them unknown keys, because a key that silently does nothing is the worst outcome.

Configuration

Human-owned and hand-editable, at ~/.config/openroutine/config.toml. This is what openroutine init writes:

# openroutine — https://openroutine.dev

# The agent a task gets when it names none.
default_agent = "claude"

# The task files to schedule. `openroutine add` appends here.
tasks = []

# An agent is a command template. {prompt} is substituted as a
# single argument; without it, the prompt arrives on stdin.
[agents.claude]
cmd = "claude -p {prompt}"

[agents.codex]
cmd = "codex exec {prompt}"

Keys

Configuration keys, with the value used when the key is absent.
KeyDefaultWhat it does
tasks[]The registered task files, as absolute paths. add and remove edit this list; hand-editing works just as well.
agents.<name>.cmd—The command template. {prompt} is substituted as a single argument, never spliced into a shell string; without it the prompt arrives on stdin. {model} and {permission_mode} work the same way.
default_agentunsetThe agent for tasks that name none. Unset by default, so an omitted agent: leaves a task Broken until you opt in.
envemptyEnvironment handed to every run, layered over the login shell’s own and under whatever the task itself sets.
max_parallelunsetHow many runs may be in flight at once, across every task. Unset means whatever the hardware tolerates.
max_runs_per_task50How many runs of each task are kept on disk.
max_log_bytes100 MiBHow much of a run’s output is kept before the log is truncated.
idle_timeout15mHow long a run may go without its agent producing output before its whole process group is ended, or none to let it stay quiet indefinitely. Measured from the last output, not from the start — a run that keeps talking is never interrupted, however long it takes, and there is no cap on total runtime. Machine-wide: a task cannot set its own.
bindloopbackWhere the local API listens. Widening it is possible, discouraged, and never removes the token requirement.

Run state — last run time, the tick it was scheduled for, recorded skips, pause toggles — is machine-owned and lives in a scheduled-tasks.json outside your repo. Delete it and you lose run history, not tasks.

CLI reference

openroutine --help for the same list. A global --config <PATH> overrides the XDG config path on any command.

Every openroutine subcommand.
CommandWhat it does
serveRun the scheduler in the foreground.
listShow every task, its schedule, and its health.
add <file.md>Register a task file so it is scheduled. Validates hard — a file that doesn’t parse is refused, not registered.
remove <name-or-path>Unregister a task. --delete also deletes the file; without it the file stays yours.
reload [task]Have the running daemon re-read the config and every registered file — unconditionally, changed or not — and report each task’s health. add and remove trigger one automatically. What a task that isn’t going to run needs before it notices an edit.
initWrite a starter config and print a sample task. Registers nothing.
statusSummarise the daemon and what it would be running.
run <task>Fire a task now. --dry-run describes the run instead of starting one; --text adds context for this run — information for the agent, never instructions, and it cannot redefine the task.
logs <task>Show a task’s most recent run. --follow keeps printing as the run writes more.
pause [task]Hold a task, or everything with --all.
resume [task]Release a task, or everything with --all.
installRegister the daemon with your service manager, without sudo. --print shows what would be written instead of writing it.
uninstallUnregister the daemon. Config, state, and tasks are left alone.
dashboardOpen the local web UI in a browser, signed in.
tokenPrint the API token. --rotate replaces it — anything using the old one stops working.

REST API

The daemon listens on 127.0.0.1:7373, guarded by a bearer token from openroutine token.

curl -X POST http://127.0.0.1:7373/v1/tasks/todo-digest/fire \
  -H "Authorization: Bearer $(openroutine token)" \
  -d '{"text": "Sentry alert SEN-4521 fired in prod."}'

The optional text reaches the agent labelled as caller-supplied context, not as instructions — anyone who can reach the endpoint can send text, so text must not be able to redefine the task.

Endpoints

Every route the local API serves.
RouteWhat it does
GET /v1/tasksEvery registered task, with schedule and next fire time.
GET /v1/tasks/{id}One task in detail.
GET /v1/tasks/{id}/runsThat task’s run history.
POST /v1/tasks/{id}/fireStart a run. Returns a run id and its log path.
GET /v1/runs/{id}/{run}One run in detail.
GET /v1/runs/{id}/{run}/logThat run’s log.
GET /v1/runs/{id}/{run}/log/streamLive-tail the log over SSE.
POST /v1/runs/{id}/{run}/cancelStop a run in flight.
POST /v1/tasks/{id}/pauseHold one task.
POST /v1/tasks/{id}/resumeRelease one task.
POST /v1/pauseHold every task on this machine.
POST /v1/resumeRelease everything.
POST /v1/reloadRe-read the config and every registered task file; returns each task’s health.

Deployment

To leave the daemon running unattended on a machine you don’t sit at — the Mac mini under the desk, a home server — register it with the system’s service manager.

cargo install --path .        # install to a stable path; see below
openroutine init              # config; prints a sample task
openroutine add hello.md      # a task to prove it works
openroutine install           # register with launchd/systemd, no sudo
openroutine status            # daemon: running (pid …)

install records the path of the binary that registers it, so run it from the installed copy rather than from target/release/openroutine — a cargo clean should not be able to unmake your scheduler. It writes a per-user service and never asks for sudo. openroutine install --print shows exactly what it would write, and what it would run, without writing anything.

Your agent must be on the login shell’s PATH

The daemon runs every agent through a login shell, so a run gets the same PATH, shims, and API keys your terminal has. A login shell is not an interactive one: zsh reads .zshenv and .zprofile but not .zshrc, and bash reads .bash_profile but not .bashrc. So an agent that only your .zshrc puts on the PATH — anything in ~/.local/bin is the usual case — is found when you test by hand and missing once launchd starts the daemon:

zsh:1: command not found: claude

openroutine list warns before you get there. It samples the login shell the way a service manager starts one — with PATH seeded to the bare /usr/bin:/bin:/usr/sbin:/sbin a daemon inherits, never the PATH your terminal happens to have — so it answers for the daemon rather than for you:

warning: "claude" is on your PATH here but not under a service manager, so
scheduled Runs will fail; move its PATH export into your login profile

Fix it by moving the PATH export into .zprofile, which repairs SSH and cron sessions at the same time, or by naming the agent absolutely:

[agents.claude]
cmd = "/Users/you/.local/bin/claude -p {prompt}"

Verify the way the daemon will see it — a login shell with none of your terminal’s inherited environment:

env -i HOME="$HOME" SHELL=/bin/zsh PATH=/usr/bin:/bin /bin/zsh -lc 'command -v claude'

macOS

install writes a LaunchAgent to ~/Library/LaunchAgents/, so the daemon starts at login rather than at boot, and launchd restarts it if it dies. On a headless machine, pair it with auto-login (System Settings → Users & Groups → Automatically log in as), which requires FileVault to be off.

Auto-login is not only about the daemon starting. Agent CLIs keep credentials in your login keychain, and that keychain is unlocked by the GUI login — a service that starts without one finds it locked, and the agent reports itself logged out. That is also why install does not write a root LaunchDaemon: starting before anyone logs in is precisely the state in which the agent cannot authenticate, so the one thing a LaunchDaemon buys is the one thing that breaks it. The trade runs the other way too, and it is a real one: auto-login with FileVault off means physical access is a logged-in desktop.

While you are there, stop the machine sleeping through its own schedules:

sudo pmset -c sleep 0 displaysleep 0 disksleep 0  # never sleep
sudo pmset -c autorestart 1 womp 1                # return after power loss
launchctl print gui/$(id -u)/dev.openroutine.daemon | grep state

Linux

install writes a systemd user unit and enables lingering, so the daemon starts at boot with no login session — the one platform where “no login needed” holds without an asterisk.

systemctl --user status dev.openroutine.daemon
journalctl --user -u dev.openroutine.daemon -f

Confirming it survives

openroutine run <task>   # a real run, end to end
openroutine logs <task>  # what the agent actually printed

Killing the daemon outright is a fair test: the service manager should bring it back within seconds under a new pid. openroutine uninstall unregisters it and leaves your config, state, and tasks untouched.

Concepts

The vocabulary the CLI, the UI, and the docs all use. Precision here is deliberate — most of these terms have a near-synonym that means something subtly different.

Terms used identically by the CLI, the web UI, and these docs.
TermMeaning
TaskA unit of agent work, defined entirely by a single markdown file registered with openroutine add. Its name is prose for people; its id is derived from that name and is what everything keys by. Renaming or moving the file changes nothing.
IdA task’s identity, derived from its name: lowercase, whitespace runs to single hyphens, anything outside [a-z0-9_-] dropped, ends trimmed. Unique across the machine — two names deriving one id is refused, not resolved. It is what the state file records, what runs/<id>/ is called, what an API path carries, and what the CLI answers to.
AgentA configured command template that accepts a prompt. Any CLI qualifies; OpenRoutine never talks to a model API itself.
DaemonThe single long-lived process that is the scheduler, the runner, the API, and the UI. The OS supervises it and schedules nothing.
ReloadThe moment the daemon re-reads its config and every registered file, whether or not anything changed — at startup, on openroutine reload, or after an add or remove. The only way a task that will not run — completed, disabled, or broken — ever notices that it was edited.
RefreshThe check a task makes on its own file at the moment it is about to run, by tick or by fire: a changed mtime, then a changed digest, and only then the new definition, adopted for that run. Confined to the run path and to one file — nothing is watched, nothing is polled, and a task that never fires never refreshes.
TickAn instant at which a task’s schedule comes due. Every tick becomes exactly one run or one skip.
RunA single execution of a task’s prompt by its agent, produced by a scheduled tick or by a fire.
SkipA tick that was not run, recorded with its reason: overlap, daemon-down, missed, paused, or definition-changed — a refresh found the tick no longer answerable. Never silent — every tick becomes exactly one run or one skip.
FireStarting a run on demand — via the API or UI — outside the schedule.
Dry runRendering a task’s fully resolved execution plan without spawning anything. A read, not a run; nothing is recorded.
Idle timeoutHow long a run may go without its agent saying anything. Measured from the last output, so work that is visibly progressing is never interrupted however long it takes, while a run that has hung is reaped. When it expires the run’s whole process group is ended and the run is recorded as timed out — never left hanging.
JitterThe deterministic offset between a task’s tick and the moment it fires, derived from the task id so it never changes between runs.
One-shotA task with no cron. With at: it fires once at that moment; without one it fires once as soon as the daemon takes it in. Either way it then becomes Completed.
CompletedA one-shot task that has answered its moment. Machine state only: editing the file gives it something to do again — at the next reload, since a task that will not run cannot refresh.
Catch-upOpting a task into running one tick it missed while the daemon was away. Off by default.
DisabledSwitched off in its own frontmatter. Not scheduled at all, so it accrues no ticks and no skips — unlike a paused task.
PausedHeld at runtime. Machine-owned state, never part of the definition; a task runs only when neither disabled nor paused. A tick that arrives while held becomes a skip, not a gap.
InterruptedA run whose daemon disappeared before it finished. Recorded on the next start, so no run is left claiming to be running forever.
ReadyA task whose definition parses, validates, and names an agent that exists. The only state from which a tick can produce a run.
BrokenA registered task whose file is missing, or whose definition fails to parse or validate. Always surfaced visibly with its error; never silently unscheduled.
FamiliarA task whose definition is the one that last started a run. A new or edited task is flagged instead — registering a file trusts whoever can edit it, so an arriving change is announced rather than blocked.