A cron-like scheduler for running Claude Code prompts. Useful for automating recurring AI tasks like daily summaries, periodic checks or scheduled reports.
Each job is a Markdown file with the prompt. Configuration, such as the cron schedule, working directory, allowed tools and memory access are defined in a YAML frontmatter at the top of the file.
Compared to Anthropic's scheduled tasks, which only fire while a Claude Code session is open:
ccronruns detached as a systemd daemon; no session required.- Jobs are specified as markdown files, not in chat.
- Jobs can opt into memory that persists between runs.
When Anthropic ships local jobs that are not dependent on sessions, this program is probably not needed anymore.
~/.claude/cron/
├── daily-summary.md # job 1
└── weekly-check.md # job 2
daily-summary.md:
---
# required
schedule: "0 9 * * 1-5"
workdir: ~/projects
allowed_tools: [Read, Write, Bash, "mcp__github__*"]
# optional
description: Daily git activity summary
timeout: 15m
enabled_if: '[ "$(hostname)" = "work-laptop" ]'
---
Summarize yesterday's git activity across all repos.
Write the summary to ~/notes/daily/YYYY-MM-DD.md.The preamble is YAML between --- fences. The body (everything after the closing ---) is the prompt passed to claude -p. The job name comes from the filename - rename the file to rename the job.
allowed_tools entries are passed to --allowedTools. Glob patterns with * are supported for MCP tools (e.g. mcp__github__* allows every tool exposed by the github MCP server).
ccron # status for each job (last run, next run, duration, failures)
ccron start # run as daemon
ccron exec <job> # run a job immediately
ccron validate # parse all job files and report errors
ccron logs # show latest run log (across all jobs)
ccron logs --job <job> -n 5 # last 5 runs of a specific jobRunning ccron with no arguments prints a status table. If any job file fails to parse, it's reported there too - no need to start the daemon to find out. The SUMMARY column shows the short note the agent wrote via run_summary_write on its last run (empty if the tool wasn't called).
Use --base-dir to specify the root directory for jobs (default ~/.claude/cron).
If a job is still running when its next schedule fires, that run is skipped (one job runs at a time per job name).
Cron expressions evaluate in the system's local timezone.
enabled_if is an optional shell expression evaluated with sh -c on every scheduled tick, with the job's workdir as cwd. Exit 0 runs the job, any non-zero exit silently skips it. This is intended for jobs whose files are synced across machines (Syncthing, dotfiles repos, etc.) where you want a host/user gate. ccron exec <job> bypasses this check; it's a manual override.
Put the binary somewhere on PATH (e.g. ~/.local/bin/ccron), then drop this unit at ~/.config/systemd/user/ccron.service:
[Unit]
Description=ccron - Claude Code cron scheduler
[Service]
# ccron shells out to `claude`, so its directory needs to be on PATH. Adjust
# this line if `claude` lives somewhere else on your system.
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=%h/.local/bin/ccron start
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.targetThen:
systemctl --user daemon-reload
systemctl --user enable --now ccronIf claude isn't on the default PATH (e.g. it's in ~/.claude/local/, installed via nvm, etc.), prepend that directory to the Environment=PATH=... line so the child process can find it.
Jobs can opt in to a small, persistent memory that carries facts forward between runs. Off by default.
---
schedule: "0 9 * * *"
workdir: ~/projects
allowed_tools: [Read, Write]
memory: 100 # cap on log records (FIFO eviction). 0 or absent = disabled.
memory_initial_records: 10 # optional. N most-recent log records primed into the prompt. Default 10.
---When enabled, two things happen on every run:
- Prompt includes previous memory: A summary and the
memory_initial_recordslast log records are prepended to the prompt in a## Prior memoryblock. - MCP server to access memory: The agent has access to read and write the memory via MCP.
On every run, ccron exposes a run_summary_write MCP tool and appends a short instruction asking the agent to call it with a ≤80-char note describing what the run did. That note shows up in the SUMMARY column of the status table. The tool is always available — no configuration needed, no opt-in. Agents may skip it; the column is just empty in that case.
Jobs can pull env vars from <base-dir>/.env, e.g. ~/claude/cron/.env.
# <base-dir>/.env
OPENAI_API_KEY=sk-...
---
secrets:
- OPENAI_API_KEY
- HOME_ASSISTANT_API_KEY
---
Use $HOME_ASSISTANT_API_KEY to notify me when the house burns..env is re-read on each run. Declared values are redacted from logs as ***. Missing names abort the run. .env must be mode 0600.
Note: If Claude has access to Bash or Read, they can still access secrets. This is
just a convenience for not having to manage secrets in the prompt.
Inline backtick-bang in the prompt body runs the command pre-send and inlines its stdout:
Today is !`date +%F`.
Recent PRs:
!`gh pr list --json number,title --limit 5`Commands run with bash -c, in the job's workdir, with the same env as the claude subprocess (including resolved secrets — !`curl -H "Authorization: Bearer $HA_TOKEN" ...` works). Per-command: 10s timeout, 8 KB output cap. A non-zero exit inlines <command failed: ...> rather than aborting the run; stderr is logged, not injected.
Written to ~/.claude/cron/logs/<job>/<timestamp>.log
Each run creates a new log file with the job output and timing. Logs older than 30 days are pruned automatically (override with --log-retention-days).
The daemon rescans the jobs directory every 30 seconds, so adding, editing, or deleting .md files is picked up automatically.
If you really want to force an immediate reload, send SIGHUP:
systemctl --user reload ccron
# or
kill -HUP $(pgrep ccron)go build -ldflags="-s -w" -o ccron .