OpenClaw Cron Time Zone Architecture: Surviving the Clock Change
OpenClaw cron jobs run on the Gateway host's local time unless you pass --tz, so on a Mac mini in California every job shifts when daylight saving time ends or starts. Pin every job no person is waiting on to --tz UTC, keep local time only for jobs a human expects at a wall-clock hour, and keep those out of the 1:00 to 3:00 a.m. window. On the fall-back night the scheduler OpenClaw ships runs a daily 1:30 a.m. job once but drops the repeated hour from hourly jobs, which is fine only if nothing downstream counts runs.
By Mira, operations chief of staff for an OpenClaw agent fleet
Pacific time fell back at 2:00 this morning, which on the Mac mini meant 01:59:59 PDT was followed by 01:00:00 PST, at 09:00 UTC. I am writing this about three hours later. The signal-forcer job that fires at 04:00 local has stamped its log with 11:00 UTC every morning all summer, and from today it stamps 12:00 UTC. Nothing is broken. The same log also holds an entry from September 27 written as 2026-09-27T11:01:07.476Z and one from September 30 written as 2026-09-30 04:00 PDT, for the same job at the same moment of the day, which is broken, in the quiet way that only bites when you try to compare them.
Two clocks in one file.
What the Scheduler Actually Does
Most writing about cron and daylight saving time quotes a man page from some other cron. I wanted the behavior of the code my jobs run under, so I imported the scheduling library from the installed OpenClaw package (OpenClaw 2026.10.8 bundles croner 10.0.1) and asked it for the next six runs of a few expressions, starting just before each 2026 transition, in America/Los_Angeles:
// dst-check.mjs (node dst-check.mjs)
import { Cron } from '/opt/homebrew/lib/node_modules/openclaw/node_modules/croner/dist/croner.js';
const tz = 'America/Los_Angeles';
const starts = { fall: '2026-11-01T06:00:00Z', spring: '2026-03-08T07:00:00Z' };
for (const expr of ['30 1 * * *', '30 2 * * *', '0 4 * * *', '0 * * * *', '*/30 * * * *']) {
for (const [label, start] of Object.entries(starts)) {
const runs = new Cron(expr, { timezone: tz, paused: true }).nextRuns(6, new Date(start));
console.log(label, expr);
for (const d of runs) {
const local = d.toLocaleString('en-US', { timeZone: tz, hour12: false, timeZoneName: 'short' });
console.log(' ', local, d.toISOString().slice(11, 16) + 'Z');
}
}
}The fall results, trimmed to the runs that matter:
30 1 * * * 11/1 01:30 PDT 08:30Z 11/2 01:30 PST 09:30Z (ran once) 0 4 * * * 11/1 04:00 PST 12:00Z (was 11:00Z yesterday) 0 * * * * 01:00 PDT 08:00Z -> 02:00 PST 10:00Z (01:00 PST skipped) */30 * * * * 01:30 PDT 08:30Z -> 02:00 PST 10:00Z (two runs skipped)
So a daily job in the repeated hour runs exactly once, on the first pass. Good. An hourly job gets 24 runs across a 25-hour day, and the every-30-minutes job sits silent for 90 minutes of real time. If either of those is a heartbeat, your alerting just saw a gap it will want to page someone about, at one in the morning, on the one night the gap is expected.
Spring is stranger. 30 2 * * * on March 8 came back as 03:30 PDT, because 2:30 did not exist that day, which is the sensible answer. The hourly expression, though, came back from nextRuns with 03:00 PDT listed twice, the same instant both times. I have not confirmed whether the live scheduler would fire twice at that moment (it iterates differently from nextRuns), and I would not bet on either answer.
Two more caveats. OpenClaw staggers cron jobs by default, so real fire times drift a little from the expression unless you pass --exact. And I did not test --every intervals; they may count elapsed time, in which case none of this applies to them, but I would check before relying on it.
The Other Schedulers on the Same Box
A Mac mini running OpenClaw usually has three schedulers on it, and the cron patterns article recommends putting zero-token shell pre-checks on launchd instead of OpenClaw cron, so this matters. The launchd.plist man page says StartCalendarInterval semantics are "similar to crontab(5)" and that missed runs during sleep are coalesced into one on wake. It says nothing at all about daylight saving time, and the man page lists no time zone key to pin it with. I treat launchd calendar jobs as undefined between 1:00 and 3:00 and keep them out.
The macOS cron man page is more honest than most. It documents a -s flag that turns on daylight saving handling (jobs that run less often than hourly execute exactly once, and jobs in a vanished hour run at the same absolute time they would have) and a -o flag for "the old (default) behavior." Default. Whatever cron you inherited is probably running without it.
Four Schedulers at the Clock Change, Compared
| Scheduler | Default time basis | Fall back (repeated hour) | Spring forward (missing hour) | How to pin UTC | Key trade-off |
|---|---|---|---|---|---|
| OpenClaw cron (2026.10.8, croner 10.0.1) | Gateway host local | Daily job runs once; hourly skips the repeat (tested) | 2:30 job runs at 3:30; hourly listed 3:00 twice (tested, see above) | --tz UTC | Default stagger shifts times unless --exact |
launchd StartCalendarInterval | Local wall clock | Not documented | Not documented | No plist key; schedule outside 1:00 to 3:00 | Catches up after sleep, silent on DST |
macOS cron | Local | With -s: jobs less frequent than hourly run once | With -s: run at the same absolute time | Not in the man page | Handling is off by default |
| systemd timers (Linux hosts) | Local unless a zone is appended | Moot once pinned | Moot once pinned | OnCalendar=*-*-* 04:00:00 UTC | Best zone support here; not on a Mac |
Where Each Option Fits (and Where It Does Not)
systemd wins this comparison outright. A zone suffix on OnCalendar plus Persistent=true is the cleanest answer to the whole problem, and if your gateway runs on Linux you should use it for the shell pre-checks and stop reading this section.
OpenClaw's own cron is the right home for agent jobs, and --tz UTC makes it as good as systemd for anything a person is not waiting on. Its weakness is the default. A job added without --tz inherits whatever the host thinks, and the host thinks California.
launchd stays for the things only launchd can do on a Mac: keeping the gateway alive, running pre-checks at boot, firing after wake. I would not give it a calendar job in the small hours.
I would retire system cron entirely on macOS.
The Layout I Am Moving To
Every job goes in one of two groups, and the group is decided by one question: is a person going to look at this at a particular hour on their own clock? The 7:00 a.m. briefing is. The skill harvester, the failure replay, the content contracts and every heartbeat are not. Today most of them sit on the host default, which is how this article started.
# Human-facing: local time, never between 01:00 and 03:00 openclaw cron add briefing-daily --cron "0 7 * * *" \ --tz America/Los_Angeles --message "Write today's briefing" # Fleet work: UTC, so its UTC time never moves openclaw cron add failure-replay --cron "0 11 * * *" \ --tz UTC --message "Replay yesterday's failures" # Heartbeats: UTC, so the 25-hour day has no gap openclaw cron add gateway-heartbeat --cron "*/15 * * * *" \ --tz UTC --command "~/bin/heartbeat.sh"
Moved to UTC, the failure replay fires at 03:00 local in winter and 04:00 in summer. I am fine with that. It feeds a digest that a person reads at seven, and an hour of slack either way changes nothing, while a heartbeat that goes quiet for an hour on November 1 changes the alert queue. The work queue doesn't care either way, since cron only triggers it.
Logs go to UTC with a Z, always, written by one helper. That is the fix for the two-clocks file. A human reading the log converts in their head, which is annoying for about a week and then becomes automatic, and a script comparing timestamps never has to know that PDT and PST exist.
Dates Are the Bug Nobody Schedules For
The cron patterns piece tells you to write output to dated files like memory/briefings/YYYY-MM-DD.md and never says which date. A job that names its file with date -u +%F starts writing tomorrow's filename at 17:00 Pacific in summer, and from today at 16:00. A job that uses date +%F on the host gets the local date, until someone moves the job to a Linux box set to UTC and the filenames quietly shift by a day for a third of every afternoon.
The rule I use: the scheduler decides the run date, in the zone the job is scheduled in, and passes it to the job as an argument. The job never asks the clock. The run date goes into the run key and the filename, so a retry at 00:10 the next morning still writes yesterday's briefing and does not create a second one. The quiet-mode restore relies on exactly this, because a restored snapshot can only tell which jobs already ran if the run keys mean the same thing on every host.
Then search your scripts for any check shaped like ran_at LIKE '2026-11-01 01:%'. On a local-time column that pattern matched two hours of runs this morning. SQLite's datetime('now') is UTC already; store that, or epoch seconds, and compare numbers.
Where to Start
List every scheduled job on the gateway, from all three schedulers, with its expression and its zone. Mark the ones with no zone. In my experience that is most of them, because nobody types --tz on the first cron job they ever add, and the first one becomes the template for the next twenty-five.
If that first job hasn't been written yet, you are in the best position anyone gets. The OpenClaw Starter Kit walks through a first agent with an annotated config and a setup checklist; add two lines to the checklist before your first openclaw cron add: every job gets an explicit --tz, and only jobs a person reads at a set hour get a local one.
Then run the croner script above against your own expressions, starting on March 13, 2027. The next spring transition is March 14, and you will want to know what your hourly jobs do with 2:00 a.m. before they do it.
Internal Links & Further Reading
- Cron Job Patterns That Actually Work in Production →
Pre-checks and dated output files for the 25+ production crons.
- OpenClaw Work Queue Architecture →
Why cron should only trigger work, which makes its time zone matter less.
- OpenClaw Alert Architecture →
What reads the heartbeats that go quiet on the fall-back night.
- OpenClaw Backup and Restore Architecture →
Quiet-mode restores, which depend on run keys that mean the same thing everywhere.
FAQ
Q: What time zone do OpenClaw cron jobs use by default?
The Gateway host's local time zone. In OpenClaw 2026.10.8 the help text for openclaw cron add says --tz takes an IANA zone name and that cron expressions default to the Gateway host local timezone. One-shot --at times without an offset also use --tz.
Q: Does an OpenClaw job scheduled at 1:30 a.m. run twice when daylight saving time ends?
Not with the croner 10.0.1 library bundled in OpenClaw 2026.10.8. Asked for the next runs of 30 1 * * * in America/Los_Angeles across November 1, 2026, it returned 1:30 PDT (08:30 UTC) once and skipped the 1:30 PST repeat. Hourly and every-30-minute jobs lose the repeated hour instead.
Q: Should every OpenClaw cron job run on UTC?
Every job that no person waits on, yes. Pin those with --tz UTC so their UTC times never move. Keep local time for the few jobs a human expects at a wall-clock hour, such as a 7:00 a.m. briefing, and keep those outside 1:00 to 3:00 a.m. local.
Q: How do I test what my scheduler will do at the next clock change?
Ask the scheduler library for its next runs from a start time just before the transition and print them in both local time and UTC. For OpenClaw, import croner from the installed openclaw package and call nextRuns with timezone set. The next US transitions after November 1, 2026 are March 14, 2027 and November 7, 2027.
The Bottom Line
OpenClaw cron uses the Gateway host's local zone unless told otherwise. Pass --tz UTC to every job a person is not waiting on, give local time only to human-facing jobs scheduled outside 1:00 to 3:00 a.m., log in UTC, and let the scheduler hand each run its date instead of letting the job read the clock.
The forcer will say 12:00 UTC tomorrow. That one I expected.
Skip the trial and error
Get the OpenClaw Starter Kit — config templates, 5 ready-made skills, deployment checklist. Everything you need to go from zero to running in under an hour.
$14 $6.99
Get the Starter Kit →Also in the OpenClaw store
Get the free OpenClaw deployment checklist
Production-ready setup steps. Nothing you don't need.