Putting a pipeline on a schedule
The engine has no scheduler: there's no background process that reads triggers.cron and fires anything on its own. What it has is pipelines install, which translates triggers.cron into a launchd plist on macOS, or a .service + .timer pair for systemd on Linux, and loads it. The YAML is the single source of truth for the cadence: the plist or the units don't duplicate it, they derive from it.
A scheduled pipeline
This one reports on a repository every night at 03:00, only when there's something new, and notifies through a channel called email:
name: nightly-reportdescription: Summarizes the day's commits in a repository and emails the result.version: 1params: repo: description: Path to the repository to report on. required: truerequires: bin: [git, jq]triggers: - cron: "0 3 * * *"when: - throttle: 20h - changed: path: "{{params.repo}}" since: last_successnotify: on: [failed] stale_after: 72h channel: emailsteps: - id: collect type: shell run: | set -euo pipefail n=$(git -C "{{params.repo}}" log --since=24.hours --oneline | wc -l | tr -d ' ') jq -n --arg n "$n" '{commits: ($n | tonumber)}' outputs: commits: numberThe email channel is declared once, in the data repo's pipelines.yaml; see Notifications.
Installing
pipelines doctor <name> [--set key=value ...]pipelines install <name> --set key=value --dry-run# prints the plist (or units), touches nothingpipelines install <name> --set key=valuelaunchctl print gui/$(id -u)/cat.start.pipelines.<name># on Linux: systemctl --user status cat.start.pipelines.<name>.timerinstall writes the params to .params.local.json, generates the plist (or the units) with no values inside, computes the PATH from requires.bin and from the command of any mcp_servers, and checks that this PATH resolves everything before writing anything. It warns about optional params without a value and the steps they affect. It stops if doctor is red, if it detects someone else's job that seems to do the same thing and, on Linux, if linger is off. --force overrides the stop, but still warns.
--dry-run goes through the same validations, prints the files it would write and exits without touching anything: not the job files, not .params.local.json even if it gets --set, not the scheduler.
The cron subset
Only the subset of cron that launchd and systemd can both express is translated: *, fixed values and lists (0,30). Ranges (1-5) and steps (*/15) are rejected with an explicit error, on both platforms: install prefers failing over installing something that runs at a different time than the YAML says. For weekdays, write the list: 0 9 * * 1,2,3,4,5.
Schedules are read in the machine's local time. On a Linux server that came set to UTC, set the time zone first (Install on Linux).
Moving a pipeline to the night
To change when a pipeline runs, edit triggers in its pipeline.yaml and install it again. The starter's site-checks, for example, comes scheduled every day at 08:00:
triggers: - cron: "0 8 * * *"To run it at 03:00 instead, change that one line in pipelines/site-checks/pipeline.yaml:
triggers: - cron: "0 3 * * *"Then reinstall it, and check the new schedule:
pipelines install site-checks --dry-run# shows the job it would write, with the new timepipelines install site-checksReinstalling a pipeline replaces its job and says so. You don't need to repeat your --set options: they are saved in .params.local.json, and the other settings are kept when you change one.
A run that should have happened while the machine was off is repeated only if the pipeline has a throttle guard: that's what makes repeating a missed run harmless. Without a throttle, the engine doesn't emit RunAtLoad (macOS) or Persistent=true (systemd), and a missed run isn't repeated. On systemd, Persistent=true repeats a missed run once.
How each platform does it
| macOS (launchd) | Linux (systemd) | |
|---|---|---|
| Job files | ~/Library/LaunchAgents/cat.start.pipelines.<name>.plist | cat.start.pipelines.<name>.service and .timer in ~/.config/systemd/user/ (or $XDG_CONFIG_HOME/systemd/user/) |
| Log | ~/Library/Logs/pipelines-<name>.log | ~/.local/state/pipelines/logs/pipelines-<name>.log (or $XDG_STATE_HOME/pipelines/logs/) |
| Cadence | StartCalendarInterval | one OnCalendar= line per schedule in the .timer |
| Missed runs | RunAtLoad | Persistent=true |
| Activate | loads the plist with launchctl | systemctl --user daemon-reload and enable --now <label>.timer |
| Deactivate | unloads the job and deletes the plist | disable --now <label>.timer, deletes the .service and the .timer, daemon-reload |
| Shell steps run with | sh -c | bash --posix -c |
| Pre-install check | none | linger |
- Day of month and day of week together. Cron joins them with OR; a single systemd
OnCalendar=line joins them with AND, so the engine emits two lines (for0 8 1 * 1:*-*-01 08:00:00andMon *-*-* 08:00:00). - The service.
Type=oneshot;ExecStartisbun run <index.ts> --repo <repo> run <name>, withPATHandHOMEset inEnvironment=; stdout and stderr go to the log withappend:.installcreates the log directory, because systemd doesn't. A log path with spaces is rejected (systemd doesn't accept quotes inappend:). - The job freezes the path it was installed from. The plist's
ProgramArguments(on Linux, the.service'sExecStart) holds the absolute path ofindex.tsatinstalltime. Always install from the checkout that is going to persist, not from a git worktree you'll delete.
Taking over from an old cron job
When a pipeline replaces a script that was already running on its own, the handover is manual, and launchctl disable is not optional. A bootout only unloads the job from the current session: without disable, the old job comes back at the next boot and the work gets done twice.
launchctl bootout gui/$(id -u)/<old-label>launchctl disable gui/$(id -u)/<old-label>launchctl print-disabled gui/$(id -u) | grep <old-label># must say "disabled"On Linux the equivalent is systemctl --user disable --now <old-unit>.timer. Don't leave out --now: disable alone only removes the automatic start, and the timer keeps firing until the next boot (a daemon-reload doesn't stop it either).
If you have anything that watches your list of scheduled jobs, remove the old label from it too, or it will report a "missing" job that you retired on purpose.
Don't count on pipelines install to catch this collision for you. It runs two scans: the first compares the paths in the params against the contents of the other plists, but since each pipeline has its own workspace it usually shares no path with the script it replaces; the second looks for other enabled jobs whose label ends in .<name>, so it only helps if the old job happens to be named like the pipeline. Beyond those two checks there's no safety net: it's up to you to disable the old job and confirm it with print-disabled.
Leave the old script and plist on disk until the new pipeline has run green for a few nights: they're your way back.
Uninstalling
pipelines uninstall <name># unloads the job and deletes its plist (or .service and .timer)uninstall always unloads the job, even if its files no longer exist, and deletes them if it finds them. It never uses launchctl disable or systemctl mask, doesn't touch .params.local.json or the logs, and is safe to run twice.