Scheduled Jobs skill for Claude Code
Cron jobs safe to rerun, overlap-proof and watched. Claude designs the job to be rerun safely before choosing a schedule, then wires it into a systemd timer, cron, launchd or a Kubernetes CronJob with catch-up, jitter and a time limit. A bundled wrapper adds a portable single-instance lock, UTC logs and healthcheck pings, and every job ships with a heartbeat monitor that alerts when it silently stops running.
- name
scheduled-jobs - size212 lines · ≈2,150 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds scheduled jobs that are safe to run, rerun, and miss — cron and systemd timers on Linux, launchd on macOS, Kubernetes CronJobs, GitHub Actions schedules, and cloud schedulers — with idempotent scripts, single-instance locking, timeouts, timezone and DST handling, catch-up of missed runs, structured logs, and dead-man's-switch monitoring. Use when writing a crontab entry, creating a systemd timer, scheduling a nightly backup, report, sync, or cleanup script, debugging a cron job that did not run, or adding alerting for missed runs. Try asking
- Turn this backup script into a nightly systemd timer with alerts if it ever misses a run
- My cron job works when I run it by hand but never runs from crontab, figure out why
- Make this sync job safe to run twice and stop overlapping runs
Use it when
- You need a script to run on a schedule
- A cron job is not running or runs twice
- You want alerts when a scheduled job fails or stops
- Moving jobs from cron to systemd timers or a cloud scheduler
Skip it when
- Jobs are triggered by app events and need queues and workers — use background-jobs
- You need a long-running daemon rather than a periodic task — use systemd-services
How to check the result
A manual run, an immediate second run and a concurrent start all behave correctly, the next run time is confirmed, and pausing the job trips the heartbeat alert.
The common mistake it prevents
Monitoring only for errors, so a job whose timer was disabled or whose host died goes unnoticed for weeks.
Done well means: the job produces the same end state whether it runs once, twice, or after a missed day; two copies never run at once; it has a time limit; its output lands in a log you can find; secrets are not in the crontab; and someone is alerted when it fails or silently stops running.
Hard rules
- Every scheduled script is idempotent. Design for “run it again” before writing the schedule.
- Monitor absence, not just failure. A job that never starts sends no error; use a heartbeat (dead-man’s switch) that alerts when a ping does not arrive.
- Never put secrets in crontab lines, unit files committed to git, or command
arguments (visible in
ps). Use an env file with mode 600, systemd credentials, or the platform’s secret store. - Schedule in UTC unless the business requirement is local wall-clock time; then handle DST explicitly.
- Jobs that delete data (cleanup, retention) get a dry-run mode and a first run reviewed with the user before enabling the schedule.
Pick the scheduler
| Environment | Default | Why |
|---|---|---|
| Linux server (systemd) | systemd timer + service | Logs in journald, Persistent= catch-up, resource limits, no overlapping by default |
| Linux without systemd / container host minimal | cron with a lock wrapper | Ubiquitous; needs your own locking, logging, env |
| macOS user tasks | launchd LaunchAgent (StartCalendarInterval) | cron works but launchd runs missed jobs after sleep |
| Kubernetes | CronJob with concurrencyPolicy: Forbid | Native retries, history, resource limits |
| Repository maintenance | GitHub Actions schedule: | No server; but runs can be delayed or dropped at busy times, and schedules in inactive public repos get disabled |
| Cloud serverless | EventBridge Scheduler, Cloud Scheduler, Azure Functions timer, Cloudflare Cron Triggers | Managed, retry policies, IAM instead of keys |
| Many jobs with dependencies | Workflow orchestrator (Airflow, Dagster, Temporal) | DAGs, backfills, per-task retries |
Workflow
- [ ] 1. Define: what, how often, how late is acceptable, what happens if missed
- [ ] 2. Make the script idempotent and give it a dry-run flag
- [ ] 3. Add locking, timeout, logging, exit codes
- [ ] 4. Configure the scheduler (UTC, catch-up, jitter)
- [ ] 5. Wire secrets and environment explicitly
- [ ] 6. Add heartbeat monitoring and failure alerts
- [ ] 7. Test: manual run, double run, killed run, missed run
2. Idempotency patterns
| Job type | Idempotent design |
|---|---|
| Daily report/export | Output named by the period it covers (report_2025-10-13.csv), not by run time; write to temp then atomic rename |
| Sync / ETL | Upsert by natural key; track a high-water mark committed only after success |
| Sending emails/notifications | Record a sent-ledger keyed by (recipient, period); skip if present |
| Cleanup / retention | Delete by age criteria, not “since last run”; dry-run lists targets first |
| Backups | Name by timestamp, verify, then prune by retention policy |
Process a period derived from the schedule (“yesterday in UTC”), passed as an argument
with a default, so a rerun or backfill is job --date 2025-10-12.
3. Wrapper: lock, timeout, log, ping
Use the bundled wrapper for cron and launchd jobs (systemd already prevents overlap):
scripts/run-locked.sh -n nightly-export -t 3600 -p "https://hc-ping.com/<uuid>" -- /opt/jobs/export.sh --date yesterday
scripts/run-locked.sh takes a single-instance lock with mkdir (atomic, works on
macOS and Linux, clears stale locks whose PID is gone), logs start/end/duration with UTC
timestamps to stderr, applies a timeout when GNU timeout/gtimeout exists, pings
<url>/start and <url>/<exit-code> (healthchecks.io convention), and exits 75 when a
previous run still holds the lock. Linux-only alternative: flock -n /run/lock/job.lock cmd.
Exit codes matter: 0 success, non-zero failure. Never end a job script with a command that masks earlier failures.
4a. systemd timer (preferred on Linux)
# /etc/systemd/system/nightly-export.service
[Unit]
Description=Nightly export
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=jobs
EnvironmentFile=/etc/jobs/nightly-export.env
ExecStart=/opt/jobs/export.sh --date yesterday
TimeoutStartSec=1h
Nice=10
# hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/exports
PrivateTmp=yes
# /etc/systemd/system/nightly-export.timer
[Unit]
Description=Run nightly export at 02:15 UTC
[Timer]
OnCalendar=*-*-* 02:15:00 UTC
Persistent=true
RandomizedDelaySec=5m
AccuracySec=1m
[Install]
WantedBy=timers.target
systemd-analyze calendar '*-*-* 02:15:00 UTC' # validate and show next elapse
sudo systemd-analyze verify /etc/systemd/system/nightly-export.*
sudo systemctl daemon-reload
sudo systemctl enable --now nightly-export.timer
systemctl list-timers nightly-export.timer
sudo systemctl start nightly-export.service # run once now
journalctl -u nightly-export.service -n 100 --no-pager
Persistent=true runs a missed occurrence at boot. A oneshot service cannot overlap
itself: if it is still running when the timer fires, the start is skipped. Add
OnFailure=notify-failure@%n.service to the [Unit] of the service for alerts.
4b. cron
# m h dom mon dow command (crontab -e; system crontabs also take a user field)
CRON_TZ=UTC
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
15 2 * * * /opt/jobs/run-locked.sh -n nightly-export -t 3600 -- /opt/jobs/export.sh >> /var/log/jobs/nightly-export.log 2>&1
- cron runs with a minimal environment: set
PATH, use absolute paths, and source an env file inside the script (set -a; . /etc/jobs/x.env; set +a). %is special in crontab (newline); escape it as\%(e.g., indate +\%F).CRON_TZis supported by cronie (RHEL/Fedora) but not every cron; on Debian/Ubuntu cron, schedule in the system timezone or keep the server on UTC.- Rotate logs with logrotate, or log to syslog via
logger -t nightly-export. - Validate expressions before installing (crontab.guru, or
systemd-analyze calendarwhen porting to a timer).
4c. Kubernetes CronJob essentials
spec:
schedule: "15 2 * * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
Timezones and DST
In a local timezone, 02:00-03:00 occurs zero times on spring-forward day and twice on fall-back day. Either schedule in UTC, or avoid the 01:00-03:00 local window, or make the job idempotent per date so a double run is harmless.
5. Secrets and environment
- Env file owned by the job user, mode 600, outside the repo:
/etc/jobs/x.env. - systemd:
LoadCredential=token:/etc/jobs/tokenthen read$CREDENTIALS_DIRECTORY/token. - Cloud schedulers: run under a dedicated service identity with least privilege; never embed long-lived keys.
6. Monitoring
- Heartbeat service (Healthchecks.io, Cronitor, Better Stack, Dead Man’s Snitch, or a self-hosted Healthchecks): set period = schedule interval and grace = expected duration + slack. Alert on missed or failed pings.
- Also alert on duration drift (a job that took 5 minutes now takes 50).
- For data jobs, check outputs too: row counts, file present and non-empty, freshness of the newest record.
Verification
- [ ] Manual run succeeds and logs start, end, duration
- [ ] Second run immediately after produces no duplicate side effects
- [ ] Concurrent start is skipped (exit 75 with the wrapper; skipped start under systemd)
- [ ] Killing the job mid-run leaves no partial output in place; the next run recovers
- [ ] Next scheduled time confirmed (systemctl list-timers / crontab -l / kubectl get cronjob)
- [ ] Heartbeat check shows "up"; pausing the job triggers an alert within the grace period
Gotchas
- “Works when I run it, not from cron”: environment (PATH, HOME, locale), working
directory, or permissions. Reproduce with
env -i HOME=$HOME PATH=/usr/bin:/bin bash -c '<cmd>'. - Laptops sleep: cron skips missed runs; launchd and systemd
Persistent=truecatch up. - Many hosts firing at :00 overload shared services; add jitter (
RandomizedDelaySec). - GitHub Actions schedules run on the default branch only and can be delayed; never rely on them for exact timing.
- A job that alerts on every transient failure gets muted; retry with backoff inside the job and alert on the final failure.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/run-locked.sh
#!/usr/bin/env bash
# Purpose: wrap a scheduled command with a single-instance lock, timestamped logs,
# an optional timeout, and optional dead-man's-switch pings.
# Usage: run-locked.sh [-n name] [-t seconds] [-p ping_url] [-l lockdir] -- command [args...]
# -n job name (default: basename of command); used in logs and the lock path
# -t kill the command after N seconds (needs GNU `timeout` or `gtimeout`)
# -p ping URL (healthchecks.io style): GET <url>/start before, <url>/<exit code> after
# -l directory for locks (default: ${XDG_RUNTIME_DIR:-/tmp})
# Exit: command's exit code; 75 if another instance holds the lock; 2 on usage error.
set -Eeuo pipefail
name="" timeout_s="" ping="" lockroot="${XDG_RUNTIME_DIR:-/tmp}"
usage() { sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; }
log() { printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${name:-run-locked}" "$*" >&2; }
while getopts ":n:t:p:l:h" opt; do
case "$opt" in
n) name="$OPTARG" ;;
t) timeout_s="$OPTARG" ;;
p) ping="${OPTARG%/}" ;;
l) lockroot="$OPTARG" ;;
h) usage; exit 0 ;;
:) log "option -$OPTARG needs a value"; exit 2 ;;
*) usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
[[ "${1:-}" == "--" ]] && shift
[[ $# -ge 1 ]] || { usage >&2; exit 2; }
[[ -z "$timeout_s" || "$timeout_s" =~ ^[0-9]+$ ]] || { log "-t must be an integer"; exit 2; }
name="${name:-$(basename -- "$1")}"
[[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || { log "job name may only contain letters, digits, . _ -"; exit 2; }
ping_url() { # never let monitoring failures break the job
[[ -n "$ping" ]] || return 0
command -v curl >/dev/null || { log "curl missing; ping skipped"; return 0; }
curl -fsS -m 10 --retry 3 -o /dev/null "$ping$1" || log "ping $1 failed (ignored)"
}
# Lock: mkdir is atomic on every POSIX filesystem (works on macOS, no flock needed).
lockdir="$lockroot/$name.lock"
mkdir -p -- "$lockroot"
if ! mkdir -- "$lockdir" 2>/dev/null; then
oldpid="$(cat -- "$lockdir/pid" 2>/dev/null || true)"
if [[ -n "$oldpid" ]] && kill -0 "$oldpid" 2>/dev/null; then
log "another instance (pid $oldpid) is running; skipping this run"
exit 75
fi
log "removing stale lock (pid ${oldpid:-unknown} not running)"
rm -rf -- "$lockdir"
mkdir -- "$lockdir" || { log "lost lock race; skipping"; exit 75; }
fi
echo "$$" > "$lockdir/pid"
trap 'rm -rf -- "$lockdir"' EXIT
trap 'log "terminated by signal"; exit 143' TERM INT
runner=()
if [[ -n "$timeout_s" ]]; then
if command -v timeout >/dev/null; then runner=(timeout --kill-after=30 "$timeout_s")
elif command -v gtimeout >/dev/null; then runner=(gtimeout --kill-after=30 "$timeout_s")
else log "no timeout/gtimeout binary; running without a time limit"; fi
fi
ping_url /start
start=$(date +%s)
log "start: $*"
set +e
"${runner[@]+"${runner[@]}"}" "$@"
rc=$?
set -e
dur=$(( $(date +%s) - start ))
if [[ $rc -eq 0 ]]; then log "success in ${dur}s"
elif [[ $rc -eq 124 && -n "$timeout_s" ]]; then log "TIMEOUT after ${timeout_s}s"
else log "FAILED with exit $rc after ${dur}s"; fi
ping_url "/$rc"
exit "$rc"
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.