How to run Claude on a schedule — and where the state goes between runs

Getting Claude to run at 6am is not the hard part. It is a cron line, and every guide on the first page of search results will give it to you:

0 6 * * * cd /path/to/project && claude -p "$(cat prompts/daily.md)" >> logs/daily.log 2>&1

That works. You will get output tomorrow morning. And then, somewhere between day four and day ten, one of four things happens and the job quietly stops being useful — usually while continuing to run, which is worse than stopping.

This page is about those four things, because they are the actual job, and almost nothing written about scheduling agents covers them.

The four things that go wrong

1. It has no yesterday. A scheduled run has no session to inherit context from. It wakes up blank. So day two does the same work as day one, and day thirty produces its thirtieth near-identical report. The repetition looks like activity. This is the most common failure and the hardest to notice, because the job is running perfectly.

2. It dies halfway and starts over. Anything touching a network will fail periodically. If your run has no checkpoint, a failure at item seven of twelve throws away the first six, and tomorrow starts from cold.

3. It writes down what it wishes were true. A run that marks something “done” and then dies before doing it leaves a state file that lies. Every subsequent run trusts the lie. This is the failure that destroys the whole thing, because a system that is confidently wrong is worse than one that knows nothing.

4. It fills the disk with near-identical files. Three hundred dated folders and no single place that says what is true right now.

The shape that fixes all four

Every scheduled run, in this order. The order is the design.

1. READ state          - what is true now, and what is still open
2. CLAIM the work      - write the checkpoint BEFORE doing anything
3. VERIFY yesterday    - test last run's claims against reality
4. DO the work         - only what is new since the last successful run
5. WRITE the result    - to a dated folder
6. PROMOTE             - overwrite the current-state file

Steps 2, 3 and 6 are the ones everyone skips, and they are the ones that matter.

Claim before you work

Almost everyone writes the checkpoint at the end. That is not a checkpoint, it is a receipt — it records that the run finished, which you can already tell from the output existing. A run that dies during the work leaves nothing at all, and tomorrow starts cold.

Write it first:

{
  "run": "2026-09-02T06:00:00Z",
  "claimed": ["fetch-inbox", "summarise", "post-digest"],
  "completed": [],
  "status": "started"
}

Append to completed as each item finishes. Now a run that dies after “fetch-inbox” leaves a file saying exactly that, and tomorrow resumes at “summarise” instead of refetching.

Verify yesterday by observation, never by flag

This is the rule that separates a system that stays true from one that rots, and it is worth more than everything else on this page.

A flag your own run wrote about its own success is not evidence. It is a claim. If yesterday’s run wrote "posted": true, that tells you the run reached the line that sets the flag — not that anything was posted.

So: write the acceptance test into the task, and have the next run execute it.

{
  "task": "publish the weekly digest to the blog",
  "acceptance_test": "GET https://example.com/blog/digest returns 200 and body contains the string 2026-09-02",
  "opened": "2026-09-01",
  "status": "open"
}

Tomorrow’s run does not read a flag. It performs the GET. If it passes, the task is closed — and closed because it was observed. If it fails, it stays open with today’s date on it. The state file cannot drift from reality, because nothing about reality is stored in it.

The corollary, and it is the important one: when something cannot be checked, write NOT READ with the date. Never zero. Zero says you measured and got nothing. NOT READ says you could not look. A system that confuses those two starts congratulating itself, and you will believe it.

Do only what is new

Store a content hash beside each input you process. If the hash matches last run’s, skip it. Day one processes everything; day two processes what changed. This is what stops a daily job from being three hundred identical jobs.

Promote, don’t accumulate

Dated folders are history. Add one file that is the current truth, overwritten in place:

state/current.md          # what is true right now - overwritten every run
state/open-tasks.json     # only genuinely open items - a closed one leaves entirely
runs/2026-09-02/          # what today did. History. Nobody has to read it.

open-tasks.json never grows, because closing a task means deleting its row. A year in, you have one small state file and 365 folders nobody needs to open.

The cron line, done properly

#!/usr/bin/env bash
set -euo pipefail

cd /path/to/project
LOCK=/tmp/daily-run.lock
exec 9>"$LOCK"
flock -n 9 || { echo "$(date -Is) already running, skipping"; exit 0; }

claude -p "$(cat prompts/daily.md)" \
  --allowedTools "Read,Write,Bash" \
  >> "logs/$(date +%F).log" 2>&1

The flock is not optional. Without it, a run that takes longer than your interval overlaps with the next one and they corrupt each other’s state. Two lines, and it prevents the worst class of bug in this whole design.

Three more practical notes. Cron has almost no PATH, so use absolute paths or set it explicitly at the top. Cron does not load your shell profile, so any API key must be in the script or in an env file the script sources. And log to a dated file rather than one growing forever, or you will find out about it when the disk fills.

Then, in prompts/daily.md, the run’s closing instruction — which is the whole reason any of this works:

Before finishing: update state/current.md with what is true now. Move any task you completed out of state/open-tasks.json entirely. For any task you handed off, add it with an acceptance_test that tomorrow’s run can execute. Anything you could not check, write NOT READ with today’s date, never 0.

What this still cannot do

Everything above runs on your laptop, and your laptop sleeps. Move it to a small always-on box or a container and it works properly — that is genuinely the answer, and it is cheap.

What is left after that is the state itself: something has to hold it where more than one machine and more than one agent can reach it, atomically, so two runs at once do not tear a file. Files plus git gets you a long way. Past that you want a table with conditional writes — the schema fits in a paragraph, and we published it, free, at where to put the state.

Related