Routinely
Scheduled jobs that just work — on macOS and Linux.
Sometimes I just want something to run at a specific time, or I just want to make
sure it's always running. Routinely tries to make that simple by managing the
native scheduling systems of MacOS & Linux (launchd & systemd).
It's not doing anything fancy- it's just keeping me from having to look up syntax
for launchd and systemd every time I need to schedule something.
I've run into problems in the past where scheduled jobs would silently fail for unknown reasons, usually environment-related. Routinely tries to make that debugging process easier by providing tools to test the job in the same environment the scheduler will use, and log the output to help identify issues.
routinely doctor- checks for common issues before installingroutinely kick- runs the job immediately in the same environment as the schedulerroutinely install- installs the job as a native scheduled taskroutinely status- shows the last run time and next scheduled run timeroutinely uninstall- removes the job from the native scheduler
Add Routinely as a dependency, include a [tool.routinely] section in your
pyproject.toml, and run routinely install. After that, everything should
Just Work.
Contents
- Quickstart
- How it works
- Config reference
- Schedule syntax — phrases · cron
- Long-running services —
schedule = "always" - CLI reference
routinely doctor— preflight diagnosticsroutinely kick— test the job for real- Why does my job not run?
- launchd vs systemd semantics
- What Routinely is not
- Development
- Authorship
Quickstart
-
Add
routinelyto your project.uv add routinely
-
Add a job block to your
pyproject.toml:[tool.routinely.digest] command = "python -m digest.main" schedule = "daily at 06:00" catch_up = true
-
Install it:
uv run routinely install
The job will be installed at the appropriate location
(macOS: ~/Library/LaunchAgents/, Linux: ~/.config/systemd/user/)
and will start running on the specified schedule.
routinely render shows the exact unit file
before anything touches your system.
How it works
launchd agents are described in a relatively complex XML PList format.
By default, it doesn't expand environment variables or resolve ~ in paths.
It can be difficult to specify paths correctly in a plist, and Routinely makes
it easier by resolving paths at install time. Don't worry about virtual
environments or committing absolute paths - Routinely handles it for you.
Routinely renders the unit at install time from the
pyproject.tomlblock spec above, resolving paths against the project root, $HOME, and
the active interpreter (sys.executable) — so the committed config stays
machine-independent, and moving a project just means running
routinely install again. Preflight checks run before anything is written,
catching the failure modes launchd never reports.
Likewise, systemd services on Linux are described in their own
format.
Routinely handles the same path resolution and environment variable expansion
for systemd as well. With a couple exceptions (described below),
Routinely provides the same experience for both platforms.
Requires Python 3.11+. Zero runtime dependencies.
Example Routinely Blocks
You can probably copy and paste these into your pyproject.toml. Syntax docs
are below, but this is probably enough to get you started.
- Run daily at 2:00 AM
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "Daily at 2:00"
catch_up = true
- Run every hour, at X:00
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "Hourly"
catch_up = true
- Keep a long-running service always running
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "always"
- Run a job once a week on Monday at 3:00 AM
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "Weekly on Monday at 3:00"
catch_up = true
- Complex cron schedules (here: run every weekday at 6:00 AM, 9:00 AM, and 3:00 PM)
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "0 6,9,15 * * 1-5"
catch_up = true
Config reference
All keys live under [tool.routinely.<job-name>]. One block per job;
multiple blocks are fine.
| Key | Required | Default | Meaning |
|---|---|---|---|
command |
yes | — | Command line, shell-split. A leading python/python3/python3.x is replaced with the interpreter running routinely install — so uv run / an active venv "just works". A leading relative path resolves against the project root; a bare command is looked up on PATH at install time. |
schedule |
yes | — | 5-field cron ("0 6 * * *"), one of four scheduled phrase forms, or "always" for a long-running service. Full grammar: Schedule syntax. |
catch_up |
no | false |
Run a missed slot when the machine comes back. See the semantics table below — this is the key whose meaning differs most across platforms. |
label |
no | local.<project-name>.<job-name> |
Unit identity: launchd label / systemd unit basename. Uninstall works by label even after the project moves. |
working_directory |
no | "." (project root) |
Relative paths resolve against the project root; ~ expands at install time. |
stdout, stderr |
no | macOS: ~/Library/Logs/<job>/{out,err}.log; Linux: the journal |
Log destinations. Parent directories are created for you. |
environment |
no | {} |
Extra environment variables. Scheduled jobs inherit no shell environment; set PATH explicitly if your job spawns other tools. |
The project root is the git toplevel, or the directory containing
pyproject.toml if there's no git repo.
Schedule syntax
A schedule string is either 5-field cron or one of five phrase
forms. The phrases are Routinely's own (there is no external standard for
them); this section is their complete definition, and
src/routinely/schedule.py is the source of truth. Parsing is
case-insensitive and whitespace-insensitive ("Daily at 6:00" is fine).
Anything that isn't a recognized phrase is parsed as cron; anything invalid
is rejected at render/install time with an error naming the bad field —
nothing falls through silently.
Phrase forms
| Phrase | Grammar | Equivalent | Meaning |
|---|---|---|---|
"hourly" |
exactly that word | cron 0 * * * * |
at minute 0 of every hour |
"daily at HH:MM" |
HH = 0–23 (one or two digits), MM = exactly two digits 00–59 |
cron MM HH * * * |
once a day at that local time |
"weekdays at HH:MM" |
same time rule | cron MM HH * * 1-5 |
Monday–Friday at that local time |
"every N <unit>" |
N ≥ 1; unit s/sec(s)/second(s), m/min(s)/minute(s), h/hr(s)/hour(s); space before the unit optional (15m or 15 minutes) |
— no cron equivalent | an interval, not a calendar time |
"always" |
exactly that word | — not a schedule at all | a long-running service: no timer, the init system starts it at login/boot and restarts it on failure — see Long-running services |
That's the whole phrase language — there is deliberately no "monthly",
"every tuesday", or natural-language date parsing. Anything beyond these
five shapes is cron's job.
Calendar vs interval matters. The first three phrases produce calendar
schedules (launchd StartCalendarInterval / systemd OnCalendar), which
support catch-up semantics. every N … produces an interval (launchd
StartInterval / systemd OnBootSec + OnUnitActiveSec), which fires
"every N since the last run" with no fixed wall-clock alignment — and on
macOS, interval firings missed during sleep are simply lost (see the
semantics table below).
Cron form
Standard 5 fields — minute hour day-of-month month day-of-week — with the
usual constructs per field:
*— any value5— a single value1,15— a list9-17— an inclusive range*/15,9-17/2,5/20— steps over a range (5/20= from 5 to the field max, every 20)
Ranges: minute 0–59, hour 0–23, day 1–31, month 1–12, weekday 0–7 (0 and 7
are both Sunday; numeric only — mon/jan names are not supported).
Descending ranges (5-1) are rejected rather than wrapped.
One classic cron subtlety is preserved on both platforms: if both
day-of-month and day-of-week are restricted, the job runs when either
matches (0 6 1,15 * 1 = the 1st, the 15th, and every Monday).
All times are local time. There is no timezone field and no seconds field.
Long-running services: schedule = "always"
Some things aren't jobs — a local web server, a watcher, a bridge process —
they should just be running. schedule = "always" covers that case with
the same spec and the same commands:
[tool.routinely.serve]
command = "uv run --no-dev uvicorn --factory plug_rest.app:create_app --host 0.0.0.0 --port 8000"
schedule = "always"
Instead of a timer pair, Routinely renders a plain service unit and lets the init system do the supervising:
- macOS: a launchd agent with
RunAtLoad = trueandKeepAlive = {SuccessfulExit = false}— launchd starts it at load/login and restarts it whenever it exits with a non-zero status. A cleanexit 0stays stopped (that's your off switch from inside the process). - Linux: a systemd user service with
Restart=on-failure,RestartSec=3,After=network-online.target, andWantedBy=default.target— no.timerunit at all.installenables and (re)starts the service.
The commands map naturally:
install/uninstall/render/logs— unchanged.status— shows whether the service is loaded and running (with its pid) instead of a next-fire time.kick— restarts the service (launchctl kickstart -k/systemctl restart), which is what you want after a code change.doctor— same path/syntax checks, plus: on Linux it warns whenloginctllinger is off (without it the service stops at logout and won't start until you log in — fix withloginctl enable-linger), and on both platforms it warns ifcatch_upis set, which is meaningless for a service that's always running.
Reinstalling an always-service restarts it, so routinely install is also
the "deploy the config change" command.
One honesty note on parity: systemd restarts the service on any failure,
including abnormal signals; launchd's SuccessfulExit = false keys off the
exit status. And restart pacing differs — systemd waits RestartSec=3 and
applies its start-rate limiting, while launchd throttles respawns on its own
(roughly a 10-second minimum between starts).
CLI reference
routinely install [name] # render → validate → install → load. Idempotent.
routinely render [name] [--platform launchd|systemd] # print unit(s), touch nothing
routinely doctor [name] [--json] # preflight diagnostics (see below)
routinely kick [name] [--restart] # run the job NOW, in the real scheduled environment
routinely status [name] [--json] # loaded? last exit? next fire? — same view on both platforms
routinely logs [name] [-f] [-n N]
routinely uninstall [name] [--label LABEL] # by label; works after the project moves
With no name, commands operate on every job in the project. Everything is
user scope (~/Library/LaunchAgents, systemctl --user) — no sudo.
For schedule = "always" services, kick restarts the service and status
reports running state (pid) instead of a next-fire time.
install is a reinstall when the job already exists (unload → rewrite →
reload), so it's also how you apply config changes. It refuses to install if
any preflight check fails, and tells you what to fix.
For scripts and agents: status --json and doctor --json emit
structured JSON instead of the human formatting. Exit codes are meaningful
everywhere: doctor exits 1 if any check fails, status exits 1 if any
selected job is not loaded, install exits 1 on refusal or error, and all
commands exit 0 on success. Nothing ever prompts interactively, install is
safe to retry, and kick lets an automated caller verify a job end-to-end
without waiting for its schedule.
routinely doctor - preflight diagnostics
Every check corresponds to a real way launchd jobs die silently:
$ routinely doctor
✓ label com.evanjones.paperdigest
✓ program .../.venv/bin/paper-digest -> .../.venv/bin/python3
✓ working dir /Users/…/daily_scholar_digest
✓ log dir /Users/…/Library/Logs/paper-digest (created)
✓ unit syntax plutil -lint OK
✓ loaded yes · last exit 0 · next fire 2026-07-22 06:00
- program exists and is executable — the #1 cause of a silently-dead agent
- working directory exists
- log parent directories exist (created if missing — launchd won't)
- rendered unit passes
plutil -lint(macOS) /systemd-analyze verify(Linux) - loaded state, last exit code, next fire time
- Linux: warns if
loginctllinger is off (user timers stop at logout) - warns when a cron expression explodes combinatorially on launchd (see below)
routinely kick — test the job for real
Running your command in a terminal proves almost nothing about how it behaves
under the scheduler: your shell has a full PATH, your environment variables,
your working directory. The scheduled run has none of that — launchd
starts jobs with an empty environment, and "works in my terminal, dies at 6am"
is the classic launch-agent debugging time sink.
kick asks the init system itself to run the job immediately
(launchctl kickstart / systemctl --user start), so it executes with
exactly the scheduled run's environment, working directory, and log
destinations:
routinely kick # fire the job now
routinely logs -f # watch what it did
routinely status # …and how it exited
--restart kills a currently-running instance first (kickstart -k /
systemctl restart); without it, kick only starts the job if it isn't
already running.
Two things kick deliberately does not do:
- It doesn't bypass your app's own run policy. If the job internally decides
"already ran today, nothing to do," a kick runs the process and the process
declines — which is itself a useful test. Give your app a force flag (e.g.
--once) if you need to override its policy. - It doesn't simulate the environment by re-spawning the process itself. The init system is the only thing that ever runs your job, so what you debug is what ships.
The debugging loop, in order:
routinely doctor— static checks: paths, permissions, syntax, loaded state. Catches most silent failures before anything runs.routinely kick— dynamic check: does the job actually work under the scheduler, right now?routinely logs/routinely status— what happened and how it exited.
Why does my job not run?
launchd fails silently — no error, no log, the job just never fires. In rough order of likelihood:
- The interpreter path is stale. You recreated
.venv(e.g. freshuv sync), so the path baked into the installed plist no longer exists. Runroutinely doctor— the program check fails — thenroutinely installto re-render against the new interpreter. - You edited config but didn't reinstall. Neither launchd nor systemd
watches files: units are read at load time.
routinely installagain. - The machine was asleep or off at the scheduled time and
catch_up = false. That slot is simply gone. Setcatch_up = trueif a late run is better than no run. - Interval schedules (
every 15m) miss firings during sleep on macOS — perlaunchd.plist(5), that's inherent toStartInterval. Use a calendar schedule if catch-up matters. - The job ran but crashed instantly.
routinely statusshows the last exit code;routinely logsshows stderr. A common cause: the job's own subprocesses need aPATHyou haven't set inenvironment. Reproduce it on demand withroutinely kickinstead of waiting for the next fire. - Linux: you logged out.
systemctl --usertimers stop at logout unlessloginctl enable-lingeris set.doctorwarns about this. - macOS said "Background Items Added" and someone clicked it off in System Settings → General → Login Items. Re-enable it there.
launchd vs systemd: same knob, different guarantees
Routinely maps one spec to both platforms honestly rather than pretending parity:
| Situation | launchd (macOS) | systemd (Linux) |
|---|---|---|
| Slot missed while asleep (calendar) | Fires on next wake; multiple missed slots coalesce into one | Persistent=true catches it at next timer evaluation |
| Slot missed while powered off (calendar) | Covered only by RunAtLoad → runs at next login |
Persistent=true runs it at next boot (last-trigger time is tracked on disk) |
| Extra runs when nothing was missed | Yes: catch_up = RunAtLoad, which fires at install and at every login — launchd keeps no last-run state |
No: Persistent fires only if a slot was actually missed |
| Slot missed while asleep (interval) | Lost — StartInterval limitation |
Timer resumes; OnBootSec restarts it after reboot |
| Schedule expressiveness | Single integers per field: */15 9-17 * * 1-5 expands to 180 calendar dicts (Routinely warns past 24 and suggests an interval) |
OnCalendar expresses lists/ranges compactly |
| DST / timezone change | Cached next-fire date can be wrong until reload | Recomputed |
| Logs | Files (launchd has no journal) | journald (routinely logs wraps journalctl) |
Keep-alive for schedule = "always" |
KeepAlive SuccessfulExit=false: restarts on non-zero exit; a clean exit 0 stays stopped; launchd self-throttles respawns (~10 s) |
Restart=on-failure (any failure incl. signals) with RestartSec=3; systemd start-rate limiting applies |
Last exit code in status |
Reported after every run | Reported only after failures. A successful oneshot resets its exec state to the same zeros as a never-ran service (verified live), and the fix — RemainAfterExit=yes — would stop the timer and kick from re-triggering the job. Success is inferred from the journal (routinely logs), not the exit code. |
| Unit syntax preflight | plutil -lint (always available) |
systemd-analyze verify --user needs systemd ≥ 250 (Ubuntu 24.04+); on older hosts doctor downgrades the check to a warning |
The practical consequence: a catch_up = true job must tolerate being
started when there's nothing to do — e.g. "already ran today? exit 0".
That's by design: Routinely schedules opportunities to run; whether a
run is actually due is application state, and only the application can judge
it.
macOS bonus: install points the job at a descriptively-named symlink to the
interpreter, so Login Items and ps show your job's name instead of
"python".
What Routinely is not
- Not a run-policy engine — "at most once per day" logic belongs in your app, which owns the state that defines "done".
- Not a supervisor itself and not a task queue —
schedule = "always"delegates keep-alive to launchd/systemd rather than running any Routinely process of its own; if you need process groups, dependency graphs, or managed restart policies beyond "restart on failure", see supervisord, Celery et al. Scope here is unit-file lifecycle: render, validate, install, inspect, remove.
Development
just test # run the test suite
just run … # run the CLI from source
Renderers are pure functions and both are tested on every host platform; no
test touches launchctl, systemctl, or your LaunchAgents directory.
Contributing with an AI agent? Start with AGENTS.md — it holds
the invariants and the list of behaviors that look like bugs but aren't.
Authorship
Routinely was pair-programmed with Claude Code
(Claude Fable 5), with every change human-directed and human-reviewed; the
Co-Authored-By trailers in the git history mark the AI's hand, and
CONVERSATION.md is a running log of the collaboration —
including the design arguments and the live findings on real hardware.
Trust, though, should come from the evidence rather than the byline: the
behavior documented here is backed by a unit suite that runs on every
commit, live validation against real launchd and systemd (including the
platform quirks in the semantics table above, several of which were
discovered empirically), and a production job that has been running on this
code throughout its development.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file routinely-0.2.0.tar.gz.
File metadata
- Download URL: routinely-0.2.0.tar.gz
- Upload date:
- Size: 127.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bfe978b855958b2f7d2ae549606b052aa308f10038b5f2b8eb1709305c21e6aa
|
|
| MD5 |
a6982b5be16282682c4cfbe90441eb3d
|
|
| BLAKE2b-256 |
d054f9fd4dbe5f4094429bc35b67a7cb63b25a7be75b0f39d658587defeddd9d
|
File details
Details for the file routinely-0.2.0-py3-none-any.whl.
File metadata
- Download URL: routinely-0.2.0-py3-none-any.whl
- Upload date:
- Size: 31.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ada38d38e24edbd62fba3c673918b9d99b2b81c3f587b82413b6339b33a6a9d7
|
|
| MD5 |
59c170494d9fb3bda845f4432bb9ca6e
|
|
| BLAKE2b-256 |
29526da36d4500a94878d058889ff11a788587bb5ca3fa82fe12de448e785cd8
|