launchd plist template
The framework’s scheduling layer. Three files, fully documented.
launchd/_template.plist: the canonical template with__PLACEHOLDER__tokens.launchd/agents.conf.example: TSV format documentation.launchd/render.sh: substitutes tokens, writes one plist per row.
The scheduling pipeline
Section titled “The scheduling pipeline”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.
agents.conf format
Section titled “agents.conf format”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 developeralfred.test-engineer test-engineer.py cron:2:00 yes Test engineeralfred.ops-watch ops-watch.py cron:8:00 no Deploy healthalfred.agent-cleanup agent-cleanup.py cron:3:00 no alfred.agent-cleanup Daily cleanupTabs are required between fields. Trailing empty fields can be omitted.
Template tokens
Section titled “Template tokens”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.
Adding an agent
Section titled “Adding an agent”- For prompted custom roles, run
alfred agent add .... - For bespoke deterministic scripts, drop
bin/<your-role>.pyinto your fleet repo and append a row tolaunchd/agents.conf. - Run
bash deploy.sh: renders + bootstraps. - Verify with
alfred statusand./bin/alfred doctor.
Pause / resume
Section titled “Pause / resume”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).
# Manual pause:launchctl bootout "gui/$(id -u)/alfred.senior-dev"mkdir -p $ALFRED_HOME/state/_pauseddate -u +"%Y-%m-%dT%H:%M:%SZ" > $ALFRED_HOME/state/_paused/senior-dev
# Resume:rm $ALFRED_HOME/state/_paused/senior-devlaunchctl bootstrap "gui/$(id -u)" \ ~/Library/LaunchAgents/alfred.senior-dev.pliststdout / stderr
Section titled “stdout / stderr”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.
RunAtLoad
Section titled “RunAtLoad”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.