Job Kinds
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.
shell
Runs the command field under /bin/sh -c. Stdout and stderr are merged into the run log.
- Command format: any shell snippet. Quote shell metacharacters.
- Working directory: optional
working_dirfield (defaults to the scheduler’s cwd, typically/var/lib/cronlordunder systemd). - Environment: per-job env vars layer on top of the scheduler’s env. Don’t rely on this for secrets - the values land in the DB as plain text; use a secret manager that exports at runtime.
- Timeout:
timeout_sec> 0 sends SIGTERM at the deadline, SIGKILL 2 seconds later. Exit status becomestimeout.
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 codes
| Exit | Status in UI |
|---|---|
0 | success |
| non-zero | fail |
| SIGTERM after timeout | timeout |
http
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.
Plain URL form
https://api.example.com/cron/daily-rollup
JSON form
{
"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. DefaultGET.url- required. Scheme must behttporhttps. The runner rejectsfile://,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:2xxis success.follow- follow redirects. Defaulttrue.
Response handling
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:
- The status code does not match
expect_status. - The connection is refused or times out.
- The URL scheme isn’t
http/https.
claude
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.
Requirements
- The
claudeCLI must be on the scheduler’s$PATH. Override the binary name withCRONLORD_CLAUDE_CLI=/usr/local/bin/claude. - The CLI must be logged in (typically
claude loginonce on the box).
Command format
The command field is the prompt verbatim. CronLord passes it as a single argument to claude -p.
Optional args
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" }
}
Example
[[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
Choosing between them
| 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.
Notifications
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.
Timezones
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:
- On spring-forward, the missing wall-clock hour (
02:00-02:59in the US) doesn’t fire that day. - On fall-back, the repeated wall-clock hour fires exactly once, on the first occurrence. A subsequent
next_aftercall 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.