The most useful agents I run are the ones I never start. Every morning at 7:00am, a job sweeps sources, scores candidates against a written rubric, and writes me a queue file — whether I'm awake or not. Twice a day, a watcher reads my inboxes and pushes a Telegram digest. Content engines fire before I've had breakfast. The difference between an AI assistant and AI infrastructure is exactly this: does it run on a clock, or does it wait for you?
Here's how I build scheduled AI agents on cron and launchd that actually run, actually stay safe, and actually tell me when something breaks.
TL;DR
Scheduled AI agents = a wrapper script that runs Claude Code headlessly (claude -p), fired by launchd (macOS) or cron (Linux). Three rules make it work: (1) hard written guardrails — draft-never-send, read-only where possible, no fabricated data; (2) verify every job is actually armed with launchctl list plus a manual end-to-end run, never assume; (3) design heartbeats so silence is a signal, not an ambiguity. Use plain scripts where no judgment is needed — agents are the expensive tool.
The short answer#
A scheduled AI agent is a headless run of an agent CLI started by the operating system's scheduler — launchd on macOS, cron elsewhere — with its prompt, its permissions, and its output destination all fixed in advance, so it produces work without anyone being present. Three things separate one that works from one that quietly does nothing. It needs an explicit environment, because the scheduler does not load your shell profile and will not find tools on your interactive PATH. It needs guardrails written as prohibitions, because an unattended agent has nobody to ask: no sending email, no deploying, no charging anyone, no publishing without passing a gate. And it needs a heartbeat, because the default failure mode of a scheduled job is silence, and silence is indistinguishable from success unless something outside the job checks that the job ran and exited zero. Build the third one first; without it you will not learn that the other two are broken.
How do you run an AI agent on a schedule?#
The mechanism is almost boring. A shell script cd's into the project directory and invokes Claude Code in headless mode — claude -p "<prompt>" — with a prompt that points at a written procedure. The script logs everything it does to a file. Then the operating system's scheduler runs that script on a cadence: launchd on macOS (a plist in ~/Library/LaunchAgents/), cron on Linux.
That's the whole trick. The intelligence lives in the agent; the reliability lives in a 40-year-old scheduling primitive.
What jobs are worth putting on a schedule?#
Not a hypothetical architecture — this is the clock as it runs on my machine today. Every row is a real installed job with a log file I can open.
| Job | When | Mechanism | What it does |
|---|---|---|---|
| Brand content engines | 6:00–8:00am daily | launchd, per-brand | Generate and stage the day's content per brand, analytics-gated, publishing through a documented safe-rollout procedure |
| Daily job-queue sweep | 7:00am daily | launchd → headless Claude Code | Sweeps job boards, scores against a written rubric, dedupes against a pipeline CSV, writes today's queue file, drafts (never sends) outreach, logs blockers |
| Interview-reply watcher | 9:00am + 6:00pm daily | launchd → plain Python | Reads inboxes read-only over IMAP, matches new mail against the pipeline CSV, classifies hits, pushes a Telegram digest |
| Authority engine | Every 2 hours | Scheduled loop | Publishes SEO/GEO-optimized content autonomously, feeding the archive from the 500-post blog engine post |
Note the mix: some jobs are full agent runs, some are plain scripts. The interview watcher is deliberately not an LLM — it reads mail, string-matches against a CSV of companies I've applied to, and classifies each hit as interview-signal (real next-step language — pushed to Telegram immediately), auto-ack (a "thanks for applying" bot — surfaced quietly), or company-match (needs a human eyeball). No judgment required, so no agent spent. Use the agent where reading and deciding is genuinely needed; use a script where it isn't. Agents are the expensive, occasionally-wrong tool.
What should an unattended AI agent never be allowed to do?#
An agent running with no human watching needs harder rules than an interactive one. Mine are written into the procedures the jobs execute, and they're non-negotiable:
- Draft, never send. Autonomous jobs prepare outreach, posts, and emails — a human approves anything that leaves the machine. My daily sweep drafts DMs and a post every day; it has sent exactly zero of them.
- Read-only where possible. The inbox watcher reads mail over IMAP and never sends or replies. If a job doesn't need write access to something, it doesn't get it.
- No fabricated data. Every scheduled procedure inherits the same hard rule as my interactive work: no invented metrics, links, or facts. My sweep logs "research leads needing link verification" as blockers rather than writing unverified links into the queue.
- Blockers surface, they don't block silently. Anything requiring a human — a login, an approval, an ambiguous form question — gets appended to a
BLOCKERS.mdfile instead of the agent guessing. One of my jobs logged a job-application form it couldn't ethically answer (a bot-detection question) rather than answering it. - Scoped credentials. Jobs reuse existing local auth helpers; a scheduled job never gets broader access than the task needs.
The pattern behind all five: an unattended agent should be able to fail, pause, or under-deliver — but never surprise you. The full drift-prevention layer (written procedures the agent must load) is covered in how CLAUDE.md and skills stop agent drift.
How do you verify a scheduled agent is actually armed?#
Here's the failure mode nobody warns you about: you write the script, you write the plist, you feel done — and the job never runs. Wrong path, wrong permissions, plist never loaded, scheduler rejected it silently. You find out weeks later that your "24/7 automation" ran zero times.
My rule: a scheduled job does not exist until it's verified armed. The checklist I run after installing any job:
- Ask the scheduler.
launchctl list | grep <label>must show the job (orcrontab -lon Linux). If the scheduler can't name it, it isn't scheduled. Every job I install ships with this exact one-liner in its docs — for the interview watcher it'slaunchctl list | grep campilii, and the labelcom.campilii.interviewcheckmust appear. - Run it manually, end to end. Execute the exact wrapper script the scheduler will call — not the underlying command — and require exit 0. My daily sweep's install wasn't done until a manual run produced a real queue file, real CSV rows, and real logged blockers.
- Check the artifacts. The job must leave evidence: a log line, an updated state file, an output file with today's date. Verify each one exists after the test run.
- Verify the state file advances. Jobs that process "new items since last run" keep a last-checked timestamp. Confirm it updates, or your job will reprocess everything forever — or nothing ever.
- Wait for the first scheduled firing and read the log. Manual success doesn't prove scheduled success — launchd jobs run with a different environment than your shell. Confirm the first real firing in the log the next day.
Step 5 is not paranoia. A launchd job inherits a stripped PATH, no shell profile, and none of the environment your interactive terminal has — which means a wrapper that runs perfectly by hand can die instantly on schedule, with nothing to show for it but an exit code in launchctl list. I learned this the expensive way; the full autopsy is in the launchd silent-death postmortem. Read it before you trust any job you haven't watched fire on its own clock. This post is the success story; that one is the failure story — you need both.
How do you know a scheduled agent failed?#
A job that finds nothing and a job that's broken look identical: no output. That ambiguity will quietly eat your trust in the whole system, so I design it away.
My inbox watcher sends a Telegram digest whenever it finds matches — and on the evening run, if it found nothing all day, it sends a "no replies, heartbeat" message. One guaranteed touch per day. If that heartbeat ever misses, I know the system is down, not the news. Every scheduled job also writes a run log and its stdout/stderr to files, so "did it run?" is always answerable in one command.
Cheap rule of thumb: every scheduled job needs a way to say "I ran and found nothing" that is distinguishable from saying nothing at all.
How should a scheduled agent handle its own failures?#
Things break: an upstream site changes, a token expires, the machine was asleep. The design response, in order:
- Log first, alert second. Every run appends to a log file. Alerts (Telegram, in my case) are for signal — matches found, heartbeat, hard failure — not for noise.
- Fail toward the blockers file. When a job hits something needing a human, it writes the blocker down and finishes the rest of its run. Partial completion plus an honest note beats a crashed run.
- Idempotent by design. Jobs dedupe against their state (the pipeline CSV, the timestamp file) so re-running after a failure is always safe. Recovery is "run it again," never "figure out what half-completed."
- Manual re-run is always documented. Every job's docs include the one-liner to trigger it by hand —
python3 <script>for the watcher, the wrapper script path for the sweep. When something misfires, recovery takes a minute, not an archaeology session.
When is a scheduled agent the wrong tool?#
Scheduled agents are one layer of a larger architecture — the clock layer. The operating layer (Claude Code), the procedure layer (skills and CLAUDE.md), the infrastructure layer, and the verification loops around all of it are laid out in the pillar post: How One Person Runs Five Brands with AI Agent Fleets. But if you build only one thing from this series, build one scheduled job with a heartbeat. The first morning you wake up to work that finished itself — correctly, with a log proving it — the rest of the architecture stops being optional in your mind.
FAQ#
Why launchd instead of a cloud scheduler?#
Because the jobs' source of truth is local files. My pipeline sweep reads and writes files in a local directory — a cloud sandbox can't touch them. Rule: run the scheduler where the state lives. Local state, local launchd/cron; cloud state, cloud scheduler.
How do I check if a launchd job is actually running?#
launchctl list | grep <your-label> — if the label doesn't appear, the job isn't loaded, no matter what your plist file says. Then run the wrapper script manually and require exit 0, confirm the output artifacts exist, and read the log after the first scheduled firing. Manual success does not prove scheduled success, because launchd runs with a different environment than your shell.
Isn't it risky to run Claude Code with permissions skipped on a schedule?#
It's a real tradeoff, which is why the guardrails live in the written procedure instead: draft-never-send, read-only access, no fabrication, blockers-file escalation, scoped credentials. Headless runs can't stop to ask permission, so you remove the dangerous capabilities from the job's reach and constrain it with hard written rules — then verify behavior with a manual end-to-end run before arming it.
How much do scheduled agent runs cost?#
An agent run costs real API money; a Python script costs effectively nothing. That's exactly why the mix matters: my twice-daily inbox watcher is plain Python, and the agent runs are reserved for jobs that genuinely need reading and judgment. Audit your schedule for jobs that don't need an LLM at all — usually several don't.
RELATED
If you want this kind of always-on agent infrastructure built inside your company — schedules, guardrails, heartbeats, and the verification discipline that keeps it honest — that's exactly the work I do. See how I work with teams.
— Italo Campilii. If you're building something that needs this kind of operator, get in touch.