39 KiB
Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26
Status: implemented
Depends on: 2026-09-26-telegram-attachments-design.md (IChatChannel, ChatContext.Channel, the renderer's
RunInOrderAsync); 2026-09-26-process-environment-hook-design.md (IProcessSpawnPolicy, which builds the watcher
scripts' environment, and ISecretRedactor, which cleans their output); 2026-09-26-telegram-message-context-design.md
(MessageContext.TimestampHeader, the local-time header on every prompt).
Problem
The Telegram bot only speaks when spoken to. The owner wants it to act on a clock: "remind me at 9 tomorrow to call
the dentist", "every weekday at 8, summarise what's in inbox/", "check this page every 10 minutes and tell me only
if the price drops below 50". VolcanicSecretary (/tmp/VolcanicSecretary, src/scheduler/*, src/tools/schedule.ts)
already does this for a single-owner pi bot. This spec ports its behaviour to our multi-chat .NET host: which jobs
exist, how they fire, where the code lives, and how a scheduled prompt fits with a run that's already active in the
same chat.
Reference behaviour kept from VolcanicSecretary
- A job has an
at(one-off) orcronschedule, and it's either fixed text or a shell command. - Watcher convention: exit 0 with empty stdout (after trimming) stays silent. Exit 0 with output delivers the output (here: always to the agent, see Decisions). A non-zero exit or a timeout is a failure. It's reported once, straight to the chat and never through the model, with the last 20 lines of stderr. While later runs fail the same way nothing more is sent. A success resets that.
- One-off jobs are removed after they fire, whether that worked or not. On startup overdue one-offs fire right away. Cron jobs never catch up.
- At most one instance of a job runs at a time. An occurrence that comes due while the previous one is still running is skipped and logged.
- One timer is armed for the soonest due job and capped at 1 h, then re-armed on every tick, create and cancel.
- Captured output is capped: the first 48 KiB and the last 16 KiB of each stream are kept. The script environment doesn't contain the bot token (here: nor any other host secret, through the process environment hook).
- Short ids of 5 characters from
abcdefghjkmnpqrstuvwxyz23456789. The store is written atomically (temp file + rename) after every change or run, and the writes are serialised.
Decisions
- Three explicit kinds instead of the source × delivery matrix.
remindersends fixed text to the chat with no model call.promptruns fixed text through the agent.watcherruns a script, and when the script reports a match its output always goes to the agent. There's no direct-output mode for watchers: the script itself is the condition (silent until it matches), so matches are rare and the agent turning raw output into a message is worth one model call. The consequence: every match costs one model call, and a match that arrives while a run is active is queued as a follow-up like any scheduled prompt. Reminders stay direct because they are fixed text. Failure reports stay direct (see Firing) because they must arrive even when the model is unavailable. - A watcher's script runs without the agent. The agent is invoked only when the condition is met. Every tick runs
the script and nothing else: no model call, no session activity (no session is created or woken, nothing is queued),
nothing in the transcript. The condition is met only when the script exits 0 with non-empty stdout (after
trimming). Then, and only then, the output goes to the agent through
RunPromptAsync. A silent run (exit 0, empty stdout) costs nothing and is invisible except forLastRunAt/LastResult. A failure is reported directly, never through the model. - A new extension project,
extensions/Volcanic.AgenticCore.Extensions.Scheduler. It references only Abstractions, plus Cronos. It isn't a core slice: slices are composed perAgentSession, while the scheduler is process-wide, has to fire for chats with no live session, and brings a third-party dependency the core shouldn't carry. It follows the Claude Code pattern: one process-wide object (JobScheduler), a thin per-session extension that registers thescheduletool, and a delivery interface the host implements. The scheduler itself adds nothing to Abstractions. It uses contracts that other specs add there:IProcessSpawnPolicyandISecretRedactor(2026-09-26-process-environment-hook-design.md) and the session time zone (§8 of this spec). - Jobs are scoped per chat through a
targetstring. On Telegram the target is the chat id (invariant culture). The per-session extension is bound to its target by the host throughAgentSessionOptions.ExtraExtensions, so there's no need to add a "scope" toIExtensionContext. The tool,/jobsand/cancelonly see the jobs of their own target. Scripts run in that target's workspace,<WorkspaceRoot>/<chatId>. - Scheduled agent prompts are queued as follow-ups, never as steering. When the chat is idle a prompt job starts a
run. When a run is active (a user request, or another job), the prompt goes in with
StreamingBehavior.FollowUp: the current work finishes first, then the job's prompt is handled in the same run. A clock-driven prompt shouldn't redirect what the user is doing mid-turn. User messages keep steering as they do today. - Cron parsing uses Cronos (
Cronoson NuGet, Hangfire, MIT, no dependencies). It parses standard 5-field expressions and macros (@daily,@hourly, …).GetNextOccurrence(DateTimeOffset, TimeZoneInfo)works in a given zone and has defined DST semantics: a time skipped when clocks go forward fires at the transition, and an ambiguous time when clocks go back fires once. That matchescronerin the reference. The alternatives: NCrontab has no time zones (we'd have to do the DST arithmetic ourselves). Quartz.NET is a whole scheduler, with its own persistence and a 6/7-field syntax the model gets wrong. We only need "next occurrence after t in zone z". Seconds (CronFormat.IncludeSeconds) and theHjitter aren't allowed: minute granularity is enough, and it also sets a 1-minute floor for watchers. - Each chat has its own time zone. It applies to cron, to displayed times, to the session's system prompt, to the
local-time header on each prompt, and to the
TZof the chat'sbash, watcher andclaude -pprocesses (§8). Anatwithout an offset is read in the chat's zone, because models often leave the offset out. Anatwith an offset is used as given. The zone is a chat setting, not a scheduler setting, because the system prompt andbashneed it with the scheduler disabled too:- Stored by the host, in a small per-chat settings store outside every workspace:
<AgentDir>/telegram/chat-settings.json(§7). Not injobs.json(the scheduler can be off) and not in the session (a/newsession must keep it). Likejobs.jsonit's out of the workspace, so the agent doesn't browse or edit it by accident.bashcan still reach it as the bot's OS user, like every file under the agent dir. - Default:
Telegram:DefaultTimeZone(envDEFAULT_TIMEZONE), elseTimeZoneInfo.Local, which honoursTZ. A chat without a stored zone follows the default, so changing the default moves every such chat. - Set by
/timezone <IANA id>in the chat, or by the agent through aset_timezonetool when the user says where they are. Validated withTimeZoneInfo.FindSystemTimeZoneByIdand stored as an IANA id. - The process-wide
Scheduler:TimeZoneis gone. The scheduler asks the host for a target's zone (IJobDelivery.TimeZoneFor).
- Stored by the host, in a small per-chat settings store outside every workspace:
- Changing a chat's zone re-evaluates cron and leaves one-offs alone. A cron expression is wall-clock time in the
chat's zone ("every weekday at 8" means 8 where the user is now), so on a change every cron job of that chat gets its
next occurrence recomputed in the new zone, strictly after now, with no catch-up. A one-off
atis an absolute instant, fixed when the job was created (an offset-lessatwas resolved in the zone of that moment), so it fires at the same instant and is only displayed in the new zone. The confirmation says both. Per-job zones are out of scope. - Restart and missed fires: one-offs fire late with a note, and cron doesn't catch up. On startup an overdue one-off
fires immediately. If it's more than 60 s late, direct text gets
(due <time>, delivered late)and an agent prompt getsThis was due at <time>; the bot was offline.A cron job's next occurrence is computed strictly after "now". Occurrences missed while the bot was down are dropped, which is logged at Information. A one-off is removed only after its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once). - Persistence is one central file:
<AgentDir>/scheduler/jobs.json(by default~/.volcanic/agent/scheduler/). The workspace is deliberately not used: the agent'sbashcan write anywhere there, and a central file is loaded without scanning every workspace. The format is{ "version": 1, "jobs": [...] }inVolcanicJsoncamelCase. - Direct delivery is text, through
IChatChannel. The sibling spec2026-09-26-telegram-attachments-design.mdaddsIChatChannel(Abstractions,Ui/) withSendTextAsync/SendFileAsync.IJobDeliverystays, because it does what the channel doesn't (target routing, prompt runs, workspaces, deliverability), but the Telegram implementation ofIJobDelivery.SendTextAsyncis a thin call to the chat session'sIChatChannel.SendTextAsync. A scheduledprompt(and a watcher match) runs as a normal agent run, so it can use that spec'ssendtool. Direct reminders and failure alerts stay text. - Direct deliveries are not added to the transcript. Reminders and failure alerts go to the chat only;
IChatChannelnever persists and the scheduler appends noCustomMessage. As a consequence the agent doesn't see direct reminders (it can't answer "what was that reminder about?" from context). That's accepted: a job that should be in the conversation is aprompt, and a watcher match always is. - Scheduled prompts carry the same local-time header as user messages. Every prompt a job sends to the agent (a
promptjob, a watcher match) starts with the message-context spec's timestamp line in the chat's zone, e.g.[Sat 2026-09-26 08:00 Europe/Berlin], taken fromTimeProviderat fire time. The Telegram delivery adds it (§6), so the scheduler's own format stays host-neutral. The header is the actual fire time, so a late one-off shows both that and itsThis was due at …note. - Watcher output is redacted before the agent sees it. Watcher stdout isn't a tool result, so the loop's redaction
doesn't reach it; the scheduler applies
ISecretRedactoritself (§4). - Time comes only from
TimeProvider. NoDateTime.NoworTask.Delay: the timer isTimeProvider.CreateTimer.
Components
1. Job model (Scheduler/Jobs.cs)
public enum JobKind { Reminder, Prompt, Watcher }
public sealed record JobSchedule(DateTimeOffset? At, string? Cron); // exactly one is set; At is an absolute instant
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
public sealed record ScheduledJob
{
public required string Id { get; init; }
public required string Target { get; init; } // chat id on Telegram
public required string Name { get; init; }
public required JobKind Kind { get; init; }
public required JobSchedule Schedule { get; init; }
public string? Text { get; init; } // reminder, prompt
public string? Command { get; init; } // watcher
public int? TimeoutSeconds { get; init; } // watcher only; default 300, max 3600
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public JobResult? LastResult { get; init; }
}
Validation, shared by the tool and the store loader. The name is required and at most 80 characters.
Exactly one of at/cron is set. The cron expression parses with Cronos CronFormat.Standard. A one-off at must be
in the future when created (with 60 s of grace). Reminders and prompts need text (at most 4000 characters) and no
command. Watchers need command and no text. timeoutSeconds must be between 1 and 3600 and is only allowed on
watchers. There are at most MaxJobsPerTarget (50) jobs per target. Every error is a sentence the model can act on.
2. JobStore
LoadAsync() returns the jobs. A missing file means an empty list. A corrupt file is renamed to
jobs.json.corrupt-<timestamp>, logged as an error, and loading starts empty: a bad file must not keep the host down.
Invalid jobs are dropped with a warning. SaveAsync(jobs) writes jobs.json.<pid>.tmp and then does
File.Move(overwrite: true). Writes go through one SemaphoreSlim.
3. ScriptRunner (IScriptRunner, faked in tests)
RunAsync(ScriptRequest(command, cwd, timeout, target, jobId, timeZoneId), ct) → ScriptOutcome (Silent |
Output(text) | Failed(exitCode?, stderrTail, timedOut)).
- The shell matches the built-in
bashtool, so a script the agent tested withbashbehaves the same when it's scheduled:/bin/sh -c <command>on Unix andcmd.exe /d /s /con Windows. It's overridable throughSchedulerOptions.Shell. On Unix the command is passed throughArgumentList, not quoted by hand. On Windowscmd.exedoesn't parseArgumentList's quoting, so the command goes throughArgumentsas/d /s /c "<command>", exactly as thebashtool does. A configuredSchedulerOptions.Shellalways usesArgumentList. - The working directory is the target's workspace (created if missing). Stdin is closed.
- The command, cwd and environment come from
IProcessSpawnPolicy.Prepare(DI singleton, registered byAddAgenticCore) withPurpose = ScheduledScript,Scope = target,Origin = job idandTimeZoneId= the target's zone. So the script sees the same filtered environment asbash, withTZset to the chat's zone, and host hooks apply. There's no scheduler-specific scrub list any more (SchedulerOptions.ScrubbedEnvironmentis removed). A policy that throws isFailed(null, "process environment hook failed: …"). Session-levelProcessSpawnEventhandlers don't run: a job fires without a live session. - Timeout or cancellation →
Process.Kill(entireProcessTree: true). A timeout isFailed(null, stderrTail, timedOut: true); a cancellation throwsOperationCanceledExceptionafter the kill. A shell that fails to start (or a workspace that can't be created) isFailed(null, "failed to start <shell>: …"), never an exception. - Output capture as in the reference, then
Trim()on stdout.
4. JobScheduler (process-wide, the slice's entry type)
public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
{
Task StartAsync(IJobDelivery delivery, CancellationToken ct); // load, fire overdue one-offs, arm timer
Task StopAsync(CancellationToken ct); // disarm, kill running scripts, wait ≤ 10 s
IReadOnlyList<JobView> List(string target); // JobView = job + NextRunAt (null = never)
Task<JobView> CreateAsync(string target, NewJob job, CancellationToken ct); // throws JobValidationException
Task<bool> CancelAsync(string target, string id, CancellationToken ct); // false: unknown, or another target's
TimeZoneInfo TimeZoneFor(string target); // delivery.TimeZoneFor
Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken ct); // recompute cron, re-arm; counts for the reply
}
public interface IJobDelivery
{
/// <summary>Send Markdown to the target's chat, in order with that chat's rendered output.</summary>
Task SendTextAsync(string target, string markdown, CancellationToken ct);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken ct);
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}
The delivery is passed to StartAsync and not to the constructor. That breaks the cycle
ChatRegistry → session factory → scheduler extension → scheduler → delivery → ChatRegistry.
Firing (FireAsync(job), tracked in an in-flight set and never awaited by the tick):
| Kind | Action | Message format |
|---|---|---|
| reminder | SendTextAsync |
⏰ <text>; fired more than 60 s late: ⏰ <text> (due <d MMM HH:mm>, delivered late) |
| prompt | RunPromptAsync |
[Scheduled job "<name>" (<id>)]\n<text>; fired more than 60 s late, the note gets its own line after the header: [Scheduled job "<name>" (<id>)]\nThis was due at <d MMM HH:mm>; the bot was offline.\n<text> |
| watcher, matched (exit 0, non-empty stdout) | RunPromptAsync |
[Scheduled job "<name>" (<id>)] The watcher script printed:\n<stdout> |
| watcher, silent (exit 0, empty stdout) | nothing: no model call, no session activity, no transcript entry | only LastRunAt/LastResult are updated |
| watcher, failed | SendTextAsync once per failure streak |
⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n````; with no exit code it reads failed (timed out)orfailed (did not start)` |
- Watcher matches never get a late note (a watcher only runs when its occurrence comes due; there's no catch-up).
- Watcher invariant. Running a watcher's script touches only
IScriptRunner,IsDeliverable,WorkspaceForandTimeZoneFor. It never callsRunPromptAsyncorSendTextAsync, and the Telegram delivery'sWorkspaceFor/TimeZoneFordon't create or look up a chat session (nor doesIsDeliverable). Only the outcome decides what follows:Output→ exactly oneRunPromptAsync,Failed→ at most oneSendTextAsyncper failure streak,Silent→ nothing. - Redaction. A watcher's stdout goes through
redactor.Redactbefore it's put into the prompt, and the stderr tail before it's put into the failure alert (direct to the chat, but still not a place for the bot token). Known secret values become[REDACTED](process-environment-hook spec §9).LastResult.StderrTailis stored redacted. - A failure repeats the previous one when the previous
LastResultwas also a failure with the sameExitCodeandTimedOut. A repeat isn't sent. - For a prompt (and a watcher match), "in flight" lasts until
RunPromptAsynccompletes, i.e. until the run that handled it settles. So a 5-minute job over a 20-minute run skips occurrences instead of piling up follow-ups. LastRunAt/LastResultare updated and persisted after each fire. A one-off is removed after its fire completes.- A delivery exception is logged and swallowed. The Telegram channel already retries a send (1 s, 3 s) and then
throws
ChatDeliveryException. The job's state still advances: there's no redelivery queue. CancelAsyncremoves the job. If that job's watcher script is running it's killed and its outcome thrown away. A prompt run that's in progress isn't aborted: that's what/stopis for.!IsDeliverable(target)at fire time → skip, log a warning, keep the job (a cron job advances); a skipped one-off stays in the store and fires again after the next start.- Saves are serialised and each one snapshots the jobs inside that gate, so a save never overwrites newer state.
TimeZoneChangedAsync(target)recomputesNextRunAtfor the target's cron jobs in the new zone (strictly after now, no catch-up), leaves one-offs untouched, re-arms the timer and returnsZoneChange(int CronJobs, int OneOffs). A cron occurrence already in flight isn't affected.
5. SchedulerExtension and the schedule tool (per session)
SchedulerExtension(JobScheduler scheduler, string target) registers ScheduleTool (Tool<ScheduleArgs>):
schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
at?, cron?, text?, command?, timeoutSeconds?)
createreturns the job line (see below), its next fire time in the chat's zone, andNow: <local time> (<zone>), so the model can see a wrong date or zone straight away.listreturns the target's jobs plus the same "Now" line.cancelneedsid.PromptSnippet:schedule: reminders, recurring prompts and watcher scripts that message this chat.PromptGuidelines:- Times are in the chat's zone (shown in the system prompt);
atis ISO 8601; cron has 5 fields. The zone isn't repeated in the guideline, so the tool's prompt text stays the same when the zone changes. - Use
reminderfor fixed text andpromptwhen the job needs you to think or use tools. - Watcher scripts run with the same shell as
bash, from the workspace, without you: print nothing and exit 0 while the condition isn't met (that costs nothing). Print the facts only when it is met; the output comes back to you as a prompt, and you write the message. Exit non-zero only on a real error. - Put reusable scripts in
scripts/and test them withbashbefore scheduling.
- Times are in the chat's zone (shown in the system prompt);
- It isn't registered when the extension is loaded through discovery without a host-registered
JobScheduler: the constructor can't be resolved, and that shows up as an ordinary extension load error.
6. Telegram host wiring
- Options: a new
Schedulersection bound toSchedulerOptions:Enabled(true),StorePath(<AgentDir>/scheduler/jobs.json),DefaultScriptTimeout(300 s),MaxJobsPerTarget(50) andShell. There's no time zone and no scrub list here: zones are per chat (§7), the environment comes from the process environment hook. An emptyStorePathmeans<AgentDir>/scheduler/jobs.json;Validaterejects aDefaultScriptTimeoutoutside 1 s–1 h,MaxJobsPerTarget< 1 and emptyShellentries (exit 2). Program.csregistersSchedulerOptions,IScriptRunner→ScriptRunner,TimeProvider.System(oneTryAddSingleton, shared with the message-context and attachments specs),JobScheduler,TelegramJobDelivery,ChatSettingsStore,ChatTimeZoneService(§7; registered even when the scheduler is disabled) andSchedulerHostedService. The hosted service callsStartAsync(delivery)andStopAsync, and is registered afterPollingService.ChatRegistry.CreateAsyncaddsExtraExtensions = [ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, chatId.ToString(CultureInfo.InvariantCulture)))].TelegramJobDeliveryis built onChatRegistryandTelegramOptions:SendTextAsync→(await chats.GetAsync(chatId)).Channel.SendTextAsync(markdown, ct), i.e. the chat session'sIChatChannel(TelegramChatChannel, attachments spec). No bespoke delivery code: the channel goes through the renderer queue (ChatRenderer.RunInOrderAsync) and the reply path (split at 4096, HTML with a plain fallback, retries, status message pushed to the bottom), which keeps a reminder in order with a reply that's being rendered.RunPromptAsync→ prefixes the prompt withMessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target))on its own line (message-context spec), thensession.PromptAsync(prompt, null, StreamingBehavior.FollowUp). When it queued rather than ran, it then awaitssession.WaitForIdleAsync(). Exceptions are reported throughchat.Reportas for user prompts. It doesn't go throughUpdateRouteror the chat'sChatInbox(message-context spec), so a scheduled prompt is never debounced into a user's message and can never answer a pendingInputAsyncquestion (e.g. the owner's Claude Code login code prompt).IsDeliverable→ the chat id is inAllowedUserIds(private chat id = user id).WorkspaceFor→ChatRegistry.WorkspaceFor(a path computation; no session is created).TimeZoneFor→ChatSettingsStore.TimeZoneFor(chatId)(§7).
- Commands (
ChatCommands, added toMenuand/help):/jobslists this chat's jobs, orNo scheduled jobs.:The header and every time are in the chat's zone.**Scheduled jobs** (Europe/Berlin, now 26 Sep 14:03) `k7m2q` ⏰ Call the dentist · once 27 Sep 09:00 `p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 29 Sep 08:00 `w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)/cancel <id>→Cancelled k7m2q (Call the dentist).No job k7m2q in this chat.also covers another chat's id. With no id:Usage: /cancel <id> (see /jobs)./statusgains**Jobs:** <n>.- With the scheduler disabled,
/jobsand/cancelstay in the menu and replyScheduling is turned off on this bot (Scheduler:Enabled).;/statushas no Jobs line.
- Security: scheduled scripts run unattended as the bot's OS user, with the same trust as the
bashtool that every allow-listed user already has, and the same filtered environment. The existing Security section indocs/telegram-bot.mdgains that sentence.
7. Per-chat time zones (Telegram host)
ChatSettingsStore(Chats/ChatSettingsStore.cs):<AgentDir>/telegram/chat-settings.json,{ "version": 1, "chats": { "<chatId>": { "timeZone": "Asia/Tokyo" } } }inVolcanicJsoncamelCase. Loaded once and cached in memory; written atomically (temp file + rename) through oneSemaphoreSlim, likeJobStore. A corrupt file is set aside and loading starts empty; a stored id that no longer resolves is logged and ignored (the chat follows the default).TimeZoneFor(chatId)→ the stored zone, else the default. It's a host type, not a scheduler one: it exists with the scheduler disabled, and it's the place for later per-chat settings.- Default:
Telegram:DefaultTimeZone(env shortcutDEFAULT_TIMEZONE, empty = unset), elseTimeZoneInfo.Local. An unknown id is a startup validation error (exit 2, like the rest ofTelegramOptions). It also setsAgenticCoreOptions.DefaultTimeZone(§8), so headless use of the core in the same process agrees. - Parsing (
ChatTimeZones.TryParse(string, out TimeZoneInfo, out string error)), shared by the command and the tool: trim,TimeZoneInfo.FindSystemTimeZoneById. A Windows id is accepted and converted to IANA (TimeZoneInfo.TryConvertWindowsIdToIanaId); the IANA id is what's stored.Localand bare offsets (+03:00,GMT+3) are rejected withUnknown time zone "<x>". Use an IANA name such as Europe/Berlin or America/New_York. ChatTimeZoneService.SetAsync(chatId, zone | null)is the one path for a change: write the store (null removes the entry, back to the default),session.SetTimeZone(zone)on the chat's live session if there is one (§8), thenscheduler.TimeZoneChangedAsync(target)when the scheduler is enabled. It returns the text both entry points reply with:Time zone set to Asia/Tokyo (UTC+09:00). Now 27 Sep 02:15. 2 recurring jobs now follow this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).The job clause is left out when the chat has no jobs./timezone(ChatCommands, added toMenuand/help):/timezone Asia/Tokyosets,/timezone resetreturns to the default,/timezonealone showsTime zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.It runs as a host command, outside the agent loop.set_timezonetool:ChatRegistry.CreateAsyncadds a small host extension (ChatSettingsExtension(chatId, service), throughExtraExtensionsnext toSchedulerExtension) that registersset_timezone(zone). Guideline:Use set_timezone only when the user tells you their location or time zone changed; confirm the new zone in your reply.Errors fromTryParsebecome error results. It runs whether or not the scheduler is enabled./statusgains**Time zone:** <id> (<offset>).ChatTimeZoneServicefinds the live session through a function (ChatRegistry.Find) so DI has no ChatRegistry ↔ service cycle; with no scheduler,/timezonereplies without the job clause.
8. Session time zone (core)
The session carries its zone, so the system prompt and bash agree with the chat. AgentSessionOptions.TimeZone,
AgenticCoreOptions.DefaultTimeZone, AgentSession.TimeZone and IExtensionContext.TimeZone already exist (added by
the process-environment-hook spec, together with TimeZoneIds.ToIana); this spec adds SetTimeZone, the prompt change
and Claude Code's TZ:
-
AgentSessionOptions.TimeZone(TimeZoneInfo?) andAgenticCoreOptions.DefaultTimeZone(TimeZoneInfo?, null =TimeZoneInfo.Local). The session's zone is the first that is set. -
AgentSession.TimeZoneandAgentSession.SetTimeZone(TimeZoneInfo). A change applies from the next run: the system prompt is rendered per run (BeforeAgentStartEvent), so a run in progress keeps its prompt. It isn't persisted in session entries: the host owns persistence (§7), and a headless host passes the option each time. -
IExtensionContext.TimeZone(Abstractions) exposes it to tools and extensions.scheduledoesn't need it (it asksJobScheduler.TimeZoneFor(target)), but the calendar and MCP do (their specs). -
IExtensionContext.PrepareProcessAsyncpasses it asTimeZoneId, sobash'sTZis the chat's zone anddateprints local time (2026-09-26-process-environment-hook-design.md§3–§4). -
System prompt.
SystemPromptBuildertakes aTimeProviderand aFunc<TimeZoneInfo>instead ofFunc<DateTimeOffset>, and the environment section becomes:Current date: 2026-09-26 (Saturday) Time zone: Europe/Berlin (UTC+02:00) Working directory: /data/workspaces/111111111The date is the date in the session's zone. The time of day is deliberately left out: the prompt is rendered at every run, and a clock time would change the start of the cached prefix on every run (the Claude Code provider replays the whole transcript and relies on the prompt cache). Date and offset change at most daily. The model gets the exact time from the local-time header on every user message and scheduled prompt (
2026-09-26-telegram-message-context-design.md; host-side, appended so it doesn't touch the cached prefix), fromschedule(its "Now" line) or fromdateinbash. -
Claude Code's
TZ. The Claude Code provider passes the session's zone to itsclaude -pchild explicitly: the extension givesClaudeCodeApiOptionsaFunc<string?>that readsIExtensionContext.TimeZoneper request (soSetTimeZoneapplies from the next model call), andClaudeArgs.BuildEnvironmentsetsTZto that IANA id after the allow-list, like its pinnedCLAUDE_CODE_*flags. Anything Claude Code itself adds about the date then agrees with our prompt. It isn't set on Windows (the OS ignoresTZ, as in the process-environment-hook spec §3), nor forclaude auth login/auth statusand the doctor's probes, which have no session.TZstays off the inherited allow-list: the host's ownTZnever leaks through. This goes through the provider, notIProcessSpawnPolicy(process-environment-hook spec §7).docs/claude-code-provider.md(its environment section) is updated with the implementation. -
Headless sample: no change; it gets
TimeZoneInfo.Localas before, now shown in the prompt.
9. Docs
The implementation updates docs/telegram-bot.md (a Scheduling section: examples, the watcher convention and that
a match always goes to the agent, /jobs, /cancel, /timezone, Scheduler:* and Telegram:DefaultTimeZone
settings, the restart behaviour, what a zone change does to jobs), docs/architecture.md (the extension list and the
session time zone) and the command table in CLAUDE.md.
Testing
xunit 2. No test calls a paid API. Time is FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing, in
Volcanic.AgenticCore.Extensions.Tests and Volcanic.AgenticCore.Telegram.Tests; shared with the message-context and
calendar specs). Zones are fixed (Europe/Berlin, Asia/Tokyo), never the machine's.
- Schedules (
tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/): next cron occurrence strictly afterfrom, in the zone. DST spring-forward and fall-back days give one fire each. Anatwithout an offset is read in the zone. Invalid cron, seconds fields,H, and a pastatare rejected with readable messages. - Store: missing file → empty. Round trip leaves no temp file. A corrupt file is set aside and loading starts empty. Invalid entries are dropped. Concurrent saves are serialised.
JobScheduler, with a fakeIJobDelivery(records calls, lets a test holdRunPromptAsyncopen) and a fakeIScriptRunner:- An overdue one-off fires on start, with the late note past 60 s, and is removed only after delivery.
- Cron doesn't catch up after a simulated downtime.
- Advancing the clock fires, and the 1 h cap re-arms without firing.
- A reminder is sent as text and a prompt as a prompt.
- Watcher invariant: a watcher every minute whose fake runner returns
Silentfor N = 10 ticks → 10 runner calls, zeroRunPromptAsync, zeroSendTextAsync, and onlyLastRunAt/LastResultchanged in the store. The 11th tick returnsOutput("price 48")→ exactly oneRunPromptAsyncwith the "watcher script printed" text, still zeroSendTextAsync. - A failure is reported once. Same exit code again → silent. A different exit code or a timeout → reported. A success followed by a failure → reported.
- Overlapping occurrences are skipped while a watcher runs, and while a prompt run is held open.
- Targets are isolated in
ListandCancelAsync. - Cancel kills a running watcher and drops its output.
IsDeliverable == falseskips the fire and keeps the job.StopAsynckills scripts and returns within the grace period.- Zone change: a
0 9 * * *job and a one-off inEurope/Berlin; the fake delivery switches the target toAsia/TokyoandTimeZoneChangedAsyncruns → the cron job's next run is 09:00 Tokyo, the one-off's instant is unchanged, the timer is re-armed, the counts are (1, 1). Another target's jobs don't move. - Redaction: with a fake redactor for
hunter2-secret, a watcher whose output contains it → the prompt says[REDACTED]; a failure whose stderr contains it → the alert and the storedStderrTailsay[REDACTED].
ScriptRunner(real processes, Unix-only like the existing script-based tests, with a realProcessSpawnPolicyover an injected environment snapshot): output, silent, a failure with a 20-line stderr tail, a timeout kills the process tree, the capture is capped,TELEGRAM_BOT_TOKENandOPENAI_API_KEYare absent,TZis the target's zone, a throwing host hook givesFailedwithout starting a process, and the cwd is the workspace.scheduletool: create, list and cancel throughToolInvocation, validation errors as error results,list/cancelnever see another target's jobs, the "Now" line uses the target's zone, and an offset-lessatis read in the target's zone.- Session zone and prompt (
tests/Volcanic.AgenticCore.Tests): withFakeTimeProviderat 2026-09-26T23:30Z, a session inAsia/TokyorendersCurrent date: 2026-09-27 (Sunday)andTime zone: Asia/Tokyo (UTC+09:00), and one inEurope/Berlinrenders 2026-09-27 too whileAmerica/New_Yorkrenders 2026-09-26.SetTimeZoneduring a run changes the next run's prompt, not the current one. Without options the default isTimeZoneInfo.Local. - Claude Code
TZ(tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/):BuildEnvironmentwith a zone id setsTZ, without one leaves it unset, and a parentTZis never copied; the fake-CLI request test sees the session's zone in the child's environment and a new zone afterSetTimeZone. - Telegram (
ChatHost+FakeBotClient+FauxProvider, with a realJobScheduleron a temp store path andFakeTimeProvider):- A reminder is sent to the right chat, the Faux provider saw no request, and the session transcript has no new entry.
- A watcher over a real script that prints nothing, advanced through several ticks: the Faux provider saw no
request, no session was created for the chat (
ChatRegistry.Findis null), no message was sent. The script is then changed to print a line: the next tick produces exactly one Faux request and the agent's reply is rendered. - A prompt job on an idle chat → the agent's scripted reply is rendered, and the faux request's last user message starts with the timestamp header at the fire time in the chat's zone.
- A prompt job while a user run is active: the faux turn is held, then the clock advances. The job's prompt is
queued as a follow-up (a
QueueUpdatewith one follow-up and no steering). The user's reply is rendered before the job's reply. - The Faux provider scripts a
scheduletool call that creates a job for its own chat, and/jobsin another chat doesn't show it. /cancelwith its own id, an unknown id, and another chat's id.- Restart: dispose the host and start a new
ChatHoston the same store. The overdue one-off is delivered with the late note, and the cron job isn't caught up. - While the owner has a pending login code prompt, a scheduled prompt doesn't answer it.
/timezone Asia/Tokyo: the store has the zone, the reply names it and the job counts,/jobsshows Tokyo times, and the next faux request's system prompt saysAsia/Tokyo./timezone Mars/Olympus→ the error text, nothing stored./timezone reset→ the default. A restart keeps the zone. A scriptedset_timezonecall does the same as the command. Another chat's zone is untouched.
Out of scope
- Chats other than private ones, and per-job time zones (a cron job always follows its chat's current zone).
- Editing a job in place (cancel and create again), pausing, run history beyond
LastResult, and catch-up policies for cron. - Seconds-level cron, jitter, and a heartbeat that wakes the agent without a job.
- A direct (model-free) delivery mode for watcher output (see Decisions).
- Sending files from direct jobs (a
promptjob can use thesendtool from2026-09-26-telegram-attachments-design.md), and a sandbox for scripts. - Adding direct deliveries to the transcript (see Decisions).
- Scheduler support in the headless sample. Any host can implement
IJobDelivery.