Lesson

Anatomy of a Job

What's inside a job directory — the schedule file and the run script.

A job is a directory with two files:

system-jobs/hello-world/
  schedule    # when to run
  run         # what to run

That's it. Adding a job is mkdir + two files. Removing a job is rm -rf the directory. The filesystem IS the job registry.

The Schedule File

Open system-jobs/hello-world/schedule:

60

One number. That's a complete schedule definition — "run every 60 seconds." The manager reads this, generates a launchd plist with StartInterval: 60, and registers it.

Now look at system-jobs/morning-briefing/schedule:

{"type": "scheduled", "calendar": {"Hour": 9, "Minute": 0, "Weekday": [1,2,3,4,5]}}

This is a calendar schedule — "run at 9:00 AM on weekdays." Launchd calls this StartCalendarInterval.

And system-jobs/inbox-watcher/schedule:

{"type": "on-change", "paths": ["../../workdir/inbox"]}

This is the most interesting one. It doesn't run on a timer — it runs when files change in the workdir/inbox/ directory. Drop a file in, the job fires. Launchd calls this WatchPaths.

Three Schedule Types

TypeFormatlaunchd KeyWhen It Runs
Periodic60 or {"type": "periodic", "seconds": 60}StartIntervalEvery N seconds
Scheduled{"type": "scheduled", "calendar": {...}}StartCalendarIntervalAt specific times
On-change{"type": "on-change", "paths": [...]}WatchPathsWhen files change

The Run Script

Open system-jobs/hello-world/run:

#!/bin/bash
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Hello from launchd! Job: ${JOB_LABEL:-unknown}"

It's just a script. Bash, bun, python, whatever — if it's executable and has a shebang, launchd will run it. The manager sets chmod 755 automatically.

The JOB_LABEL environment variable is injected by the manager via the plist's EnvironmentVariables. Every job knows its own name.

Try It: Create a Job

Create your own job right now:

mkdir -p jobs/my-first-job

# Schedule: every 30 seconds
echo '30' > jobs/my-first-job/schedule

# Run script: log the date
cat > jobs/my-first-job/run << 'EOF'
#!/bin/bash
echo "[$(date)] My first job ran! 🎉"
EOF
chmod 755 jobs/my-first-job/run

Sync it:

bun run sync

You should see your job in the output. Verify:

bun run src/cli.ts status

Your job appears as aijs.user.my-first-job — it's in the user scope because it's in the jobs/ directory (not system-jobs/).

Kick it:

bun run src/cli.ts kick my-first-job
bun run src/cli.ts logs my-first-job

You just created a scheduled job by making a directory with two files. No config files, no service definitions, no YAML. Directories and scripts.

Where Things Live

system-jobs/     # Built-in jobs (scope: system) — shipped with the project
jobs/            # Your jobs (scope: user) — you create these
workdir/         # Runtime data — logs, inbox, digests, sorted files
  logs/          # Per-job stdout and stderr

The manager scans both system-jobs/ and jobs/ at sync time. System jobs have labels like aijs.system.hello-world. Your jobs have labels like aijs.user.my-first-job.

What You Now Know

  • A job is a directory with schedule + run
  • Three schedule types: periodic, calendar, filesystem watch
  • The run script is any executable with a shebang
  • system-jobs/ for built-in, jobs/ for yours
  • Adding a job = mkdir + two files + sync