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.
Disclosure (AI-assisted): the English text of this post was drafted with an LLM from my Japanese originals and reviewed by me before publishing. Every code example and its output was executed by the reference implementation; the numbers are measured, not written.
Top comments (0)