Date Actions
The date action family does timezone- and locale-aware date work inside an automation: render an instant, read one back out of a string, shift it, measure between two, and snap to a calendar boundary. Eight operators, no more — the omissions are deliberate and are listed at the end of this page.
- name: dueLabel
type: date
operator: format
props:
input: '{{trigger.data.dueAt}}'
pattern: "EEEE d MMMM yyyy 'at' HH:mm"
timezone: Europe/Paris
locale: fr-FRThe Operators
| Operator | Required props | Optional props | Output |
|---|---|---|---|
format |
input, pattern |
timezone, locale |
{ formatted } |
parse |
input, pattern |
timezone |
{ instant, valid } |
add |
input + ≥1 duration part |
timezone |
{ instant } |
subtract |
input + ≥1 duration part |
timezone |
{ instant } |
diff |
from, to, unit |
timezone |
{ value } |
startOf |
input, unit |
timezone |
{ instant } |
endOf |
input, unit |
timezone |
{ instant } |
now |
(none) | pattern, timezone, locale |
{ instant }, plus formatted with a pattern |
instant is always an ISO 8601 string, never a Date: a step output is JSON-persisted to run history, re-read by templates and returned in webhook bodies, and a Date survives none of those hops with its type intact.
timezone is an IANA identifier (Europe/Paris), defaulting to UTC. An instant carries no zone of its own — timezone is what the instant is read in. A fixed offset (+02:00) is accepted but cannot express DST, so prefer a named zone anywhere that observes it. locale is a BCP 47 tag (fr-FR, default en-US) and affects only the month and weekday name tokens; a purely numeric pattern ignores it.
The Pattern Token Set Is Closed
pattern is written in Unicode LDML tokens, and the vocabulary is closed:
| Token | Meaning | Parseable |
|---|---|---|
yyyy |
Calendar year, 4 digits (2026) | yes |
MM |
Month, 2 digits (01–12) | yes |
dd |
Day of month, 2 digits (01–31) | yes |
HH |
Hour, 2 digits, 24-hour (00–23) | yes |
mm |
Minute, 2 digits (00–59) | yes |
ss |
Second, 2 digits (00–59) | yes |
MMMM |
Month name, full, localised (March) | no |
MMM |
Month name, short, localised (Mar) | no |
EEEE |
Weekday name, full, localised (Saturday) | no |
EEE |
Weekday name, short, localised (Sat) | no |
YYYY |
Legacy alias of yyyy |
yes |
DD |
Legacy alias of dd |
yes |
An unrecognised token is an error, not a pass-through. Every ASCII letter outside a quoted literal must belong to a token in the table above — LDML's own rule, where letters are reserved. Quote literal letters: "yyyy-MM-dd'T'HH:mm:ss". Non-letters (- / : space) are literals and pass through untouched. Closing the set is what keeps this surface finite and testable: the moment a full forty-token vocabulary is implied, all forty are owed.
The four name tokens are marked not parseable. parse therefore takes no locale, and a pattern containing MMMM or EEEE is rejected on the parse side — mars is ambiguous across locales and abbreviation styles, and accepting it would be guesswork rather than parsing.
parse Reports Validity as Data
- name: readDueDate
type: date
operator: parse
props:
input: '{{trigger.data.dueDate}}'
pattern: dd/MM/yyyy
timezone: Europe/ParisOutput is { instant, valid }. A string that does not match the pattern succeeds the step with valid: false and a null instant, rather than failing it. That is deliberate, and two behaviours depend on it:
- Retry. A failed step is retried per its
retryconfig. Retrying a deterministic verdict burns the budget and delays the run for a result that cannot change. - Control flow. A failed step stops the branch unless
continueOnErroris set, so a downstreamfilterorpathmeant to route the invalid rows would never run. Returning data keeps the decision where an author can act on it.
A malformed pattern is the opposite case — an author error, not data — and does fail the step.
- name: routeInvalid
type: filter
operator: continue
props:
condition:
conditions:
- field: '{{readDueDate.valid}}'
operator: equals
value: true
onFalse: skipArithmetic: Calendar Units vs Elapsed Time
add and subtract take at least one duration component. The counts are plural (days: 7) while unit names elsewhere are singular — quantities versus names, the same split Temporal and java.time draw. Declaring none is a configuration error.
| Component | Resolved as |
|---|---|
years |
Calendar — DST-aware, wall clock preserved |
months |
Calendar — 31 January + 1 month is 28 February |
weeks |
Calendar — DST-aware, wall clock preserved |
days |
Calendar — a day across a DST change is 23 or 25 real hours |
hours |
Elapsed — an hour is always 60 minutes |
minutes |
Elapsed — unaffected by DST |
seconds |
Elapsed — unaffected by DST |
Day-and-larger is calendar, hour-and-below is fixed. Adding { days: 1 } to noon in Europe/Paris across spring-forward lands on noon the next day — 23 real hours later. Adding { hours: 24 } lands on 13:00 — 24 real hours later. Both are correct answers to different questions, and the split is what keeps add: { hours: 3 } and a subsequent diff in hour agreeing. diff draws the same line: hour and below are fixed-length and ignore the zone entirely.
Larger units apply before smaller ones, so month-end clamping happens first: 31 January + { months: 1, hours: 6 } clamps to 28 February and then adds six hours.
- name: reminderAt
type: date
operator: subtract
props:
input: '{{trigger.data.dueAt}}'
days: 2
hours: 3
timezone: Europe/Parisdiff — Signed and Truncated Toward Zero
- name: daysLate
type: date
operator: diff
props:
from: '{{trigger.data.dueAt}}'
to: '{{now.instant}}'
unit: day
timezone: Europe/Parisunit is singular: year, month, week, day, hour, minute, second or millisecond. The result is signed — negative when to precedes from — and truncated toward zero, so a 47-hour gap is one day, not two. Calendar units are counted against the zone, elapsed ones are not.
Because diff already answers with a sign, there are no isBefore / isAfter / isBetween operators; compare the result in a filter or path condition instead.
startOf / endOf — Calendar Boundaries
- name: monthStart
type: date
operator: startOf
props:
input: '{{trigger.data.occurredAt}}'
unit: month
timezone: Europe/Parisunit is one of year, month, week, day, hour, minute or second — the same singular vocabulary as diff, minus millisecond, which no boundary snaps to. The boundary is computed in timezone, so the start of a day in Europe/Paris is 22:00 or 23:00 UTC the evening before, depending on the season.
now
- name: stamp
type: date
operator: now
props:
pattern: yyyy-MM-dd
timezone: Europe/ParisReturns { instant } — a UTC instant — and adds formatted when a pattern is supplied. It is the one operator with no required props.
What Is Deliberately Absent
The eight operators were chosen against a capability domain rather than against a date library's API surface, so several familiar names are missing on purpose:
isBefore/isAfter/isBetween— belong in afilterorpathcondition;diffalready returns a signed answer.toTimezone— an instant carries no zone, so the name teaches a wrong model.format'stimezonecovers it.dayOfWeek/isWeekday/isWeekend—formatwithEEEE, plus a filter. Three operators for one token is the per-library sprawl this family exists to avoid.timestamp/fromTimestamp— epoch tokens are a token-set question, not an operator question.- Business-day and holiday arithmetic — needs a calendar the platform does not have, and a wrong answer here is worse than no answer.
Calling Date Operators From Code
Every operator is reachable from a code action as context.actions.date.<operator>(props). That path does not decode props against the schema, so the handler re-applies the guards itself — an invalid timezone or an unrecognised token fails there exactly as it would at boot.
Related Pages
- Actions Overview — the action model and every family.
- Data & State —
filter/continue, where aparseverdict is routed. - Flow Control —
path/branchfor date-driven routing. - Triggers — the
crontrigger, which shares this timezone vocabulary. - Code Actions — the
context.actions.datecall path.
Last updated August 28, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.