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
| Type | Format | launchd Key | When It Runs |
|---|---|---|---|
| Periodic | 60 or {"type": "periodic", "seconds": 60} | StartInterval | Every N seconds |
| Scheduled | {"type": "scheduled", "calendar": {...}} | StartCalendarInterval | At specific times |
| On-change | {"type": "on-change", "paths": [...]} | WatchPaths | When 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