Skip to content

launchd plist template

The framework’s scheduling layer. Three files, fully documented.

agents.conf is the shipped fleet source, and the effective runtime roster is agents.conf plus enabled rows from $ALFRED_HOME/state/custom-agents/custom-agents.json. deploy.sh runs the renderer over that effective roster, producing one plist per row, then bootstraps each one into launchd.

flowchart LR
    conf["launchd/agents.conf<br/><i>one TSV row per agent</i>"]
    tmpl["launchd/_template.plist<br/><i>__TOKEN__ placeholders</i>"]
    render["render.sh<br/><i>token substitution</i>"]
    plists["~/Library/LaunchAgents/<br/>alfred.{codename}.plist"]
    lc["launchctl bootstrap"]
    sched["launchd timer<br/><i>fires bin/{role}.py on schedule</i>"]

    conf --> render
    tmpl --> render
    render --> plists
    plists --> lc
    lc --> sched

The same shape runs on Linux: systemd/render.sh reads the same agents.conf and emits .service + .timer units instead of plists, and lib/scheduler.py abstracts launchctl vs systemctl --user behind one interface. See Linux for the systemd path.

Each non-comment, non-blank line is a record with up to six tab-separated fields:

Field What
1. label launchd job label (also the .plist filename stem)
2. script python file in $ALFRED_HOME/bin/ to invoke
3. schedule one of: interval:<seconds> / cron:<HH>:<MM> (daily) / cron:<weekday>:<HH>:<MM> (weekly; 0=Sun)
4. needs_java yes or no. yes prepends openjdk@21 + fnm bins to PATH and sets JAVA_HOME
5. log_stem basename used for /tmp/<stem>.{stdout,stderr}. Empty falls back to label
6. role one-line operational descriptor surfaced in alfred agents and Slack post prefixes

Example:

alfred.senior-dev senior-dev.py interval:1200 yes Senior developer
alfred.test-engineer test-engineer.py cron:2:00 yes Test engineer
alfred.ops-watch ops-watch.py cron:8:00 no Deploy health
alfred.agent-cleanup agent-cleanup.py cron:3:00 no alfred.agent-cleanup Daily cleanup

Tabs are required between fields. Trailing empty fields can be omitted.

render.sh substitutes these in _template.plist:

Token Source
__LABEL__ agents.conf field 1
__SCRIPT__ agents.conf field 2
__SCHEDULE_BLOCK__ rendered from field 3 (StartInterval or StartCalendarInterval)
__PATH__ colon-joined PATH for EnvironmentVariables (varies by needs_java)
__JAVA_BLOCK__ JAVA_HOME entry (empty when needs_java=no)
__GH_ORG_BLOCK__ GH_ORG entry (omitted if env unset)
__ALFRED_BIN__ $ALFRED_HOME/bin
__ALFRED_HOME__ resolved at render time
__WORKSPACE_ROOT__ resolved at render time
__HOME__ $HOME at render time
__LOG_STEM__ agents.conf field 5 (or label if empty)
__AGENT_SHORT__ label suffix, rendered as AGENT_CODENAME
__AGENT_ROLE_BLOCK__ ALFRED_<CODENAME>_ROLE env var rendered from field 6 when present

launchd does not source shell rc files. The rendered plist calls agent-launch, which loads $ALFRED_HOME/.env at firing time and then execs the agent script from $ALFRED_HOME/bin.

  1. For prompted custom roles, run alfred agent add ....
  2. For bespoke deterministic scripts, drop bin/<your-role>.py into your fleet repo and append a row to launchd/agents.conf.
  3. Run bash deploy.sh: renders + bootstraps.
  4. Verify with alfred status and ./bin/alfred doctor.

Pause persists across deploy.sh invocations via marker files at $ALFRED_HOME/state/_paused/<short-name> (where short-name is the label minus the <prefix>. prefix).

Terminal window
# Manual pause:
launchctl bootout "gui/$(id -u)/alfred.senior-dev"
mkdir -p $ALFRED_HOME/state/_paused
date -u +"%Y-%m-%dT%H:%M:%SZ" > $ALFRED_HOME/state/_paused/senior-dev
# Resume:
rm $ALFRED_HOME/state/_paused/senior-dev
launchctl bootstrap "gui/$(id -u)" \
~/Library/LaunchAgents/alfred.senior-dev.plist

Each plist writes to /tmp/<log_stem>.stdout and /tmp/<log_stem>.stderr. Use tail -f /tmp/alfred.senior-dev.std{out,err} to watch a firing live.

/tmp/ is wiped on macOS reboot. Durable runtime state lives under $ALFRED_HOME/state/; Codex artifacts are written under $ALFRED_HOME/state/codex/. Claude transcript capture is planned, not written by the current runner.

The shipped template sets RunAtLoad = false, so bash deploy.sh does not immediately fire any agent. They wait for their scheduled trigger (or launchctl kickstart).

For immediate on-deploy firing of a specific agent, render its plist with RunAtLoad = true (edit _template.plist for that agent only). No per-agent override in agents.conf yet; tracked.