OpenRoutine

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

KeyWhat it does
descriptionWhat the task is for. Shown in the CLI, the web UI, and CRONTAB.md.
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.
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.
timeoutThe wall-clock budget for a run, or none to let it take as long as it takes. Falls back to default_timeout.
cwdWhere the agent starts, relative to the project root when relative.
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.

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

KeyDefaultWhat it does
projects[].pathA directory to scan for *.cron.md files.
projects[].namebasenameUnique short name, forming the first half of every task id. May not be empty or contain /.
projects[].crontab_mdWhether to keep a CRONTAB.md at this project’s root. OpenRoutine never insists on writing into someone’s repository.
projects[].max_runs_per_taskRuns kept per task here, overriding the global setting.
agents.<name>.cmdThe 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.
default_timeout1hThe 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.
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.

CommandWhat it does
serveRun the scheduler in the foreground.
listShow 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 .
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.
openOpen the local web UI in a browser.
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/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

RouteWhat it does
GET /v1/tasksEvery discovered task, with schedule and next fire time.
GET /v1/tasks/{project}/{name}One task in detail.
GET /v1/tasks/{project}/{name}/runsThat task’s run history.
POST /v1/tasks/{project}/{name}/fireStart 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}/logThat run’s log.
GET /v1/runs/{project}/{name}/{run}/log/streamLive-tail the log over SSE.
POST /v1/runs/{project}/{name}/{run}/cancelStop a run in flight.
POST /v1/tasks/{project}/{name}/pauseHold one task.
POST /v1/tasks/{project}/{name}/resumeRelease one task.
POST /v1/pauseHold every task on this machine.
POST /v1/resumeRelease 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.

TermMeaning
TaskA 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.
AgentA configured command template that accepts a prompt. Any CLI qualifies; OpenRoutine never talks to a model API itself.
ProjectA directory registered with the daemon and scanned for task files. Its name forms the first half of every task id.
DaemonThe single long-lived process that is the scheduler, the runner, the API, and the UI. The OS supervises it and schedules nothing.
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, or paused. Never silent.
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.
TimeoutThe 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.
JitterThe deterministic offset between a task’s tick and the moment it fires, derived from the task id so it never changes between runs.
ManualA task with no schedule — neither cron nor at — that runs only when fired.
One-shotA task scheduled by a single future timestamp (at). It fires once, then becomes Completed. Not the same as Manual.
CompletedA one-shot task that has answered its moment. Machine state only: editing the timestamp gives it something to do again.
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 task whose file exists but 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 project trusts its committers, so an arriving task is announced rather than blocked.
CRONTAB.mdThe 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.