Automation & Tooling No. 501Editor's pick

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.

  • namescheduled-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.

~/.claude/skills/scheduled-jobs/SKILL.md

--- name: scheduled-jobs description: Builds scheduled jobs that are safe to run, rerun, and miss — cron and systemd timers on Linux, launchd on macOS, Kubernetes CronJobs,… license: MIT ---

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

EnvironmentDefaultWhy
Linux server (systemd)systemd timer + serviceLogs in journald, Persistent= catch-up, resource limits, no overlapping by default
Linux without systemd / container host minimalcron with a lock wrapperUbiquitous; needs your own locking, logging, env
macOS user taskslaunchd LaunchAgent (StartCalendarInterval)cron works but launchd runs missed jobs after sleep
KubernetesCronJob with concurrencyPolicy: ForbidNative retries, history, resource limits
Repository maintenanceGitHub Actions schedule:No server; but runs can be delayed or dropped at busy times, and schedules in inactive public repos get disabled
Cloud serverlessEventBridge Scheduler, Cloud Scheduler, Azure Functions timer, Cloudflare Cron TriggersManaged, retry policies, IAM instead of keys
Many jobs with dependenciesWorkflow 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 typeIdempotent design
Daily report/exportOutput named by the period it covers (report_2025-10-13.csv), not by run time; write to temp then atomic rename
Sync / ETLUpsert by natural key; track a high-water mark committed only after success
Sending emails/notificationsRecord a sent-ledger keyed by (recipient, period); skip if present
Cleanup / retentionDelete by age criteria, not “since last run”; dry-run lists targets first
BackupsName 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., in date +\%F).
  • CRON_TZ is 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 calendar when 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/token then 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=true catch 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 76 lines · 3.0 KB
#!/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.

esc