Scheduled work
Open this for recurring or time-triggered work. The host scheduler is an external wake mechanism; Shell has no built-in recurring scheduler.
Pick the trigger
- Time-driven substantive work (“every hour, do X”): use a host scheduler.
- Event-driven work with a deadline (“when Y arrives, respond quickly”): use the event source or mailbox/watch, not cron.
- Periodic work inside an active turn: compare a stored timestamp in the turn loop, not an external scheduler.
Prefer an event watch when it is the real trigger; polling every cycle burns turns when nothing changed.
Wake-by-mailbox contract
For an explicitly human-approved schedule using the human sender, publish one
complete message to the human outbox (retain the scheduler identity.via; this
is not an interactive human instruction or permission to expand the schedule):
<project>/.lingtai/human/mailbox/outbox/<uuid>/message.json
Use a fresh UUID and a message like:
{
"id": "<uuid>", "_mailbox_id": "<uuid>", "from": "human",
"to": ["<agent-address>"], "cc": [], "subject": "<short subject>",
"message": "Use the named skill; this is the time-bound context.",
"type": "normal", "received_at": "<UTC timestamp>",
"identity": {"address": "human", "agent_name": "human",
"via": "<scheduler-name>-cron"}
}
The kernel claims the human outbox folder into human/mailbox/sent/<uuid>/ and
copies it to the agent's mailbox/inbox/<uuid>/; message handling is a separate
step. Do not promise that delivery interrupts a long active turn; a live asleep
listener can wake normally, but mail is not recovery for a stopped process.
Do not refresh merely to accelerate mail. Only when the owner explicitly
approves this schedule's refresh, publish the message first, then touch <agent>/.refresh and exit. Follow system-manual for lifecycle prechecks;
the kernel's refresh watcher owns relaunching.
Never parse or remove .agent.lock, wait for its path to vanish, or launch a
second relaunch process: path existence is not the kernel's flock and causes
duplicate agents.
Keep the scheduled prompt short: time-bound context plus the name of a
versioned skill in .library/custom/<name>/SKILL.md; the skill owns the recipe.
Do not embed a long command sequence in a prompt replayed every hour.
Script hygiene
- Make each fire idempotent; use a cycle marker when doing substantive work.
- Audit the previous cycle and append
[fire],[audit], and[err]records to a log. Do not rely only on scheduler stdout/stderr. - Start scripts with
set -euo pipefail; explicitly writecmd || truewhen a failure is intentional. - Use absolute binary paths or set
PATH; launchd/systemd/cron inherit sparse environments. Verify the expected artifact, commit, or message after work. - Do not silently prune logs or work products. Deletion needs an explicit human-approved dry-run and cleanup plan.
macOS launchd
Use a user LaunchAgent at ~/Library/LaunchAgents/<label>.plist:
<plist version="1.0"><dict>
<key>Label</key><string>ai.example.my-job</string>
<key>ProgramArguments</key><array>
<string>/bin/bash</string><string>/Users/you/.scripts/my-job.sh</string>
</array>
<key>StartCalendarInterval</key><dict><key>Minute</key><integer>0</integer></dict>
<key>StandardOutPath</key><string>/Users/you/.scripts/my-job.out</string>
<key>StandardErrorPath</key><string>/Users/you/.scripts/my-job.err</string>
</dict></plist>
Use one trigger (StartCalendarInterval or StartInterval), then validate and
load/test it:
plutil -lint ~/Library/LaunchAgents/ai.example.my-job.plist
launchctl load ~/Library/LaunchAgents/ai.example.my-job.plist
launchctl list ai.example.my-job
launchctl start ai.example.my-job
launchctl unload ~/Library/LaunchAgents/ai.example.my-job.plist
After editing a loaded plist, unload/reload it to apply the new definition.
StartCalendarInterval catches at most one missed fire after sleep;
StartInterval does not provide the same catch-up. Choose the schedule with
that limitation in mind. launchd can reap a child process tree
when the ProgramArguments process exits: &/disown are not sufficient. Prefer
the kernel refresh watcher; if a human-authorized workflow truly needs a child
to outlive the job, use a reviewed double-fork/full-detach launcher (PPID=1)
and verify its lifetime rather than assuming it.
Linux systemd timer
Create a oneshot service and timer under ~/.config/systemd/user/:
# my-job.service
[Service]
Type=oneshot
ExecStart=/bin/bash /home/you/.scripts/my-job.sh
StandardOutput=append:/home/you/.scripts/my-job.out
StandardError=append:/home/you/.scripts/my-job.err
# my-job.timer
[Timer]
Persistent=true
[Install]
WantedBy=timers.target
Activate and inspect it:
systemctl --user daemon-reload
systemctl --user enable --now my-job.timer
systemctl --user list-timers
systemctl --user status my-job.service
journalctl --user -u my-job.service
Persistent=true catches up after the machine was off; omit it when catch-up
work would be wrong.
crontab fallback
When systemd is unavailable:
0 * * * * /bin/bash /home/you/.scripts/my-job.sh >> /home/you/.scripts/my-job.log 2>&1
crontab -e uses five fields (minute, hour, day-of-month, month, weekday)
and an especially sparse PATH; use absolute paths and keep the script
idempotent.