the crontab nobody READS
Every system keeps "when" somewhere, and that somewhere is almost always unreadable. A crontab on a box no one logs into. A scheduler dashboard behind a console. A block of CI yaml with a five-field incantation at the top. The plan, meanwhile, says "every morning" in prose — in a doc, a ticket, a README — and the machine's copy of that same fact lives in a different universe entirely.
So they drift. The prose says daily; the cron says weekly; the person who knew which was right left in March. The cost isn't the syntax — five star-fields are learnable. The cost is the distance: the schedule and the thing it schedules are two artifacts, in two formats, reviewed by two different nobody.
Org closes the distance by refusing to move the schedule out of the plan. The "when" sits one line under the "what" — on the same headline, in the same parseable tree as the code that runs. The timestamp isn't metadata about the plan. It's a fact in the plan.
the DEFINITION
1. a date a machine can act on, written inline
on the headline it belongs to. <…> is an
appointment — something to fire; […] is a
note — something recorded. On a planning line it parses into
{at, repeat, active} and rides into every plan and run
record the kernel emits.
It's a construct of the grammar — the same tree that holds headlines, tags, TODO states, and drawers. The parser for it is small enough to read in a sitting, and most of this lesson is walking those eighteen lines and the data path they open. The sibling deep-dive schedules owns the engine that fires these; this page owns the grammar and the data, and hands off the clock explicitly.
the token CLASSIFIER
The whole timestamp parser is about eighteen lines of Rust in the kernel crate. It does two things. First, it reads the very first character to decide whether the timestamp is active. Then it splits the inside on whitespace and classifies each token in one pass by its shape — first match wins, per token. Here is that classifier walking left to right over the canonical line:
SCHEDULED: <2026-06-06 Sat 06:00 +1d>
│ └──────────┘ └─┘ └───┘ └──┘
│ date day time repeat
└─ the bracket → the active bit
flowchart TD
start["a token from inside the brackets"]
d{"length 10
and a dash at index 4?"}
t{"contains a colon?"}
r{"starts with + / ++ / .+ ?"}
date["it's the DATE
2026-06-06"]
time["it's the TIME
06:00"]
rep["it's the REPEAT
+1d"]
drop["ignored — tolerated, never validated
(the day name Sat lands here)"]
start --> d
d -- yes, and no date yet --> date
d -- no --> t
t -- yes --> time
t -- no --> r
r -- yes --> rep
r -- no --> drop
style date fill:#f2ddb0,stroke:#121316,stroke-width:2.5px
style time fill:#aee5c2,stroke:#121316
style rep fill:#9fc4e8,stroke:#121316
style drop fill:#d9dbd3,stroke:#121316
style start fill:#ffffff,stroke:#121316
Read the diagram as a single funnel each token falls through. A
date is the first token of exactly ten characters with a dash at
byte four — that's how 2026-06-06 is recognized, and it's also
why 2026-6-6 is silently rejected: nine characters, no match,
and because a date is required, the whole timestamp evaporates. A
time is any token containing a colon. A repeater is any token
starting with +, ++, or .+. And
everything else is ignored — the day name Sat falls
straight to the floor, tolerated and discarded, never once checked against
the date. A Sat written on a Wednesday parses without
complaint.
What comes out is exactly this, and nothing more — date and time joined
with a T, the repeater verbatim, the active bit:
{ "at": "2026-06-06T06:00", "repeat": "+1d", "active": true }
That is the entire contract. The kernel does not interpret the repeater, does not resolve the day name, does not know what "now" is. It reads shape, emits structure, and stops.
one bracket, one BIT
Active versus inactive is the first character and nothing else. Angle brackets mean active; square brackets mean inactive; the shapes inside are identical. Mismatched brackets — an open angle closed with a square — match no arm and parse to nothing at all.
| written | active | org's meaning | what a consumer should do |
|---|---|---|---|
<2026-08-01 Sat 10:00> | true | an appointment | treat as armed — eligible to fire |
[2026-08-01 Sat 10:00] | false | a recorded note | surface it, but don't fire on it |
<2026-08-01 Sat 10:00] | — | nothing — no match | no timestamp parses at all |
Here's the part worth internalizing: an inactive
SCHEDULED: still flows all the way through. Write
SCHEDULED: [2026-08-01 Sat 10:00] on a planning line and the
plan carries "active": false — recorded, not armed.
schedule_of does not filter on the active bit; it surfaces the
fact and leaves the decision to whoever consumes it. The kernel records;
the consumer decides. That separation is deliberate — the kernel's job is
to never lose information, not to enforce policy.
trigger vs FACT
Two planning keywords carry timestamps, and they are not symmetric. Only
SCHEDULED feeds the workflow schedule. DEADLINE is
parsed, surfaced, and carried — but it never becomes a trigger. A workflow
with only a DEADLINE resolves to "schedule": null
in its plan, verified. A deadline is a fact you do arithmetic on — overdue
is now > at — not a clock that wakes anything up.
shows in wb query? | in the tangle plan's schedule? | fires anything? | |
|---|---|---|---|
SCHEDULED | yes — as scheduled | yes — feeds schedule_of | it's the trigger |
DEADLINE | yes — as deadline | no — always null from a deadline | never — it's a fact, not a clock |
The verdict of that table in one line: both keywords are read and
surfaced, but only SCHEDULED can wake work. This is also the
single most common misconception about org time — people write a
DEADLINE and wonder why nothing ran. Nothing ran because a
deadline was never a trigger. It's a date the validator can measure against,
the same way a passed deadline on a TODO becomes an
overdue flag, not a notification.
One more asymmetry worth stating: a timestamp written in body prose —
a bare [2026-06-12 Fri 11:30] sitting in a paragraph — is
not picked up as a schedule. Only the planning line feeds
scheduled and deadline;
headline_of reads them off hl.planning() and
nowhere else. A date in prose is documentation; a date on the planning
line is structure.
the three REPEATERS
depth rung · skippable — the repeater conventions, for the curious
A repeater is a token like +1d that says "and again, on an
interval." Org has three prefixes, and they mean three different things.
The crucial fact about the kernel is that it carries all three
verbatim as opaque strings — it preserves the distinction without
ever computing the next fire. The meaning rides in the string; the
scheduler reads it later.
| prefix | org convention | if you miss it by three days… |
|---|---|---|
+1d | shift from the old date by the interval | next fire is tomorrow, then the day after — the schedule keeps its original cadence regardless of when you acted |
++1d | shift until in the future by the interval | it jumps forward repeatedly past today, landing on the next still-future slot — no pile-up of missed fires |
.+1d | shift from the completion date | the clock restarts from when you actually finished — three days late means the next one is three days later too |
The verdict of that table: + is fixed cadence,
++ is catch-up-to-now, and .+ is
measured-from-done. Each is a different answer to "what counts as the
anchor." The kernel doesn't pick — it captures the prefix exactly so the
scheduler can. If two repeaters somehow appear in one timestamp, the last
one wins by plain overwrite; there's no error, just the final value.
the cron escape HATCH
depth rung · skippable — when a timestamp isn't enough
Some schedules aren't calendar-shaped. "Every weekday at 6" or "the 1st
and 15th" want cron's five fields, not a single appointment with a
repeater. For those, drop a :SCHEDULE: key into a
properties drawer and write plain cron. Drawer keys
are uppercased on parse, so :schedule: in lowercase works
exactly the same.
When both are present, the drawer wins. schedule_of checks
the SCHEDULE property first; only if it's absent does
it fall back to the SCHEDULED timestamp. Here is that dual
clock probed against the real surfaces:
* Cron wins :workflow: SCHEDULED: <2026-07-01 Wed 09:00 ++1w> :PROPERTIES: :SCHEDULE: 0 6 * * * :END:
wb query still reports the timestamp in full —
"scheduled": {"at": "2026-07-01T09:00", "repeat": "++1w",
"active": true}, with the property visible as
{"SCHEDULE": "0 6 * * *"}. But wb tangle
resolves the world's schedule to
{"cron": "0 6 * * *"} — the drawer overrode the timestamp,
and the timestamp is still on record. One snippet teaches two things at
once: who wins, and that query shows what was written while
tangle shows what was resolved.
The rule of thumb is simple. Calendar-shaped time — "every morning,"
"this Friday," an appointment a human would write on a wall — wants a
timestamp. Machine-shaped time — odd cadences, multiple fixed minutes,
anything a human would reach for cron to express — wants
:SCHEDULE:.
through the PLAN
Follow one line from text to run record. The journey is short and every hop is a named function in the kernel, so there's no magic to take on faith:
flowchart LR org["SCHEDULED: <…06:00 +1d>
on a :workflow: headline"] ho["headline_of
reads the planning line"] so["schedule_of
:SCHEDULE: prop, else SCHEDULED"] bw["build_world
schedule on EVERY world"] run["Workflow.run
schedule in EVERY run record"] who["the keeper · external cron · POST /api/workflow
— something with a clock wakes it"] org --> ho --> so --> bw --> run --> who style org fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style ho fill:#ffffff,stroke:#121316 style so fill:#aee5c2,stroke:#121316 style bw fill:#9fc4e8,stroke:#121316 style run fill:#f3c5a3,stroke:#121316 style who fill:#d9dbd3,stroke:#121316
Read it as a relay. The planning line is read by
headline_of. schedule_of resolves it — drawer
first, then the timestamp — into the schedule field.
build_world attaches that field to every world it
builds, and it builds them recursively — which is the quietly important
part. Because each world carries its own schedule, a monthly
parent workflow can hold an hourly child, each on its own clock. The
nesting lesson states this in a sentence; here is
the mechanism: per-level schedules, because build_world
recurses and schedule_of runs at every level.
Finally, Workflow.run puts the schedule into every run
record and every summary, recursively through sub-workflows. So the answer
to "what does this run think its schedule is?" is always in the record,
never reconstructed. And you can read the whole plan without running
it: POST /api/workflow with ?plan=1 returns
the schedule and plan and runs nothing.
checking what it EXTRACTED
depth rung · skippable — the three ways to see the parse
You never have to guess what the machine read off your line. Three
surfaces show it. wb query <file> shows the raw parse —
the scheduled and deadline objects per headline.
wb tangle <file> shows the resolved plan — the
schedule on each world, after the drawer-vs-timestamp
precedence runs. And POST /api/workflow?plan=1 returns that
same plan over HTTP without executing anything.
The canonical example is nightly-digest.org — a
:workflow: headline scheduled
<2026-06-06 Sat 06:00 +1d> over a rust → js → go pipeline.
Its two surfaces line up exactly:
wb query → "scheduled": { "active": true, "at": "2026-06-06T06:00", "repeat": "+1d" }
wb tangle → "worlds"[0]["schedule"]: { "active": true, "at": "2026-06-06T06:00", "repeat": "+1d" }
And the DEADLINE-only case, the one that catches everyone, settled in eight lines of real output:
* Deadline only :workflow:
DEADLINE: <2026-07-04 Sat>
wb query → "deadline": { "at": "2026-07-04", "repeat": null, "active": true }
wb tangle → "schedule": null
The deadline parsed perfectly — it's right there in the query — and the
plan's schedule is still null, because a deadline was never a
trigger. If you expected a fire and got silence, this is the check that
explains it in one read.
the kernel has no CLOCK
Honesty section, because the grammar makes a promise the kernel deliberately does not keep. The kernel is pure in, pure out — no host calls, no clock. It cannot know what "now" is. It can carry time as data; it cannot feel time pass. Every fact above is about reading and surfacing a date, never about acting on one.
So what actually wakes a workflow? Today, something outside the kernel
with a clock of its own. The only callers of Workflow.run in
the host are the HTTP endpoint and the demos — there is no daemon walking
org timestamps on a timer. The runtime's native clock is the
keeper, but it ticks agent definitions on
an interval set by WB_KEEPER_INTERVAL_MS — not org
timestamps. So the live path is: the timestamp is parsed, surfaced in the
plan and the run record, and then an external trigger —
cron, CI, or a machine exec hitting
POST /api/workflow — or the keeper-by-interval does the
waking. The parent workflows lesson says
"the engine fires these"; this deep dive is more precise than its parent,
on purpose: the engine can fire these, and the firing engine is
the sibling lesson schedules, not this one.
A few smaller honest edges, all by construction:
- Repeater math is the consumer's job.
+1drides through as the string"+1d". The kernel never computes the next fire; it preserves the distinction so the scheduler can. - Time ranges survive as non-ISO strings. A token like
06:00-07:30contains a colon, so it's kept whole as the time — yielding"at": "2026-06-20T06:00-07:30", which is not valid ISO. An honest quirk: the classifier matches on shape, not on validity. - No timezones, anywhere. The grammar has no timezone field. All time is engine-local. If you need zone-correct firing, that lives in the engine that runs the plan, not in the timestamp.
- Org warning cookies are dropped. A
-2dwarning on a deadline matches no arm and is silently discarded — surfaced data only, never a behavior.
questions people actually ASK
Why didn't my DEADLINE run anything?
Because a deadline was never a trigger. schedule_of only
consults SCHEDULED (and the :SCHEDULE:
property); a DEADLINE-only workflow resolves to
"schedule": null, verified. A deadline is a date you measure
against — overdue is now > at — not a clock. Move it to
SCHEDULED if you want it to fire.
Do I need the day name, like Sat?
No. The day name is decorative. The classifier matches no arm for it,
so it's tolerated and dropped, and it's never validated against
the date — a Sat on a Wednesday parses cleanly. Write it for
human readability or omit it; the parse is identical either way.
What happens with 2026-6-6?
No timestamp at all. The date rule is exactly ten characters with a
dash at byte four; 2026-6-6 is nine characters and matches
nothing. Because a date is required, the whole timestamp silently drops.
Always zero-pad: 2026-06-06.
Which wins — :SCHEDULE: or SCHEDULED?
The :SCHEDULE: drawer property wins.
schedule_of checks the property first and only falls back to
the timestamp if it's absent. The plan resolves to
{"cron": …} while wb query still shows the
timestamp — overridden, but not erased.
Where are timezones?
Nowhere in the grammar. There's no zone field; all time is engine-local. Zone-correct firing is a property of the engine that runs the plan — the kernel only ever carries the wall-clock string you wrote.
Can I put a timestamp in body text instead of the planning line?
You can write one, but it won't schedule anything. Only the planning
line feeds scheduled and deadline; a bare
[…] in prose parses as a date but is never picked up as a
schedule. Put it on the planning line under the headline.
keep GOING
This page is the grammar and the data path; its neighbors hold the engine and the rest of the tree.