Skip to content

Repository files navigation

Routinely

CI

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 installing
  • routinely kick - runs the job immediately in the same environment as the scheduler
  • routinely install - installs the job as a native scheduled task
  • routinely status - shows the last run time and next scheduled run time
  • routinely 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

  1. Add routinely to your project.

    uv add routinely
  2. Add a job block to your pyproject.toml:

    [tool.routinely.digest]
    command  = "python -m digest.main"
    schedule = "daily at 06:00"
    catch_up = true
  3. 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 value
  • 5 — a single value
  • 1,15 — a list
  • 9-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 = true and KeepAlive = {SuccessfulExit = false} — launchd starts it at load/login and restarts it whenever it exits with a non-zero status. A clean exit 0 stays 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, and WantedBy=default.target — no .timer unit at all. install enables 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.
  • kickrestarts 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 when loginctl linger is off (without it the service stops at logout and won't start until you log in — fix with loginctl enable-linger), and on both platforms it warns if catch_up is 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 loginctl linger 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:

  1. routinely doctor — static checks: paths, permissions, syntax, loaded state. Catches most silent failures before anything runs.
  2. routinely kick — dynamic check: does the job actually work under the scheduler, right now?
  3. 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:

  1. The interpreter path is stale. You recreated .venv (e.g. fresh uv sync), so the path baked into the installed plist no longer exists. Run routinely doctor — the program check fails — then routinely install to re-render against the new interpreter.
  2. You edited config but didn't reinstall. Neither launchd nor systemd watches files: units are read at load time. routinely install again.
  3. The machine was asleep or off at the scheduled time and catch_up = false. That slot is simply gone. Set catch_up = true if a late run is better than no run.
  4. Interval schedules (every 15m) miss firings during sleep on macOS — per launchd.plist(5), that's inherent to StartInterval. Use a calendar schedule if catch-up matters.
  5. The job ran but crashed instantly. routinely status shows the last exit code; routinely logs shows stderr. A common cause: the job's own subprocesses need a PATH you haven't set in environment. Reproduce it on demand with routinely kick instead of waiting for the next fire.
  6. Linux: you logged out. systemctl --user timers stop at logout unless loginctl enable-linger is set. doctor warns about this.
  7. 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) LostStartInterval 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 queueschedule = "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.

About

Scheduled jobs that just work — launchd agents on macOS and systemd user timers on Linux, from one block in pyproject.toml. Zero dependencies, with real debugging tools (doctor, kick, status).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages