Lesson

Generate a Plist

Turn a JobDefinition into valid plist XML that launchd can consume.

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 run script)
  • WorkingDirectory — the job's directory
  • Schedule triggerStartInterval, StartCalendarInterval, or WatchPaths
  • Log pathsStandardOutPath and StandardErrorPath per 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 -lint is your friend — validate before you install