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. A content engine publishes on a 2-hour loop with zero manual steps. 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 agents that actually run, actually stay safe, and actually tell me when something breaks.
The core pattern: headless Claude Code on a scheduler
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. My real jobs look like this:
- Daily pipeline sweep, 7:00am — a launchd LaunchAgent runs a wrapper script that invokes Claude Code headlessly against a documented daily procedure: sweep sources, score against the rubric, dedupe against a CSV, write today's queue file, draft (never send) outreach, log new blockers to a blockers file.
- Inbox watcher, 9:00am and 6:00pm — a Python script (no agent needed; not everything scheduled should be an LLM) reads inboxes read-only, matches new mail against a pipeline CSV, classifies hits, and pushes a digest to Telegram.
- Authority engine, every 2 hours — publishes SEO/GEO-optimized content autonomously, feeding the archive I described in the 500-post blog engine post.
Note the mix: some jobs are full agent runs, some are plain scripts. Use the agent where judgment is needed; use a script where it isn't. Agents are the expensive, occasionally-wrong tool — spend them on the steps that need reading and deciding.
Guardrails: what an unattended agent may never 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.
"Armed" is a claim you verify, not assume
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. - 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.
Heartbeats: silence must be a signal
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.
Failure handling without babysitting
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. When something misfires, recovery takes a minute, not an archaeology session.
Where this fits
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 (Cloudflare), 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.
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.
— Italo Campilii. If you're building something that needs this kind of operator, get in touch.