Skip to content

[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.

[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.

KeyDefaultWhat it does
table keyrequiredThe task name. Used in CLI (runwisp exec <name>), API, log paths.
runrequiredShell 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_triggertrueAllow manual triggers from CLI / API / UI. Set false to make the task cron-only.
KeyDefaultWhat it does
cron(none)5- or 6-field cron expression (the optional 6th field is leading seconds). Supports @hourly, @every 1h30m.
timezoneinheritedIANA 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.
jitterinheritedCap 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_runs100Cap on catch-up runs when catch_up = "all". Positive integer. Zero means “use the default” (100); negatives are rejected at config load.
run_on_startfalseFire 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 :30
run = "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 = true
run = "/usr/local/bin/warm-cache"
# no cron — fires once per boot and never again

How scheduling works has the full cron grammar, the DST behaviour, and the catchup details.

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 busy

RunWisp 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_at is the cron tick, start_at is 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/tasks show 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.

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 / CLI
run = "/usr/local/bin/merge-shards"

Here’s what flips when api_trigger = false:

  • POST /api/tasks/{name}/run comes back 403 Forbidden with the message API triggering disabled for this task.
  • runwisp exec <name> shows the same error and exits non-zero.
  • Cron firings, retries, and on_overlap are 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.

KeyDefaultWhat it does
max_concurrent1Maximum 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_max100When 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.

KeyDefaultWhat it does
retry_attempts0Additional 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.
timeoutinherited 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 0255.

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 here

Retries 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.

KeyDefaultWhat it does
log_max_size100MBPer-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_runsinherited 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_forinherited 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.

KeyDefaultWhat 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:

  1. The daemon process’s own environment.
  2. env_file, then env — defaults first, then the task’s own.
  3. secrets_file, then secrets — 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 config
secrets_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.

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:

KeywordHow it reaches the commandExample declaration
envExported as an environment variable{ env = "PROJECT_ID" }
argAppended as a positional argument, in declaration order{ arg = "source" }
optionAppended as --name value (or --name=value, see below){ option = "--region" }
flagA 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:

Terminal window
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:

ModifierApplies toWhat it does
defaultenv / arg / option / flagValue used by scheduled runs and pre-filled in manual forms. For a flag, true/false.
requiredenv / arg / optionThe 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).
choicesenv / arg / optionRenders as a dropdown; values outside the list are rejected.
allow_customenv / arg / optionWith choices, also lets the operator type a value not in the list.
typeenv / arg / option"string" (default) or "number"; a number that doesn’t parse is rejected.
descriptionanyHelp 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.

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 required field can’t be omitted, so it keeps nagging until you fill it.
  • Pass an empty string when your program distinguishes --note '' from no --note at 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.

KeyDefaultWhat 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/shInterpreter 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/"

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.*].

KeyDefaultWhat 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_missedtrueWhether 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.

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.
[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 once
timeout = "9m" # die before the next firing
graceful_stop = "30s" # this drainer needs more than the 5s default
retry_attempts = 3
retry_delay = "2s"
retry_backoff = "exponential"
keep_runs = 200
keep_for = "14d"
notify_on_failure = ["slack-ops"]
run = """
set -eu
trap 'echo "draining gracefully"; /usr/local/bin/process-queue --drain' TERM
echo "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.