CronLord ships three job kinds: shell, http, and claude. Each
runs either in the scheduler’s process or on a leased remote worker
(executor = "worker"), and writes its output to a per-run log file
streamed back over SSE. claude is intentionally scheduler-only - see
deployment: supported worker kinds.
Runs the command field under /bin/sh -c. Stdout and stderr are merged
into the run log.
working_dir field (defaults to the
scheduler’s cwd, typically /var/lib/cronlord under systemd).timeout_sec > 0 sends SIGTERM at the deadline, SIGKILL
2 seconds later. Exit status becomes timeout.Example:
[[jobs]]
id = "nightly-backup"
name = "Backup DB"
schedule = "0 3 * * *"
kind = "shell"
command = "pg_dump -Fc mydb | aws s3 cp - s3://backups/mydb-$(date -I).dump"
working_dir = "/var/lib/backup"
timeout_sec = 1800
| Exit | Status in UI |
|---|---|
0 |
success |
| non-zero | fail |
| SIGTERM after timeout | timeout |
Calls an HTTP endpoint. The command field can be either a plain URL
(executes a GET with no body) or a JSON object describing the full
request.
https://api.example.com/cron/daily-rollup
{
"method": "POST",
"url": "https://api.example.com/webhook",
"headers": {
"Content-Type": "application/json",
"X-Signing-Secret": "rot-your-own-secret"
},
"body": "{\"source\":\"cronlord\"}",
"expect_status": 200,
"follow": true
}
Fields:
method - any standard HTTP verb. Default GET.url - required. Scheme must be http or https. The runner rejects
file://, gopher://, and anything non-web to avoid SSRF from stored
credentials.headers - map of header name -> value.body - request body as a string (not an object - encode it yourself
to keep the contract predictable).expect_status - integer or array of integers. If set, any other
response code is treated as a failure. Default: 2xx is success.follow - follow redirects. Default true.The runner logs the status line, the first 32 KB of the response body,
and the total elapsed time. The run is marked fail if:
expect_status.http/https.Runs claude -p <prompt> using the local Claude Code CLI. Useful for
agent-style scheduled tasks - a 5 a.m. repo scan, a weekly vault
summary, a nightly secret-rotation check.
claude CLI must be on the scheduler’s $PATH. Override the
binary name with CRONLORD_CLAUDE_CLI=/usr/local/bin/claude.claude login once on the box).The command field is the prompt verbatim. CronLord passes it as a
single argument to claude -p.
Add a model field to the job’s args JSON (via API or TOML) to pin a
model:
{
"kind": "claude",
"command": "Summarize today's /var/log/syslog and flag anything unusual.",
"args": { "model": "claude-haiku-4-5-20251001" }
}
[[jobs]]
id = "weekly-vault-summary"
name = "Summarize vault changes"
schedule = "0 9 * * 1"
kind = "claude"
command = "Read the last 7 daily notes in /mnt/vault and write a one-page summary to /mnt/vault/summaries/week.md"
timeout_sec = 600
| Need | Use |
|---|---|
| Anything that already runs in a shell | shell |
| Hitting a webhook, health check, or your own API | http |
| A prompt-driven task that calls out to Claude | claude |
| Mixing all three | write three jobs, chain with webhooks |
Retries and webhook notifications work identically across all three kinds. The job editor shows kind-specific help next to the command field so you don’t have to remember the JSON schema.
Every job can carry two optional webhook fields, delivered in parallel
by a best-effort fiber when a run reaches a terminal status (success,
fail, timeout, or cancelled).
| Field | Shape | Purpose |
|---|---|---|
webhook_url |
POST JSON with job_id, run_id, status, exit_code, started_at, finished_at, error, trigger |
Generic automation (PagerDuty, custom dashboards, Zapier, …). |
slack_webhook_url |
POST Slack Block Kit (text + blocks) |
Slack channel posts. |
The Slack field must begin with https://hooks.slack.com/ - anything
else is refused so that a misconfigured or attacker-controlled URL can’t
receive the Slack-shaped payload. Status appears as a text tag
([ok], [fail], [timeout], [cancelled]), never an emoji, so the
message reads the same regardless of the recipient’s client.
Both channels retry up to three times with a two-second gap and log to stderr when they give up; failures never block the scheduler.
Each job has a timezone column (default UTC). The scheduler resolves
it through Crystal’s Time::Location, so any IANA zone your host
supports works - America/New_York, Europe/Berlin, Asia/Tokyo, etc.
The value is validated at save time; an unknown zone is rejected with
400.
DST is handled the POSIX-cron way:
02:00-02:59 in the
US) doesn’t fire that day.next_after call picks up from the
next day.The live cron preview in the job editor re-queries /api/cron/explain
whenever the timezone field changes, so you see the fires in the zone
the job will actually use before you save.