scheduler
Types and helpers for the scheduler. Trigger, Action, Condition and Job with their zod schemas, the pure recurrence engine, the model-friendly ScheduleInput format, condition evaluation and the limits for unattended runs.
The scheduler runs jobs at set times. For example, it sends a reminder on time, or checks the campus card balance every morning and notifies the user when it falls below a threshold. This module defines the data structure of a job and provides functions that compute repeat times, evaluate conditions and check how often a job may run. A plugin uses it when it creates scheduled jobs or shows a job's next run on a screen.
Sources: packages/sdk/src/scheduler/{types,recurrence,schedule-input,check,policy}.ts. Concepts:
Scheduler. Nothing here imports React Native, and all times are in
the device's local time.
Types and schemas
/** "HH:MM", 24-hour, local time. */
const timeOfDay = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'time must be HH:MM (24h)');
const triggerSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('at'), at: z.number() /* epoch ms */ }),
z.object({
type: z.literal('every'),
unit: z.enum(['minute', 'hour', 'day', 'week']),
interval: z.number().int().min(1).max(1000),
/** day/week units: times of day; default = the time of `startAt`. */
times: z.array(timeOfDay).min(1).max(24).optional(),
/** week unit: 0 = Sunday … 6 = Saturday; default = weekday of `startAt`. */
weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(),
startAt: z.number(), // epoch ms; first possible occurrence
/** Total occurrences in the series, then stop. */
count: z.number().int().min(1).optional(),
/** Last possible occurrence (inclusive), epoch ms. */
until: z.number().optional(),
}),
]);
type Trigger = z.infer<typeof triggerSchema>;
const conditionSchema = z.object({
/** Dot path into the data source result, e.g. "balance". Empty = the whole result. */
path: z.string().default(''),
op: z.enum(['<', '<=', '>', '>=', '==', '!=', 'changed', 'always']),
value: z.union([z.number(), z.string(), z.boolean()]).optional(),
});
type Condition = z.infer<typeof conditionSchema>;
const actionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('notify'), title: z.string().min(1), body: z.string().optional(), url: z.string().optional() }),
z.object({
type: z.literal('check'),
tool: z.string(), // `<plugin>.<tool>`, read-only
args: z.record(z.string(), z.unknown()).optional(),
condition: conditionSchema,
title: z.string().min(1), // `{value}` is replaced by the checked value
body: z.string().optional(),
url: z.string().optional(),
}),
z.object({ type: z.literal('agent'), prompt: z.string().min(4).max(1000) }),
]);
type Action = z.infer<typeof actionSchema>;
type JobSource = 'user' | 'agent' | 'plugin';
type JobRun = {
at: number;
/** The occurrence it ran for (null for a manual run). */
scheduledAt: number | null;
ok: boolean;
notified: boolean;
value?: unknown;
error?: string;
ms: number;
manual?: boolean;
/** `agent` jobs: the reply (empty when it stayed silent). */
output?: string;
/** Not run: more than MAX_LATE_MS late. */
missed?: boolean;
};
type Job = {
id: string;
title: string;
trigger: Trigger;
action: Action;
enabled: boolean;
source: JobSource;
runs: number;
lastRunAt: number | null;
nextRunAt: number | null;
lastValue?: unknown;
lastError?: string;
history?: JobRun[];
failures?: number;
alertedAt?: number | null;
meta?: Record<string, unknown>;
createdAt: number;
updatedAt: number;
};
/** Jobs whose runs execute in JS (a check or an agent run) rather than as a pre-armed notification. */
const runsInJs: (action: Action) => boolean;There are three kinds of action. A notify action sends a notification. A check action calls a
read-only tool and notifies only when a condition holds. An agent action runs the agent with a
prompt.
timeOfDay, triggerSchema, conditionSchema and actionSchema are all exported. The host
refuses to run a check job whose tool isn't effect: 'read'.
Recurrence engine
These pure functions work out when a job runs, based on its trigger.
type Occurrence = { at: number; index: number };
function nextOccurrence(trigger: Trigger, after: number): Occurrence | null; // first strictly after `after`
function occurrencesAfter(trigger: Trigger, after: number, limit: number, before?: number): Occurrence[];
function lastOccurrence(trigger: Trigger, t: number): Occurrence | null; // latest at or before t
function runsBy(trigger: Trigger, t: number): number; // occurrences by t
function totalOccurrences(trigger: Trigger): number | null; // null = open-ended
function describeTrigger(trigger: Trigger, now?: number, locale?: 'zh-Hans' | 'en'): string;
function parseTime(t: string): [number, number]; // "08:30" → [8, 30]
const formatTime: (h: number, m: number) => string; // (8, 5) → "08:05"A repeating series is made of periods of equal length. A period can be a number of minutes or
hours, one day, or one week starting on Monday. Every period has the same time slots, formed from
the times of day and the weekdays. Slots before startAt are skipped, and the rest are numbered
0, 1, 2… until count or until ends the series. Daylight-saving changes don't shift the series.
The example below runs every 3 days at 9 am, 16 times, starting on October 20:
const every3Days16Times: Trigger = {
type: 'every', unit: 'day', interval: 3, times: ['09:00'], count: 16,
startAt: new Date(2026, 9, 20).getTime(),
};
occurrencesAfter(every3Days16Times, Date.now(), 3); // next three
totalOccurrences(every3Days16Times); // 16
describeTrigger(every3Days16Times, Date.now(), 'en'); // e.g. "Every 3 days at 09:00 · 16 times"ScheduleInput (model- and form-friendly)
A Trigger stores times as epoch milliseconds, which are hard for a model or a form to fill in.
ScheduleInput uses local time strings in the form "YYYY-MM-DD HH:mm" instead, and toTrigger
converts it to a Trigger.
const scheduleInputSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('once'), at: localDateTime }),
z.object({
type: z.literal('repeat'),
unit: z.enum(['minute', 'hour', 'day', 'week']),
interval: z.number().int().min(1), // every N units
times: z.array(timeOfDay).optional(), // day/week
weekdays: z.array(z.number().int().min(0).max(6)).optional(), // week: 0 = Sunday
start: localDateTime.optional(), // default now
count: z.number().int().min(1).optional(),
until: localDateTime.optional(), // inclusive
}),
]);
// localDateTime: /^\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}(:\d{2})?$/ ("YYYY-MM-DD HH:mm")
type ScheduleInput = z.infer<typeof scheduleInputSchema>;
function toTrigger(input: ScheduleInput, now?: number): Trigger;
function toScheduleInput(t: Trigger): ScheduleInput;
function parseLocal(s: string): number; // throws on bad or non-existent dates
function formatLocal(ms: number): string; // "2026-10-20 08:00 周二"
function summarize(trigger: Trigger, now?: number, n?: number): {
schedule: string; // describeTrigger
next: string[]; // next n occurrences, formatLocal
total: number | null;
finished: boolean;
};We recommend using scheduleInputSchema as a tool's input and returning the output of summarize
in the tool's result. The model then has the exact run times and can read them back to the user to
confirm.
Conditions
A check job uses these functions to read a value from a tool result, decide whether to notify,
and fill in the notification text.
/** Read a dot path ("data.balance", "items.0.amount") from a tool result. */
function getPath(value: unknown, path: string): unknown;
/** Does the checked value satisfy the condition? `previous` is the value from the last run. */
function evaluate(cond: Condition, value: unknown, previous?: unknown): boolean;
/** Replace `{value}` in notification text with the checked value (numbers rounded to 2 decimals). */
function fillTemplate(template: string, value: unknown): string;The changed operator is true only when there is a previous value and the new value differs from
it (compared as JSON). The numeric operators convert numeric strings to numbers. Any value that
isn't a number compares as false.
Policy constants
These constants and functions set the limits for jobs that run unattended. For example, they set how many jobs can be active, how often a check job may run, and how many failures in a row disable a job.
const MAX_ACTIVE_JOBS = 50;
const MIN_CHECK_GAP_MS = 15 * 60_000;
const MIN_AGENT_GAP_MS = 60 * 60_000;
const MAX_AGENT_JOBS = 10;
const AGENT_MAX_STEPS = 8;
const HISTORY_LIMIT = 20;
const FAIL_ALERT_AFTER = 2;
const DISABLE_AFTER = 10;
const ALERT_COOLDOWN_MS = 6 * 3_600_000;
const MAX_LATE_MS = 2 * 3_600_000;
const LATE_MS = 5 * 60_000;
const NO_REPLY = 'NO_REPLY';
function minGap(trigger: Trigger, now: number): number; // Infinity for one-shots
function checkScheduleProblem(trigger: Trigger, now: number): string | null; // why a check schedule is too frequent
function recordRun(job: Job, run: JobRun): { patch: Pick<Job, 'history' | 'failures' | 'alertedAt' | 'enabled'>; alert: 'none' | 'failing' | 'disabled' };
const isLate: (run: Pick<JobRun, 'at' | 'scheduledAt'>) => boolean;
const isSilentReply: (text: string) => boolean; // empty or starts with NO_REPLY
function agentJobHeader(job: { id: string; title: string; prompt: string }): string;The host's scheduler engine enforces these limits. A plugin can import the constants to explain the limits before the user creates a job, so the user doesn't find out only after the job fails to run.