Scheduled Actions
A schedule turns an action into a calendar trigger: the controller computes due
instants and queues an ordinary execution at each one. Runners are never aware of
calendars — a scheduled occurrence is a normal queue entry that the existing dispatcher
assigns to a capable runner, exactly like a manual POST /api/actions/:module/:action
trigger.
# actions/nightly_backup.toml
name = "nightly_backup"
description = "Creates a database snapshot every night at 03:30"
schedule = "30 3 * * *"
exec = [["python3", "backup.py"]]
[runtime]
engine = "nix"
flake = "./flake.nix"
files = ["./backup.py"]
Syntax
The schedule field accepts two forms:
# String form: cron expression evaluated in UTC.
schedule = "30 3 * * *"
# Table form: cron expression plus an explicit IANA timezone.
schedule = { cron = "30 3 * * *", timezone = "Europe/Zurich" }
cron(required): a Croner pattern with five fields, or six fields when seconds are given ("15 30 3 * * *"fires at second 15). Nicknames such as@daily,@hourly,@weekly, and@monthlyare supported.timezone(optional, table form only): an IANA timezone name such asEurope/Zurich. Defaults toUTCin both forms.
Templated schedules
Both fields are rendered with the same Tera context as the
action name and constraints during action expansion. A schedule value may therefore be
derived from graph data or module parameters:
# Module parameter supplies the timezone; every expanded instance shares the cron.
name = "report_{{ origin_resource.name }}"
description = "Monthly report for {{ origin_resource.name }}"
origin_resource = "application"
schedule = { cron = "0 8 1 * *", timezone = "{{ params.report_timezone }}" }
exec = [["python3", "report.py"]]
# Each matching application carries its own audit_schedule property, so every
# expanded action instance runs on its own calendar.
name = "audit_{{ origin_resource.name }}"
description = "Audit {{ origin_resource.name }}"
origin_resource = "application"
schedule = "{{ origin_resource.audit_schedule }}"
exec = [["python3", "audit.py"]]
# Relation-bound actions read the schedule from the matching edge.
name = "sync_{{ origin_resource.name }}"
description = "Sync via target"
origin_resource = "application"
relation = "SYNCED_WITH"
schedule = { cron = "{{ relation.properties.sync_schedule }}", timezone = "UTC" }
exec = [["python3", "sync.py"]]
Because a dynamic action expands to one instance per matching resource (or per matching relation edge), each expanded instance becomes an independent schedule binding. Two applications audited by the same action file can run at completely different times, and a slow audit of one application never delays the other.
Cron Patterns
Expressions are parsed with Croner — similar to Vixie Cron, with a few additions:
// ┌──────────────── (optional) second (0 - 59)
// │ ┌────────────── minute (0 - 59)
// │ │ ┌──────────── hour (0 - 23)
// │ │ │ ┌────────── day of month (1 - 31)
// │ │ │ │ ┌──────── month (1 - 12, JAN-DEC)
// │ │ │ │ │ ┌────── day of week (0 - 6, SUN-SAT)
// │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0)
// │ │ │ │ │ │
// * * * * * *
| Field | Required | Allowed values | Allowed special characters | Remarks |
|---|---|---|---|---|
| Seconds | Optional | 0-59 | * , - / |
Only in six-field form. A fixed second is allowed; schedules that repeat more often than once per minute are rejected. |
| Minutes | Yes | 0-59 | * , - / |
|
| Hours | Yes | 0-23 | * , - / |
|
| Day of Month | Yes | 1-31 | * , - / ? L W |
|
| Month | Yes | 1-12 or JAN-DEC | * , - / |
|
| Day of Week | Yes | 0-7 or SUN-SAT | * , - / ? # L + |
7 is Sunday, the same as 0. # selects the nth weekday of the month. |
Additional modifiers:
?— in the day-of-month or day-of-week field it behaves like*(legacy cron compatibility).L— last day of the month in the day-of-month field (0 0 L * *). In the day-of-week field combined with#it selects the last specific weekday of the month (0 0 * * 5#L= last Friday).#— the “nth” occurrence of a weekday within a month:5#2is the second Friday; ranges are supported (MON-FRI#2).W— the weekday (Mon-Fri) closest to a given day of the month, without crossing month boundaries:15Wmatches the 14th if the 15th is a Saturday, the 16th if it is a Sunday.+— prefixing the day-of-week field makes day-of-month and day-of-week a logical AND instead of the default OR:0 0 1 * +MONfires only when the 1st of the month is also a Monday.
Weekday and month names are case-insensitive (MON and mon both work).
Nicknames can be used as the whole pattern:
| Nickname | Description |
|---|---|
@yearly |
Run once a year — 0 0 1 1 * |
@annually |
Run once a year — 0 0 1 1 * |
@monthly |
Run once a month — 0 0 1 * * |
@weekly |
Run once a week — 0 0 * * 0 |
@daily |
Run once a day — 0 0 * * * |
@hourly |
Run once an hour — 0 * * * * |
Semantics
Scheduled actions are bindings — one expanded action definition plus its rendered cron expression and timezone — and each firing produces one occurrence:
- Missed occurrences are skipped. If the controller is offline (or the graph is being rebuilt) when an occurrence falls due, that occurrence is not replayed on startup. The next future occurrence runs normally.
- Overlap is per binding. While an earlier execution of the same binding is still pending, assigned, or running, the next occurrence is skipped and recorded as a skip. A different binding of the same action — for example another application — is not affected.
- No capable runner at due time: the occurrence is queued and waits for a matching runner. Later occurrences of the same binding are skipped while it waits.
- Minimum interval: schedules may not repeat more often than once per minute. Faster schedules are rejected with a diagnostic.
- Daylight-saving time: a local time that does not exist (spring forward) resolves to the next existing instant, so the binding still fires exactly once on that date. A local time that occurs twice (fall back) fires once, at the first instant.
- Required inputs: a scheduled occurrence carries no caller payload. Every required
[[input]]must declare adefault, otherwise the binding is disabled with a diagnostic. Inputs with defaults are filled in automatically.
Rebuilds and schedule changes
Bindings are recomputed on every graph rebuild from the same snapshot as the actions:
- A new or changed schedule starts producing occurrences after the next successful rebuild.
- When a binding disappears or its cron/timezone changes, future occurrences stop immediately and its pending occurrences are cancelled. Executions already claimed by a runner run to completion.
- A binding whose rendered schedule is invalid (bad cron, unknown timezone, too frequent, missing input defaults) does not fail the graph build. It stays visible with a diagnostic and produces no occurrences until the data is fixed.
Static validation (rescile-ce validate) additionally rejects:
- a
schedulethat is neither a string nor a table withcron(+ optionaltimezone), - unknown keys inside the schedule table,
schedulecombined withauto_execute = true(conflicting trigger modes), andscheduleonrun_mode = "service"actions (services useauto_execute).
Observing Schedules
GET /api/scheduleslists every binding with its module, action, rendered cron, timezone, origin resource, next due time (UTC), diagnostic, and last skip reason.GET /api/action-queueentries created by the scheduler carry ascheduleobject (binding_id,scheduled_at,cron,timezone) and atrigger_condition: {"Scheduled": "<instant>"}so scheduled executions are distinguishable from manual ones.- The
/actionsUI page shows a schedule badge on each action card plus the next run time, binding diagnostics, and recent skip reasons.
What Scheduling Does Not Do (Yet)
- No catch-up or replay of missed occurrences (skip is the only policy today).
- No
enabledflag on schedules — remove theschedule(or the graph property driving it) to disable a binding. - No scheduling of
run_mode = "service"actions. - Occurrence history is currently surfaced via the queue and the schedules API; there is no dedicated long-term schedule audit log.