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
| Key | What it does |
|---|---|
name | What 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. |
description | What the task is for. Shown in the CLI and the web UI. Required. |
cron | A crontab expression, e.g. "0 2 * * *". Mutually exclusive with at. |
at | A single moment, RFC 3339. Fires once, then the task is Completed. Mutually exclusive with cron. |
catch_up | Run one tick missed while the daemon was away, if it is recent enough to still be wanted. Off by default. |
disabled | Switched 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. |
agent | Which configured agent runs it. Falls back to default_agent. |
jitter | How far the fire time may be nudged, deterministically. Accepts 2m, 30s, or a bare 0 for exact ticks. |
cwd | Where the agent starts. Absolute paths are fine; relative ones resolve against the task file’s directory, which is also the default. |
model | Injected through the agent template’s {model} placeholder, as a single argument. |
permission_mode | Injected through the {permission_mode} placeholder, as a single argument. |
env | Environment 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
| Key | Default | What 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_agent | unset | The agent for tasks that name none. Unset by default, so an omitted agent: leaves a task Broken until you opt in. |
env | empty | Environment handed to every run, layered over the login shell’s own and under whatever the task itself sets. |
max_parallel | unset | How many runs may be in flight at once, across every task. Unset means whatever the hardware tolerates. |
max_runs_per_task | 50 | How many runs of each task are kept on disk. |
max_log_bytes | 100 MiB | How much of a run’s output is kept before the log is truncated. |
idle_timeout | 15m | How 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. |
bind | loopback | Where 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.
| Command | What it does |
|---|---|
serve | Run the scheduler in the foreground. |
list | Show 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. |
init | Write a starter config and print a sample task. Registers nothing. |
status | Summarise 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. |
install | Register the daemon with your service manager, without sudo. --print shows what would be written instead of writing it. |
uninstall | Unregister the daemon. Config, state, and tasks are left alone. |
dashboard | Open the local web UI in a browser, signed in. |
token | Print 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
| Route | What it does |
|---|---|
GET /v1/tasks | Every registered task, with schedule and next fire time. |
GET /v1/tasks/{id} | One task in detail. |
GET /v1/tasks/{id}/runs | That task’s run history. |
POST /v1/tasks/{id}/fire | Start a run. Returns a run id and its log path. |
GET /v1/runs/{id}/{run} | One run in detail. |
GET /v1/runs/{id}/{run}/log | That run’s log. |
GET /v1/runs/{id}/{run}/log/stream | Live-tail the log over SSE. |
POST /v1/runs/{id}/{run}/cancel | Stop a run in flight. |
POST /v1/tasks/{id}/pause | Hold one task. |
POST /v1/tasks/{id}/resume | Release one task. |
POST /v1/pause | Hold every task on this machine. |
POST /v1/resume | Release everything. |
POST /v1/reload | Re-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.
| Term | Meaning |
|---|---|
Task | A 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. |
Id | A 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. |
Agent | A configured command template that accepts a prompt. Any CLI qualifies; OpenRoutine never talks to a model API itself. |
Daemon | The single long-lived process that is the scheduler, the runner, the API, and the UI. The OS supervises it and schedules nothing. |
Reload | The 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. |
Refresh | The 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. |
Tick | An instant at which a task’s schedule comes due. Every tick becomes exactly one run or one skip. |
Run | A single execution of a task’s prompt by its agent, produced by a scheduled tick or by a fire. |
Skip | A 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. |
Fire | Starting a run on demand — via the API or UI — outside the schedule. |
Dry run | Rendering a task’s fully resolved execution plan without spawning anything. A read, not a run; nothing is recorded. |
Idle timeout | How 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. |
Jitter | The deterministic offset between a task’s tick and the moment it fires, derived from the task id so it never changes between runs. |
One-shot | A 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. |
Completed | A 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-up | Opting a task into running one tick it missed while the daemon was away. Off by default. |
Disabled | Switched off in its own frontmatter. Not scheduled at all, so it accrues no ticks and no skips — unlike a paused task. |
Paused | Held 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. |
Interrupted | A run whose daemon disappeared before it finished. Recorded on the next start, so no run is left claiming to be running forever. |
Ready | A task whose definition parses, validates, and names an agent that exists. The only state from which a tick can produce a run. |
Broken | A registered task whose file is missing, or whose definition fails to parse or validate. Always surfaced visibly with its error; never silently unscheduled. |
Familiar | A 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. |