Declarative AI Jobs via launchd — No Queue, No Daemon, Just macOS

repoaiinfrastructuretypescriptmacosschedulingagent-loopslaunchdbun

Direct alternative to Inngest for Mac-local AI job scheduling — WatchPaths trigger maps to file-change event patterns; the directory-as-job convention parallels how joelclaw's system-bus functions are organized

James Long — creator of Prettier, Actual Budget, and formerly Mozilla DevTools — dropped the actual TypeScript source for how he’s scheduling AI agent jobs on his Mac. Two files. No cloud. No Docker. No Redis. Just launchd, the native macOS process supervisor that’s been sitting on every Mac since 10.4.

The pattern is simple: a job is a directory. Drop a schedule file in it and an executable run script, and the manager handles the rest — generates a .plist, registers it with launchctl, sets up stdout/stderr log files, and boots it into the gui/<uid> domain so it persists across reboots without root. The sync.ts entrypoint runs a diff of desired-vs-installed state and culls stale jobs automatically. It’s declarative infrastructure by filesystem convention. No database, no manifest file, no registration step.

There are three schedule types. Periodic takes a plain integer (seconds interval) or {"type": "periodic", "seconds": 300}. Scheduled takes a launchd StartCalendarInterval-style calendar object — run at 9am weekdays, that sort of thing. And then there’s on-change, which maps to launchd’s WatchPaths — the job fires when specific file system paths change. That last one is particularly interesting for reactive AI pipelines where you want to process a file the moment something drops it.

For context: this is part of something James is calling qbot — his personal AI agent system. The job label convention is qbot.{scope}.{name}, scoped to either system (built-in jobs from the project) or user (jobs you add yourself). It’s written in Bun, which means Bun.spawnSync for launchctl calls and import.meta.dir for resolving paths. The approach trades Inngest’s durable step execution and event fan-out for simplicity and zero infrastructure overhead — everything that needs to happen on a schedule already has a home.

Key Ideas

  • Directory-as-job — each job is a folder with a schedule file + executable run script; adding a job is just mkdir + two files
  • Three trigger types — periodic (interval), scheduled (calendar via StartCalendarInterval), and on-change (file system events via WatchPaths)
  • launchd as durable runtime — jobs survive reboots and crashes natively without any watchdog daemon; the OS is the process supervisor
  • Declarative syncsyncAllJobs() diffs desired state (folders) vs installed state (plists in ~/Library/LaunchAgents) and removes stale entries automatically
  • Structured log output — each job gets its own .out.log and .err.log under a central logs/launchd/ directory; no log aggregation needed
  • WatchPaths is underrated — filesystem-triggered jobs are a native primitive that most people never reach for; pairs well with agent output directories
  • Bun-native — uses Bun.spawnSync for launchctl, import.meta.dir for job discovery; zero Node.js compatibility shims needed
  • Scoped labelsqbot.system.* for built-in jobs vs qbot.user.* for user-added jobs; clean namespace separation via the label prefix