Lesson

Your First Job

Init the project, create your first job, see it register with launchd.

You're going to get a job running on launchd before you understand how any of it works. Empty directory to working scheduler in a few minutes.

Init the Project

mkdir launchd-ai-scheduler && cd launchd-ai-scheduler
bun init -y

Install Dependencies

bun add -d @types/bun typescript

Add to your tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["@types/bun"]
  }
}

Create Your First Job

A job is a directory with two files — a schedule and a run script:

mkdir -p system-jobs/hello-world

The schedule — run every 60 seconds:

echo '60' > system-jobs/hello-world/schedule

The run script — log a timestamp:

cat > system-jobs/hello-world/run << 'EOF'
#!/bin/bash
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Hello from launchd! Job: ${JOB_LABEL:-unknown}"
EOF
chmod 755 system-jobs/hello-world/run

That's a complete job definition. A directory, a schedule file, an executable script.

Build the Manager

Create src/manager.ts — this is the core. It discovers job directories, generates launchd plists, and registers them. Your companion can help you build this step by step, or you can write it yourself. The key functions:

  • parseScheduleText(raw) — reads the schedule file
  • plistForJob(job) — generates launchd XML
  • installJob(job) — writes the plist, registers with launchctl
  • syncAllJobs() — discovers all jobs, installs enabled ones, removes stale ones

Ask your companion: "teach me how to build the manager"

Build the Sync Entrypoint

Create src/sync.ts:

import { syncAllJobs } from "./manager";

const report = await syncAllJobs();
console.log(`[jobs] Synced ${report.synced.length} jobs`);
if (report.synced.length > 0) console.log(`[jobs] Updated: ${report.synced.join(", ")}`);
if (report.removed.length > 0) console.log(`[jobs] Removed: ${report.removed.join(", ")}`);

Add to package.json:

{
  "scripts": {
    "sync": "bun run src/sync.ts"
  }
}

Run It

bun run sync
[jobs] Synced 1 jobs
[jobs] Updated: aijs.system.hello-world

Verify

launchctl list | grep aijs
-  0  aijs.system.hello-world

Your job is registered with launchd. It will run every 60 seconds. It survives reboots. The OS manages it.

Kick It

Force-run now instead of waiting:

launchctl kickstart gui/$(id -u)/aijs.system.hello-world

Check the output:

cat workdir/logs/hello-world/stdout.log
[2026-03-13 17:57:42] Hello from launchd! Job: aijs.system.hello-world

It works. You built a job scheduler. The rest of this course is understanding how each piece works and making it do real AI work.