Summary
Reminder cron schedules are always evaluated in UTC, and the common CRON_TZ= prefix (Vixie crontab syntax) fails to parse. Timezone-aware cron scheduling is needed so reminders like "every day at 09:00 local time" survive DST transitions without manual UTC re-conversion.
Reproduction
On current dev (Cronos 0.13.0, pinned in Directory.Packages.props), via dotnet-script against the exact pinned assembly:
CronExpression.Parse("CRON_TZ=Europe/Brussels 0 9 * * *", CronFormat.Standard)
→ CronFormatException: The given cron expression has an invalid format.
Minutes: Value must be a number between 0 and 59 (all inclusive).
CronExpression.Parse("0 9 * * * Europe/Brussels", CronFormat.Standard)
→ CronFormatException: Unexpected character 'E'.
So both the prefix form and a trailing-zone 6-field form are rejected. This is expected — CRON_TZ= is crontab environment-variable syntax and Cronos has never parsed it in any released version.
The real blocker is not the parser, though: CronScheduleHelper.GetNextOccurrence hardcodes UTC:
// src/Netclaw.Actors/Reminders/CronScheduleHelper.cs
var expression = CronExpression.Parse(cronExpression, CronFormat.Standard);
return expression.GetNextOccurrence(from, TimeZoneInfo.Utc);
There is no timezone field anywhere in the reminder pipeline (ReminderSchedule, ReminderScheduleProto, set_reminder tool).
Key finding: the capability already exists in Cronos
Cronos is built with time zones in mind, and the overload works out of the box — verified against the pinned 0.13.0:
CronExpression.Parse("0 9 * * *").GetNextOccurrence(DateTimeOffset.UtcNow,
TimeZoneInfo.FindSystemTimeZoneById("Europe/Brussels"))
→ 2026-08-07 07:00:00Z (09:00 CEST, DST-correct)
So this is a small change: no new dependency, no new scheduling logic — netclaw just needs to stop hardcoding TimeZoneInfo.Utc.
Proposed change (happy to send a PR)
-
CronScheduleHelper — accept an optional CRON_TZ=<tz-id> prefix (Vixie crontab syntax, which is what agents/users naturally try): strip it in TryParse/GetNextOccurrence, resolve via TimeZoneInfo.FindSystemTimeZoneById, and pass the zone to GetNextOccurrence(from, tz). Unknown zone → clear error. Default stays UTC (no behavior change for existing reminders).
-
Storage — zero proto changes. ReminderManagerActor.ScheduleDefinitionAsync (the single funnel point for all cron scheduling/rescheduling, ReminderManagerActor.cs:965) re-parses the stored expression on every fire, so keeping the prefix inside the stored CronExpression string requires no ReminderScheduleProto/mapper changes. (Alternative: an optional time_zone_id proto field — cleaner schema, but touches netclaw_messages.proto + NetclawProtoMapper.)
-
Touch points — Describe() hardcodes " UTC" in its output (CronScheduleHelper.cs), surfaced by ListRemindersTool/SetReminderTool; the ReminderScheduleParser validation error message; and the set_reminder tool description. All trivial.
-
Tests — extend CronScheduleHelperTests: prefix parsing, DST spring-forward/fall-back transitions, invalid-zone rejection, plain-5-field regression; plus one ReminderManagerActorTests case asserting reschedule lands at the tz-correct UTC instant.
Roughly one small PR, confined to Netclaw.Actors/Reminders + tests.
Environment note
TimeZoneInfo.FindSystemTimeZoneById needs tzdata present in the container image (standard on Linux daemon deployments); worth a mention in docs/tests.
Current workaround
Convert the desired local time to UTC manually and re-adjust the reminder at each DST change.
Summary
Reminder cron schedules are always evaluated in UTC, and the common
CRON_TZ=prefix (Vixie crontab syntax) fails to parse. Timezone-aware cron scheduling is needed so reminders like "every day at 09:00 local time" survive DST transitions without manual UTC re-conversion.Reproduction
On current
dev(Cronos 0.13.0, pinned inDirectory.Packages.props), viadotnet-scriptagainst the exact pinned assembly:So both the prefix form and a trailing-zone 6-field form are rejected. This is expected —
CRON_TZ=is crontab environment-variable syntax and Cronos has never parsed it in any released version.The real blocker is not the parser, though:
CronScheduleHelper.GetNextOccurrencehardcodes UTC:There is no timezone field anywhere in the reminder pipeline (
ReminderSchedule,ReminderScheduleProto,set_remindertool).Key finding: the capability already exists in Cronos
Cronos is built with time zones in mind, and the overload works out of the box — verified against the pinned 0.13.0:
So this is a small change: no new dependency, no new scheduling logic — netclaw just needs to stop hardcoding
TimeZoneInfo.Utc.Proposed change (happy to send a PR)
CronScheduleHelper— accept an optionalCRON_TZ=<tz-id>prefix (Vixie crontab syntax, which is what agents/users naturally try): strip it inTryParse/GetNextOccurrence, resolve viaTimeZoneInfo.FindSystemTimeZoneById, and pass the zone toGetNextOccurrence(from, tz). Unknown zone → clear error. Default stays UTC (no behavior change for existing reminders).Storage — zero proto changes.
ReminderManagerActor.ScheduleDefinitionAsync(the single funnel point for all cron scheduling/rescheduling,ReminderManagerActor.cs:965) re-parses the stored expression on every fire, so keeping the prefix inside the storedCronExpressionstring requires noReminderScheduleProto/mapper changes. (Alternative: an optionaltime_zone_idproto field — cleaner schema, but touchesnetclaw_messages.proto+NetclawProtoMapper.)Touch points —
Describe()hardcodes" UTC"in its output (CronScheduleHelper.cs), surfaced byListRemindersTool/SetReminderTool; theReminderScheduleParservalidation error message; and theset_remindertool description. All trivial.Tests — extend
CronScheduleHelperTests: prefix parsing, DST spring-forward/fall-back transitions, invalid-zone rejection, plain-5-field regression; plus oneReminderManagerActorTestscase asserting reschedule lands at the tz-correct UTC instant.Roughly one small PR, confined to
Netclaw.Actors/Reminders+ tests.Environment note
TimeZoneInfo.FindSystemTimeZoneByIdneeds tzdata present in the container image (standard on Linux daemon deployments); worth a mention in docs/tests.Current workaround
Convert the desired local time to UTC manually and re-adjust the reminder at each DST change.