If you arrived from the Kairos 1.0 announcement, this is the
hands-on one: take a crontab you already have and translate it.
First, the honest part: if your jobs are plain fixed-time repeats, stay on cron. "Every hour",
"3am daily" — there is nothing to gain from rewriting them. You reach for a schedule language when
you hit one of these: "end of month" won't fit; "business day" won't fit; the job started running at
the wrong hour after a server move; nobody can tell you what was skipped during an outage.
All examples below use one premise — the 2026 US federal holidays (observed dates) — and were run on
the reference implementation. The premise is declared once and reused:
premise US {
calendar-system: Gregorian
tz: "America/New_York"
wkst: Sun
}
@US
holidays2026 = [2026-01-01, 2026-01-19, 2026-02-16, 2026-05-25, 2026-06-19,
2026-07-03, 2026-09-07, 2026-10-12, 2026-11-11, 2026-11-26,
2026-12-25] covering: 2026..2026
satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
bizDay = everyDay \ (satSun | holidays2026)
1. 0 9 * * 1-5 — weekdays at 9
Two things this cron line does not say. Which 9 o'clock — it's whatever timezone the host
happens to have, which is exactly how "the batch moved an hour after we containerized" happens.
And holidays — cron has no such concept.
bizDay |> at(T09:00)
Labor Day week (Sep 4–10, 2026):
2026-09-04T09:00
2026-09-08T09:00
2026-09-09T09:00
2026-09-10T09:00
Monday 9/7 (Labor Day) and the weekend are skipped. "New York, 9:00" is written in the definition,
so it survives any host move.
2. 55 23 28-31 * * + a script — month-end at 23:55
cron has no "last day", so the folk remedy is to wake up on the 28th–31st and let a script check
"is tomorrow the 1st?". The check disappears into the expression:
monthEnd |> at(T23:55)
#=> 2026-09-30T23:55 2026-10-31T23:55 2026-11-30T23:55 2026-12-31T23:55
3. Payday: the 25th, previous business day if it falls on a weekend or holiday
This is where migration starts to pay off — cron cannot express the second half at all:
everyDay |> within(month) |> nth(25) |> roll(Preceding, on: bizDay)
#=> 2026-09-25 2026-10-23 2026-11-25 2026-12-24
October 25 is a Sunday, so it rolls back to Friday the 23rd. December 25 is Christmas Day, so it
rolls back to the 24th. roll is a conditional move — it only acts when the point isn't on the
axis — which is what distinguishes it from counting (shift).
4. Last business day of the month
bizDay |> within(month) |> last
#=> 2026-09-30 2026-10-30 2026-11-30 2026-12-31
October 31 is a Saturday, so the month closes on Friday the 30th. "Build the stream of business
days, take the last point of each month" — the structure reads exactly as stated.
5. Three business days before month-end
The expression that started the whole project. Holidays are simply not on the business-day ruler,
so counting skips them:
monthEnd |> roll(Preceding, on: bizDay) |> shift(-3, unit: bizDay)
#=> 2026-08-26 2026-09-25 2026-10-27 2026-11-24
November: the 30th is a Monday; three business days back skips Thanksgiving (11/26) and lands on
Tuesday the 24th.
The three questions cron never answers
Save the definition to a file and ask the CLI.
What's next?
$ kairos next -n 3 --from 2026-09-14 payday.kairos
2026-09-25
2026-10-23
2026-11-25
What did I miss? Say the box was down Oct 20–31:
$ kairos list --from 2026-10-20 --to 2026-11-01 payday.kairos
2026-10-23
The payroll run on the 23rd was skipped — and you learn it as a list, not by re-deriving the
schedule in your head. (--from/--to is a half-open interval; the --to day is excluded.)
So how do I actually run it? Keep one crontab line. Kairos stops at when things should
happen; firing, retrying, and logging stay with your runner (systemd, a job queue, whatever you
already trust). That division of labor is written into the spec, not left to convention — which is
no help by itself, so here are the two wiring patterns. Both work with the CLI as it ships
(--json emits the same points, machine-readable, with epoch milliseconds). One thing to know first:
labels, the [--from, --to) window and the default "today" are read in your machine's time zone
(override with --tz). The outputs in this post were taken with the machine in America/New_York;
elsewhere, add --tz America/New_York and you get the same labels. If the machine's zone and the
definition's premise zone differ, day-granular points print with a time of day — midnight in New
York shows up as T13:00 in Tokyo — and the daily window may not line up with the definition's day.
Pattern 1: cron stays the clock, Kairos makes the decision. Keep a single crontab line and move
the calendar logic out of it. Every morning at 9, ask "is there a point today?" and run the job if so:
0 9 * * * cd /srv/batch && ./run-if-today.sh payday.kairos ./payday.sh
#!/bin/sh
# run-if-today.sh <definition.kairos> <job> — exec the job if there is a point in [today, tomorrow)
today=$(date +%F); tomorrow=$(date -d "$today + 1 day" +%F) # GNU date; macOS: date -v+1d +%F
n=$(kairos list --from "$today" --to "$tomorrow" --json "$1" | jq '.results[0].dates | length')
[ "$n" -gt 0 ] && exec "$2"
Holidays and the time zone live in the definition, so the crontab line carries no weekday and no
day-of-month. The window is [today, tomorrow) in the machine's zone; if the definition's premise
zone is a different one, pass --tz with that zone so the window is the definition's day. Count with --json: the human-readable output also prints the coverage summary as
# lines, so a naive | grep -q . would fire every day. With the payday.kairos above this
yields 0 for 2026-09-14 and 1 for 2026-09-25.
Pattern 2: schedule the next point, one at a time. Put the time of day into the definition as
well (… |> roll(Preceding, on: bizDay) |> at(T09:00)) and ask for the next point. next --json
returns it as wall-clock text and as epoch milliseconds; hand it to a one-shot OS timer, and let the
job re-register the next point as its last step:
t=$(kairos next --json payday.kairos | jq -r '.results[0].dates[0]') # e.g. 2026-09-25T09:00 — printed in the machine's local time zone, which is what the timer expects
systemd-run --user --on-calendar="$(echo "$t" | tr T ' '):00" ./payday.sh
# same shape with at(1) on Linux, or schtasks /sc once on Windows
"Re-materialize the point list periodically" is exactly the operating model the spec describes; the
full 20–40-line version of this gets its own post.
And the data
The examples inline eleven holidays. In production you feed calendar data through an external
binding — the expression stays static, the data arrives at runtime — and every table carries a
covering: claim ("verified through this date"). Past that date, results still come out, but with
a machine-readable annotation saying the calendar ran out. Nothing degrades silently.
Try it
Everything runs in the browser; nothing leaves the page. Install the CLI with
npm i -g kairos-lang, then kairos next -n 3 payday.kairos.
Kairos is at 1.0 — the language is frozen; the reference implementation is a prototype
(TypeScript, zero runtime dependencies). Docs are canonical in Japanese with a
full English mirror. Repo:
https://github.com/azathothx/kairos-lang. Apache-2.0.
Top comments (0)