launchd doesn't read your schedule file. It reads property list XML — .plist files in ~/Library/LaunchAgents/. The manager generates these from your job definitions.
What a Plist Looks Like
bun run src/cli.ts plist hello-world
The result.xml field contains the generated plist. Every plist has:
- Label — unique identifier (
aijs.system.hello-world) - ProgramArguments — the executable to run (your
runscript) - WorkingDirectory — the job's directory
- Schedule trigger —
StartInterval,StartCalendarInterval, orWatchPaths - Log paths —
StandardOutPathandStandardErrorPathper job - EnvironmentVariables — PATH (so bun/node work), JOB_LABEL, JOB_NAME
The Generator
plistForJob takes a JobDefinition and returns a string of valid XML. The key pieces:
XML escaping — &, <, >, ", ' in paths must be escaped. Your username in a path is fine, but a & in a job name would break the XML.
Recursive value serialization — plist has its own type system: <string>, <integer>, <true/>, <false/>, <array>, <dict>. The plistValue function handles nested structures recursively.
Schedule section — switches on the schedule kind to emit the right plist keys:
case "periodic":
// <key>StartInterval</key><integer>60</integer>
case "scheduled":
// <key>StartCalendarInterval</key><dict>...</dict>
case "on-change":
// <key>WatchPaths</key><array>...</array>
PATH injection — launchd runs jobs with a minimal PATH (/usr/bin:/bin). The manager captures your current process.env.PATH into the plist so shebangs like #!/usr/bin/env bun work. Without this, every bun/node job silently fails.
Validate
Every generated plist should pass plutil -lint:
bun run src/cli.ts plist hello-world | \
python3 -c "import json,sys; print(json.load(sys.stdin)['result']['valid'])"
Should print true. If it doesn't, the XML is malformed — check escaping.
Verification
# Preview all plists and validate them
for job in hello-world morning-briefing inbox-watcher daily-digest; do
echo -n "$job: "
bun run src/cli.ts plist $job | python3 -c "import json,sys; d=json.load(sys.stdin); print('✓' if d['result']['valid'] else '✗')"
done
All four should show ✓.
What You Learned
- launchd speaks plist XML, not JSON or YAML
- The generator is a template with variable sections per schedule type
- PATH injection is critical — without it, non-system executables aren't found
plutil -lintis your friend — validate before you install