Routinely
Scheduled jobs that just work — on macOS and Linux, with zero dependencies.
Routinely turns "run this program every morning at six" into one block in
your pyproject.toml and one command. It installs real, native scheduled
jobs — launchd agents on macOS, systemd user timers on Linux — so your job
survives reboots and sleeps the way OS services do, without you ever writing
a plist or a unit file. And because native schedulers fail silently,
Routinely ships the debugging kit they forgot: doctor catches every known
silent-failure mode before install, kick runs your job right now in the
exact environment the scheduler will use, and status answers "did it run,
and when's the next one?" identically on both platforms. Pure standard
library — adding Routinely adds nothing else.
Contents
- Quickstart
- How it works
- Config reference
- Schedule syntax — phrases · cron
- 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 (as a dev dependency is fine):uv add --dev 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
That's a working daily job. routinely render shows the exact unit file
before anything touches your system.
How it works
launchd expands nothing in a plist: no ~, no $HOME, no environment
variables. Every path must be absolute and literal, so a working plist
necessarily contains /Users/<you>/... and can't be committed to a shared
repo. Routinely instead renders the unit at install time from the
path-free 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.
Requires Python 3.11+. Zero runtime dependencies (stdlib only).
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 * * *") or one of four phrase forms. 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 four 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 |
That's the whole phrase language — there is deliberately no "monthly",
"every tuesday", or natural-language date parsing. Anything beyond these
four 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.
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.
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
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) |
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 process backgrounder: no double-forking, no PEP 3143 detaching — the init system owns the process.
- Not a supervisor and not a task queue — 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.1.0.tar.gz.
File metadata
- Download URL: routinely-0.1.0.tar.gz
- Upload date:
- Size: 59.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0aaa0143adb1bd87a4a17e5cd4ae4516b087dccc4510bfe4e244270aa0320c43
|
|
| MD5 |
e89bd39458c2617e40fa1abff7c436a2
|
|
| BLAKE2b-256 |
c7e2fe2fd2916df3f1249682960aa1b6dace17236a2cdd68ab42a2863648e9e2
|
File details
Details for the file routinely-0.1.0-py3-none-any.whl.
File metadata
- Download URL: routinely-0.1.0-py3-none-any.whl
- Upload date:
- Size: 28.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37f018914b236f4752082f002c74744dc720a08b0f5fe17af85ebc587f3ede88
|
|
| MD5 |
7234f54213fcfd256b641ac334b8ffae
|
|
| BLAKE2b-256 |
a6b099ebfdfbf760b978520b2616b3f9cdda4e8c42b83a852c72be03075cc8b2
|