A lot of useful agent work is periodic. Summarise yesterday's commits on the repos I watch. Check a status page every hour. Read an inbox folder each morning and tell me what needs a reply. The obvious tool is cron:
0 8 * * * claude -p "Summarise the last 24h of commits on my watched repos" | mail -s digest me@example.com
It works for about a week. Then three problems show up, and they are the reason I started cron-for-agents (cfa).
Problem one: the agent cannot tell what is new#
Cron has no memory. Every run starts from nothing, so the agent either reprocesses everything, or you encode "last 24 hours" in the prompt and hope the schedule never slips. When the machine is off for a day, you miss a day. When you run the job manually, you get the same items twice.
What the job needs is a watermark: a durable marker of how far it got last time it succeeded. cfa stores one per job and passes it to the command as an environment variable:
# jobs/morning-repo-digest.yaml name: morning-repo-digest schedule: "0 8 * * *" command: | claude -p "Summarise commits on the repos in ./watched-repos.txt made after $CFA_WATERMARK. Only report notable changes." watermark: key: morning-repo-digest
The important detail is when the watermark moves. It only advances after a successful run. If the agent crashes, times out or exits non-zero, the watermark stays put and the next run picks up the same window. Work is never silently skipped.
This is the same idea as a consumer offset in a message queue, or a last_synced_at column in an integration. Agents just need it too.
Problem two: identical output, delivered again#
Many periodic checks are boring most of the time. The status page is green. There were no notable commits. The agent writes a slightly reworded "nothing to report" and you get pinged anyway. After a few days you stop reading the messages, which defeats the point.
cfa hashes each job's output and compares it with the last delivered output inside a dedup window:
function shouldDeliver(output: string, state: JobState, windowMs: number | null, now: number): boolean { const hash = createHash("sha256").update(normalise(output)).digest("hex"); if (windowMs === null) return true; if (state.lastContentHash !== hash) return true; const lastDelivered = state.lastDeliveredAt ? Date.parse(state.lastDeliveredAt) : 0; return now - lastDelivered > windowMs; }
Same content inside the window: skip delivery, record the run. New content, or the window has passed: deliver. The window is per job, so a digest can dedup over 12 hours while an alert dedups over 30 minutes.
A caveat worth being honest about: models rarely produce byte-identical text twice. Hash dedup works best when you ask the agent for a stable format ("If there is nothing notable, print exactly: NOTHING"), or when the job's command produces structured output. Normalising whitespace before hashing helps a little. Semantic dedup is possible, but I would rather make the output deterministic than add a model call to decide whether two model outputs are the same.
Problem three: output has nowhere to go#
Cron output goes to stdout, a log file, or local mail that nobody reads. For agent jobs, delivery is the product. I want the digest on Telegram in the morning, an alert on WhatsApp, a weekly report by email.
In cfa, delivery is a list of channels on the job:
delivery: channels: - telegram - email telegram: chat_id: ${TELEGRAM_CHAT_ID} email: to: me@example.com
Channels sit behind a small interface, so adding one is a single file. In the current v0.x release, delivery prints what would be sent. The real Telegram, WhatsApp and SMTP calls are the next milestone, and I wanted the scheduling, dedup and watermark core solid before wiring credentials.
State you can read with cat#
All job state lives in one JSON file:
{ "jobs": { "morning-repo-digest": { "lastRunAt": "2026-07-20T05:00:04.512Z", "watermark": "2026-07-20T05:00:04.512Z", "lastContentHash": "9f2c1e...", "lastDeliveredAt": "2026-07-20T05:00:05.001Z" } } }
No database, no server. You can inspect it, back it up, commit it or delete it to reset a job. Writes go to a temp file and are renamed into place, so a crash mid-write does not corrupt the state.
I keep making this choice (plain files over a service) across my agent tools, because it makes debugging trivial. When a job behaves oddly, the first step is cat .cfa/state.json, not opening a dashboard.
Any agent, any CLI#
A job's command is just a shell command. Its stdout is the output. That means cfa does not care which agent you use:
cfa add digest --schedule "0 8 * * *" --command 'claude -p "Summarise watched repos since $CFA_WATERMARK"' cfa add inbox --schedule "every 2h" --command 'codex exec "Triage new files in ./inbox"' cfa add uptime --schedule "every 15m" --command './scripts/check-status.sh' cfa list cfa run digest # run once now, applying dedup and watermark cfa daemon # start the scheduler
Not every scheduled job needs a model. A plain script that checks a URL benefits from dedup and delivery just as much. When it does need a model, it is one command away.
Timeouts and budgets#
An agent job that hangs is worse than one that fails. Every job has a timeout (ten minutes by default). When it fires, the process is killed, the run is recorded as failed and the watermark does not move.
I also recommend putting the budget in the job itself. Ask for a short answer, limit the number of turns if your agent CLI supports it, and schedule expensive jobs less often. The combination of watermarks and dedup already cuts a lot of waste, because the agent only sees new items and you only get messages when something changed.
What is next#
The roadmap is deliberately narrow: a full cron expression parser (today, cron-style schedules fall back to a fixed interval), real channel delivery, per-job retry policy, a cfa logs history view, and locking so two daemons cannot run the same job at once.
What it will not become is a workflow engine. There are good ones. cfa is for the common case: one agent task, on a schedule, that only looks at what is new and only speaks up when it has something to say.