Skip to content
agentic-pipelines

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:

yaml
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: number

The email channel is declared once, in the data repo's pipelines.yaml; see Notifications.

Installing

bash
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>.timer

install 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:

yaml
triggers:  - cron: "0 8 * * *"

To run it at 03:00 instead, change that one line in pipelines/site-checks/pipeline.yaml:

yaml
triggers:  - cron: "0 3 * * *"

Then reinstall it, and check the new schedule:

bash
pipelines install site-checks --dry-run# shows the job it would write, with the new timepipelines install site-checks

Reinstalling 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>.plistcat.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/)
CadenceStartCalendarIntervalone OnCalendar= line per schedule in the .timer
Missed runsRunAtLoadPersistent=true
Activateloads the plist with launchctlsystemctl --user daemon-reload and enable --now <label>.timer
Deactivateunloads the job and deletes the plistdisable --now <label>.timer, deletes the .service and the .timer, daemon-reload
Shell steps run withsh -cbash --posix -c
Pre-install checknonelinger
  • 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 (for 0 8 1 * 1: *-*-01 08:00:00 and Mon *-*-* 08:00:00).
  • The service. Type=oneshot; ExecStart is bun run <index.ts> --repo <repo> run <name>, with PATH and HOME set in Environment=; stdout and stderr go to the log with append:. install creates the log directory, because systemd doesn't. A log path with spaces is rejected (systemd doesn't accept quotes in append:).
  • The job freezes the path it was installed from. The plist's ProgramArguments (on Linux, the .service's ExecStart) holds the absolute path of index.ts at install time. 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.

bash
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

bash
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.

Written from agentic-pipelines at b8b4db7: README.md, docs/reference.md; pipelines-starter at 0b4f303: README.md; pipelines-starter at 95c134f: pipelines/site-checks/pipeline.yaml.