Docs
Everything OpenRoutine does is driven by two plain-text files: a *.cron.md
task you own, and a config.toml that says where to look and what an agent is.
Install
Requires a Rust toolchain; macOS and Linux only. Windows is an explicit non-goal.
git clone https://github.com/soulmachine/openroutine
cd openroutine
cargo install --path . # or: cargo build --release
openroutine init . # writes a config and a sample task
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, registers the
directory you name, and leaves a sample hello.cron.md beside it. Nothing else
to configure.
First five minutes
What openroutine init prints, and what to do with it.
Wrote config …/config.toml
Wrote sample task …/hello.cron.md
Try: 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 *.cron.md file. The frontmatter is the metadata, the body
is the prompt, and the file is the complete definition. Its identity is
<project>/<filename stem> — renaming or moving the file creates a
new task.
---
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 |
|---|---|
description | What the task is for. Shown in the CLI, the web UI, and CRONTAB.md. |
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. |
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. |
timeout | The wall-clock budget for a run, or none to let it take as long as it takes. Falls back to default_timeout. |
cwd | Where the agent starts, relative to the project root when relative. |
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.
A task with neither cron nor at is Manual — it
runs only when fired, from the API or the UI.
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"
# Directories whose *.cron.md files should be scheduled.
[[projects]]
path = "/Users/you/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 |
|---|---|---|
projects[].path | — | A directory to scan for *.cron.md files. |
projects[].name | basename | Unique short name, forming the first half of every task id. May not be empty or contain /. |
projects[].crontab_md | — | Whether to keep a CRONTAB.md at this project’s root. OpenRoutine never insists on writing into someone’s repository. |
projects[].max_runs_per_task | — | Runs kept per task here, overriding the global setting. |
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. |
default_timeout | 1h | The timeout for tasks that name none. An unattended agent that hangs is the worst failure mode, so runs are bounded unless the author says otherwise. |
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 <dir> | Register a directory so its tasks are scheduled. --name gives it a name; defaults to the directory’s own. |
remove <dir> | Stop watching a directory. |
init [dir] | Write a starter config and a sample task. Defaults to . |
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. |
open | Open the local web UI in a browser. |
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/myrepo/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 discovered task, with schedule and next fire time. |
GET /v1/tasks/{project}/{name} | One task in detail. |
GET /v1/tasks/{project}/{name}/runs | That task’s run history. |
POST /v1/tasks/{project}/{name}/fire | Start a run. Returns a run id and its log path. |
GET /v1/runs/{project}/{name}/{run} | One run in detail. |
GET /v1/runs/{project}/{name}/{run}/log | That run’s log. |
GET /v1/runs/{project}/{name}/{run}/log/stream | Live-tail the log over SSE. |
POST /v1/runs/{project}/{name}/{run}/cancel | Stop a run in flight. |
POST /v1/tasks/{project}/{name}/pause | Hold one task. |
POST /v1/tasks/{project}/{name}/resume | Release one task. |
POST /v1/pause | Hold every task on this machine. |
POST /v1/resume | Release everything. |
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 ~/tasks # config, and a sample 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 .cron.md file. Its identity is <project>/<filename stem>; renaming or moving the file creates a new task. |
Agent | A configured command template that accepts a prompt. Any CLI qualifies; OpenRoutine never talks to a model API itself. |
Project | A directory registered with the daemon and scanned for task files. Its name forms the first half of every task id. |
Daemon | The single long-lived process that is the scheduler, the runner, the API, and the UI. The OS supervises it and schedules nothing. |
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, or paused. Never silent. |
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. |
Timeout | The wall-clock budget for a single run. 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. |
Manual | A task with no schedule — neither cron nor at — that runs only when fired. |
One-shot | A task scheduled by a single future timestamp (at). It fires once, then becomes Completed. Not the same as Manual. |
Completed | A one-shot task that has answered its moment. Machine state only: editing the timestamp gives it something to do again. |
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 task whose file exists but 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 project trusts its committers, so an arriving task is announced rather than blocked. |
CRONTAB.md | The table OpenRoutine keeps at each project’s root: every task there, with its description, schedule, and agent. Derived from definitions alone, so it is safe to commit. |