[tasks.*]
A task is a unit of work that runs and then exits. The TOML key —
backup-db in [tasks.backup-db] — is the task name, and it turns
up everywhere: the CLI, the API, the Web UI, and the on-disk log path.
Names have to be unique across both [tasks.*] and [services.*].
Names can use letters, digits, and . _ - : (up to 100 characters).
Anything beyond TOML’s bare-key set needs quotes around the table key —
[tasks."db:backup"]. On disk, log directories flatten : and .
to _, so db:backup logs land under db_backup/.
Really only two things are required: the table itself and a run.
Everything else either inherits from
[defaults] or comes with a sensible
built-in default.
Minimum example
Section titled “Minimum example”[tasks.heartbeat]cron = "*/5 * * * *"run = "/usr/local/bin/heartbeat"That’s a complete, valid task. It fires every five minutes, captures stdout/stderr to a log file, and writes a row for every run to SQLite.
Identity & metadata
Section titled “Identity & metadata”| Key | Default | What it does |
|---|---|---|
| table key | required | The task name. Used in CLI (runwisp exec <name>), API, log paths. |
run | required | Shell command. Multi-line OK with TOML triple-quotes ("""…"""). |
description | (empty) | Human-readable description shown in the UI and TUI. |
group | "Tasks" | UI grouping label. Set to share a section header with related tasks. |
api_trigger | true | Allow manual triggers from CLI / API / UI. Set false to make the task cron-only. |
Scheduling
Section titled “Scheduling”| Key | Default | What it does |
|---|---|---|
cron | (none) | 5- or 6-field cron expression (the optional 6th field is leading seconds). Supports @hourly, @every 1h30m. |
timezone | inherited | IANA timezone for this task’s cron evaluation (e.g. "Europe/Bratislava"). Falls back to [scheduler] timezone, which itself falls back to the host’s system zone if unset. |
jitter | inherited | Cap how far this cron task’s start may slip (up to this window) so tasks sharing a fire time take turns instead of stampeding. Needs a cron (no-op without one). Details below. |
catch_up | "latest" | What to do for missed firings on startup: latest, all, or skip. |
max_catch_up_runs | 100 | Cap on catch-up runs when catch_up = "all". Positive integer. Zero means “use the default” (100); negatives are rejected at config load. |
run_on_start | false | Fire the task once every time the daemon starts, on top of any cron. The @reboot equivalent. |
Leave cron off and the task becomes manual-only — it runs only
when something explicitly triggers it. That’s a perfectly good pattern
for one-shot deploy hooks and the like.
The familiar 5-field form (min hour day month weekday) still does exactly
what it always did — 0 3 * * * fires at 03:00, on the :00 second. If you
need sub-minute precision, prepend a seconds field for a 6-field spec:
*/30 * * * * * fires every 30 seconds (on the :00 and :30), and
15 * * * * * fires once a minute at the :15 second. When you want a plain
fixed interval that doesn’t care about wall-clock alignment, @every 30s is
still the simpler choice.
[tasks.healthcheck]cron = "*/30 * * * * *" # every 30 seconds, aligned to :00 and :30run = "curl -fsS http://localhost:8080/healthz"run_on_start = true fires the task once at daemon boot — the cron
equivalent of @reboot. It’s orthogonal to cron: a task can fire at
boot and on a schedule, or at boot only (leave cron off). The boot
firing is independent of catch_up and isn’t counted against
max_catch_up_runs; it’s tagged triggered_by = startup in the run
history so you can tell it apart from a scheduled or manual run. Use it to
warm a cache, reconcile state, or send a “daemon is up” heartbeat.
[tasks.warm-cache]run_on_start = truerun = "/usr/local/bin/warm-cache"# no cron — fires once per boot and never againHow scheduling works has the full cron grammar, the DST behaviour, and the catchup details.
jitter
Section titled “jitter”When a pile of tasks share a fire time — the classic 0 3 * * * nightly
batch — they all launch in the same instant and spike CPU, IO, and memory
together. jitter stops that stampede without making anything wait that
doesn’t have to:
[tasks.nightly-report]cron = "0 3 * * *"jitter = "30m" # start may slip up to 30m while other jittered runs are busyRunWisp targets one in-flight jittered run at a time across the whole daemon. The window is the most a start can slip — a deadline, not a fixed offset:
- Idle box, no delay. Nothing else jittered in flight? The task starts
right at
03:00. The window only bites when jittered runs actually contend. - Contention, they take turns. Tasks that pile up release one after another as the gate frees — back-to-back when runs are quick, and for a big backlog, spread across the window so starts don’t burst at the edge.
- Earliest deadline first. Each task gets a staggered slot inside its window, picked to sit as far from its neighbours as possible; when the gate frees, the task with the earliest slot goes next.
A few more things worth knowing:
- The window is a ceiling, not an offset.
jitter = "30m"means “this start may slip up to 30 minutes,” not “start 30 minutes late.” Most days, with nothing contending, it slips zero. - The run records both times.
created_atis the cron tick,start_atis when it actually ran; the gap between them is the jitter, right there in the run history. Nothing hidden. - Next-run shows the tick. Because the real start depends on how busy
the gate is — just like a queued run — the dashboard, TUI, and
GET /api/tasksshow the cron tick (03:00) as the next run, not a guessed start. - Needs a
cron. Jitter paces scheduled firings, so it’s a harmless no-op on a manual-only task. Manual triggers, retries, restarts, and catch-up runs never wait on the gate — they fire right away. - It’s self-limiting. RunWisp never delays a run past its own next
tick: put
jitter = "30m"on a* * * * *task and the window quietly clamps to under a minute. - Coordinated by time of day. The slots line tasks up on a 24-hour
dial, so daily, weekly, and monthly jobs at the same clock time
cooperate. Tasks whose windows don’t overlap never crowd each other’s
slots; schedules that don’t line up to a 24-hour rhythm (like
@every 7h) still get a slot within their own window but coordinate only approximately.
Set it once in [defaults] and every
cron task inherits it — one line to pace your whole schedule.
api_trigger = false — cron-only tasks
Section titled “api_trigger = false — cron-only tasks”With the default api_trigger = true, you can kick the task off on
demand from the CLI (runwisp exec <name>), the REST API
(POST /api/tasks/{name}/run), the Web UI’s Run Task button, or the
TUI’s r key.
Set api_trigger = false and the rule becomes only the scheduler gets
to start this task. That’s the one you want for tasks that are
genuinely risky to fire off-schedule — an overnight rebuild, a data job
that isn’t safe to run twice, anything timed around another schedule
that a curious operator could break by double-firing it.
[tasks.nightly-merge]cron = "0 4 * * *"api_trigger = false # only fires from cron, never from the UI / API / CLIrun = "/usr/local/bin/merge-shards"Here’s what flips when api_trigger = false:
POST /api/tasks/{name}/runcomes back 403 Forbidden with the messageAPI triggering disabled for this task.runwisp exec <name>shows the same error and exits non-zero.- Cron firings, retries, and
on_overlapare untouched — the scheduler isn’t “API triggering,” so it runs the task as normal.
api_trigger and cron are independent of each other, which leaves one
trap: a task with api_trigger = false and no cron is a dead task
— nothing can ever start it. The loader won’t reject that combination
today, but it’s almost always a mistake.
Concurrency
Section titled “Concurrency”| Key | Default | What it does |
|---|---|---|
max_concurrent | 1 | Maximum overlapping runs of this task. Positive integer; hard internal cap of 1024. |
on_overlap | "queue" | What happens when a new firing arrives at the max_concurrent limit: queue / skip / terminate. |
queue_max | 100 | When on_overlap = "queue", cap on pending firings. New firings past the cap record end_reason = "queue_full". Positive integer; hard internal cap of 10000. Zero means “use the default”; negatives are rejected. |
Most cron-style work is happiest with max_concurrent = 1.
Concurrency policies walks through when each
policy is the right call.
Retries & timeout
Section titled “Retries & timeout”| Key | Default | What it does |
|---|---|---|
retry_attempts | 0 | Additional attempts after the initial failure. Non-negative integer; hard internal cap of 100. |
retry_delay | "5s" | Base delay between attempts. Only consulted when retry_attempts > 0. |
retry_backoff | "constant" | Curve applied to retry_delay: constant, linear, or exponential. |
restart | "never" | What to do after a run ends: "never" or "on_failure". "always" is rejected on tasks — use [services.*] for an always-on process. |
timeout | inherited from [defaults] | Per-attempt wall-clock cap. Unset means no timeout. |
graceful_stop | "5s" | Grace period after the stop signal, before SIGKILL — on timeout, on_overlap = "terminate", manual stop, and daemon shutdown. Set "0s" for insta-kill. |
stop_signal | "SIGTERM" | Signal that opens the stop ladder. One of SIGTERM, SIGINT, SIGQUIT, SIGHUP, SIGKILL, SIGUSR1, SIGUSR2 (the SIG prefix is optional). |
exit_codes | [0] | Exit codes treated as success. Anything else is failed (which then drives retries, restart, and failure notifications). Codes are 0–255. |
By default only exit 0 is success. Some tools signal “nothing to do”
or “changes pending” with a non-zero code that isn’t really a failure —
list those alongside 0 so they don’t trip retries or alerts:
[tasks.rsync-mirror]run = "rsync --quiet ... ; exit $?"exit_codes = [0, 24] # 24 = "some files vanished during transfer", harmless hereRetries fire on failed, timeout, crashed, log_overflow, and
start_failed. A manual stop or
an on_overlap = "terminate" ends the chain — see the
terminate ⇄ retries callout. The
full table of backoff formulas lives on the
Retries & timeouts page.
graceful_stop covers the whole process group: the stop signal goes to
the task’s process group, every descendant gets the same grace window,
and anything still alive afterward is SIGKILL’d together. The signal is
SIGTERM unless you set stop_signal — pick SIGINT for tools that
treat it as “Ctrl-C”, or SIGKILL to skip the grace window entirely (the
daemon then kills immediately, the same as graceful_stop = "0s"). If
your graceful_stop is longer than
[daemon] shutdown_timeout,
the daemon warns you at boot and names the task — because at shutdown
it’ll SIGKILL the straggler before the per-task window is even up.
Logs & retention
Section titled “Logs & retention”| Key | Default | What it does |
|---|---|---|
log_max_size | 100MB | Per-run log cap. Units: b, kb, mb, gb, tb. Bare 0, negative sizes, and malformed strings are rejected. |
log_on_full | "drop_old" | What to do at the cap: drop_new, drop_old, kill_task. |
keep_runs | inherited from [defaults] | Keep the N most recent runs for this task. Positive integer; hard internal cap of 1 000 000. Omit to inherit; zero means “inherit from [defaults]”; negatives are rejected. |
keep_for | inherited from [defaults] | Delete runs older than this. Omit to inherit; zero and negatives rejected, and absurdly large values (over ~100 years) are rejected as typos. |
Most duration fields (timeout, retry_delay, graceful_stop,
jitter, etc.) use Go’s standard h/m/s/ms syntax. The only
exception is keep_for, which adds d (days) and w (weeks) on top —
so "30d" and "2w" work for keep_for but nowhere else. Mixed forms
like "1h30m" work everywhere.
Set both keep_runs and keep_for and they both apply — whichever one
trims more aggressively is the one you’ll feel. See
Logs & retention for the full picture, including the
.prev rotation lifecycle and the fixed one-hour cleanup cadence.
Environment & secrets
Section titled “Environment & secrets”| Key | Default | What it does |
|---|---|---|
env | (none) | Inline KEY/VALUE map overlaid on the task’s process env. Visible in the API, UI, and CLI. |
env_file | (none) | Path to a dotenv file merged beneath env. Its values are visible too. |
secrets | (none) | Inline KEY/VALUE map, same mechanics as env — but its keys and values never leave the daemon. |
secrets_file | (none) | Path to a dotenv file merged beneath secrets. Only the path is visible; contents are not. |
The split is about visibility, not mechanics. Everything under
env / env_file is treated as out in the open — it sits right next to
cron and run in the dashboard, the TUI, and GET /api/tasks, so
anyone logged into the daemon can read it. Everything under
secrets / secrets_file reaches the spawned process exactly the same
way, but the API and UI never show the keys or values — at most you’ll
see the secrets_file path, so you can tell where values come from.
When the task starts, all four layers merge on top of the daemon’s own environment. On a key collision, later wins:
- The daemon process’s own environment.
env_file, thenenv— defaults first, then the task’s own.secrets_file, thensecrets— defaults first, then the task’s own.
So within each pair the file merges beneath the inline map (docker-compose style: inline entries win), and secrets sit above plain env, so a secret always beats a same-named public value.
File paths can be absolute, ~/-relative, or relative to the directory
runwisp.toml lives in — the same resolution as every other file
reference. The format is plain dotenv: KEY=VALUE per line, # starts
a comment, blank lines are skipped, and the contents are read literally
— no shell expansion, and no ${...} substitution
either.
[tasks.backup]cron = "0 3 * * *"run = "/usr/local/bin/backup.sh"env_file = "backup.env" # visible configsecrets_file = "/etc/runwisp/backup-secrets.env" # invisible credentials
[tasks.backup.env]BACKUP_BUCKET = "s3://prod-backups"DRY_RUN = "0"
[tasks.backup.secrets]RESTIC_PASSWORD = "${file:~/.config/runwisp/restic.pass}"That last line shows the other tool in the box:
${...} substitution pulls a value from
an env var or a file into any string at config load — including into
inline secrets. Use whichever reads better; the visibility rule is
decided by which key the value lands in, not where it came from.
It’s all validated at config load, so a bad entry fails loudly up front:
keys have to match ^[A-Za-z_][A-Za-z0-9_]*$, values can’t contain NUL
bytes, no single value can top 32 KiB, and the env + secrets entries for
a task can’t add up to more than 256.
env and secrets are great for values that stay constant across runs.
When what changes every run is the value — a project ID you fill in at
trigger time — that’s a parameter.
Parameters
Section titled “Parameters”Sometimes a task is almost the same every time, except for one or two
things you only know when you press “run” — a project id, a date range, a
--dry-run flag. That’s what params is for. You declare the inputs a
task accepts in runwisp.toml, and the dashboard, TUI, and REST API
all grow a little form for them. Scheduled runs (cron,
run_on_start, catch-up, retries) just use the declared defaults.
The config is still the only place parameters are defined. The UI and
API only ever supply values for what you’ve already declared — they
can’t invent a new flag or change a command. And every value is passed to
your program as a real argv entry or an env var, never spliced into the
shell string, so an operator typing ; rm -rf / into a field gets an
inert string, not a catastrophe.
params is an array of inline tables, one per input. Each table names
exactly one of four keywords, which decides both how the value is fed
to your command and the key you use everywhere else:
| Keyword | How it reaches the command | Example declaration |
|---|---|---|
env | Exported as an environment variable | { env = "PROJECT_ID" } |
arg | Appended as a positional argument, in declaration order | { arg = "source" } |
option | Appended as --name value (or --name=value, see below) | { option = "--region" } |
flag | A boolean; the token is appended when on, omitted when off | { flag = "--force" } |
[tasks.backup]run = "/usr/local/bin/backup.sh"params = [ { env = "PROJECT_ID", required = true }, { arg = "source", required = true }, { arg = "dest", default = "/backups" }, { option = "--region", choices = ["us", "eu"] }, { option = "--limit", type = "number", default = 100 }, { flag = "--force" },]Trigger that with source = "/data", --region = "eu", and --force
on, and RunWisp runs the equivalent of:
PROJECT_ID=… /usr/local/bin/backup.sh '/data' '/backups' '--region' 'eu' '--limit' '100' '--force'Tokens render in the order you declare them — the example above lays out args,
options, then a flag because that’s how it’s written, but if you declared the
option before the arg it would land before that arg on the command line.
You control the ordering. An option whose name ends in = renders glued
together — { option = "--date=" } with value 2026-01-01 becomes
--date=2026-01-01 rather than two tokens.
If your run is a multi-line script, the tokens attach to its final
command, so end the script with the program that should receive them (e.g.
the backup.sh call on the last line, not a trailing comment).
Each input takes a few optional modifiers:
| Modifier | Applies to | What it does |
|---|---|---|
default | env / arg / option / flag | Value used by scheduled runs and pre-filled in manual forms. For a flag, true/false. |
required | env / arg / option | The manual form won’t submit without a value. Can’t be combined with a scheduled task unless it also has a default (a cron tick has no one to ask). |
choices | env / arg / option | Renders as a dropdown; values outside the list are rejected. |
allow_custom | env / arg / option | With choices, also lets the operator type a value not in the list. |
type | env / arg / option | "string" (default) or "number"; a number that doesn’t parse is rejected. |
description | any | Help text shown under the field. |
A few rules the loader enforces up front: each table has exactly one of
env/arg/option/flag; env/arg keys match the usual env-name
shape and option/flag names start with -; an env parameter can’t
collide with a static env/secrets key on the same task; flag takes
neither choices nor type; and allow_custom only makes sense with
choices. params belongs on [tasks.*] — services don’t take it.
Whatever values actually took effect (defaults filled in, flags canonicalised, optional blanks dropped) are recorded on the run and shown in its detail view, so run history always tells you what a given execution was handed.
Omitting a value vs. passing an empty one
Section titled “Omitting a value vs. passing an empty one”When you trigger a task by hand, a value field with a default starts
pre-filled with it. Three things can happen with that field, and they’re
genuinely different:
- Leave it as-is and the value (the default, or whatever you typed) is passed.
- Clear it and the parameter is omitted — the option, arg, or env var
isn’t passed at all. Clearing does not fall back to the default; an
empty field means “don’t send this.” A
requiredfield can’t be omitted, so it keeps nagging until you fill it. - Pass an empty string when your program distinguishes
--note ''from no--noteat all. The form defaults a blank field to “omitted”, so use the small toggle under the field (in the TUI, ctrl+t) to flip it to “passing empty string”. The field then shows it’ll send"".
Flags don’t have this ambiguity — the checkbox is the whole story, off means
the token isn’t appended. Dropdowns (choices) omit when left unset.
Scheduled runs (cron, run_on_start, catch-up) and retries don’t go through
a form: they use the declared defaults, and a retry replays exactly what the
original run was given — including anything the operator deliberately omitted.
Working directory & shell
Section titled “Working directory & shell”| Key | Default | What it does |
|---|---|---|
working_dir | (daemon) | Directory the process runs in. Relative paths resolve against the runwisp.toml directory; ~ expands. Existence is checked at run time, not config load. |
shell | /bin/sh | Interpreter for run. Must be an absolute path (e.g. /bin/bash). The invocation is always <shell> -c <script>. |
umask | (daemon) | Octal file-creation mask applied to the run, e.g. "027". 3–4 octal digits, up to 0777. Empty inherits the daemon’s umask. |
user | (daemon) | Run as another OS user, in user or user:group form (name or numeric id). Empty keeps the daemon’s identity. Needs the daemon running as root. |
By default a run script executes under /bin/sh in whatever directory
the daemon was started from. Set working_dir when a script expects to be
somewhere specific, and shell when you want bash-isms (arrays, [[ ]],
brace expansion). A relative shell path fails at config load; a missing
working_dir fails at run time. shell can also be set
once in [defaults] for every task.
umask controls the permission bits stripped from files the run creates —
"027" makes new files rw-r----- and directories rwxr-x---, handy when
a job writes logs or dumps that shouldn’t be world-readable. It’s applied
inside the run’s own process (so concurrent runs never affect each other or
the daemon), and it’s digit-only, so there’s nothing to escape. Spell it
with at least three digits — "22" is rejected because it’s ambiguous;
write "022".
user drops the run to another account. Write a name or a numeric id, and
add :group (also name or id) when you want a specific group too — the same
user:group shorthand chown and Docker Compose use. The daemon resolves
the account when the run starts (not at config load, so the user can be
created after the daemon boots), seeds HOME/USER/LOGNAME for it, and
applies the user’s supplementary groups. Dropping privileges only works when
the daemon itself runs as root — if it doesn’t, the run fails loudly with
a start error rather than quietly staying as the daemon’s user.
[tasks.report]working_dir = "/srv/app"shell = "/bin/bash"umask = "027"user = "reporter:reporter"run = "./bin/generate-report --out reports/"Compose-backed tasks
Section titled “Compose-backed tasks”A cron-driven [tasks.<name>] can also point at a compose service for
one-shot runs:
[tasks.nightly-backup]cron = "0 3 * * *"compose_file = "./docker-compose.yml"compose_service = "backup"Each firing invokes docker compose run --rm <svc>; the container is
removed after exit and the exit code is captured. run and
compose_file are mutually exclusive. For importing every service in a
compose file as observable RunWisp services, see [compose.*].
Notifications
Section titled “Notifications”| Key | Default | What it does |
|---|---|---|
notify_on_failure | (none) | Notifier IDs to alert on run.failed / run.timeout / run.crashed. |
notify_on_success | (none) | Notifier IDs to alert on run.succeeded. |
notify_on_missed | true | Whether a missed scheduled run alerts. It rides notify_on_failure’s channels automatically; set false to silence the alert (the missed run row is still recorded). |
Each entry of notify_on_failure / notify_on_success is a notifier id you’ve declared in a
[[notifier]]
block. Whatever’s listed in [notify] global_notifiers (default
["inapp"]) gets tacked on automatically — set global_notifiers = []
to opt out, or ["slack-ops"] to route every failure there instead.
Per-task notifications covers the field’s
full behaviour.
These two fields are the same on [tasks.*] and [services.*] —
same names, same behaviour, same duplicate-removal rules. The
Per-task notifications page is the canonical
reference for both, and the
[services.*] notifications
section just points back here.
What’s rejected on tasks
Section titled “What’s rejected on tasks”The config loader turns these away at startup, so they can’t quietly bite you later:
restart = "always"— that’s what[services.*]is for.instances = N— a services-only field.priority/autostart— services-only; they govern boot start order and whether a service comes up at boot.depends_on— services-only; it gates a service’s boot on other services being healthy.- A task name that’s also taken by a
[services.*]— they share one namespace. - An empty or missing
run.
Worked example
Section titled “Worked example”[tasks.process-event-queue]group = "Workers"description = "Drain the queue every 10 minutes with retries"cron = "*/10 * * * *"on_overlap = "skip" # never two queue drainers at oncetimeout = "9m" # die before the next firinggraceful_stop = "30s" # this drainer needs more than the 5s defaultretry_attempts = 3retry_delay = "2s"retry_backoff = "exponential"keep_runs = 200keep_for = "14d"notify_on_failure = ["slack-ops"]run = """set -eutrap 'echo "draining gracefully"; /usr/local/bin/process-queue --drain' TERMecho "Draining queue at $(date -Iseconds)"/usr/local/bin/process-queue"""Want a head start? Run runwisp in an empty directory and it’ll offer
to scaffold a minimal runwisp.toml for you.