PKU harness
Concepts

Scheduler and notifications

One scheduler runs reminders, to-dos, class reminders and scheduled checks. Each job pairs a trigger (once, or a series with an end) with an action (notify, check or agent), and fires on the minute.

Everything time-based in PKU harness is handled by one scheduler. Each item in the scheduler is called a job. A reminder, a to-do's reminder, a class reminder and "check my card balance every morning" are all jobs.

You can create jobs in the Schedule tab, or ask the agent to create them. Plugins create jobs with services.scheduler.

Every job has two parts. The trigger decides when it runs, and the action decides what it does when it runs.

Anatomy of a job

type Job = {
  id: string;
  title: string;
  trigger: Trigger;          // when
  action: Action;            // what
  enabled: boolean;
  source: 'user' | 'agent' | 'plugin';
  runs: number;              // occurrences fired (or passed)
  lastRunAt: number | null;
  nextRunAt: number | null;  // null when the series is over
  // check / agent jobs also keep lastValue, lastError, history (last 20 runs), failures …
  meta?: Record<string, unknown>;  // link back to the owner, e.g. { todoId }
  createdAt: number;
  updatedAt: number;
};

Triggers

There are two kinds of trigger. All times are in the phone's local time.

A one-time trigger, { type: 'at' }, has a single field, at, which is a timestamp in milliseconds. It suits reminders such as "in 2 days" or "tomorrow at 8:00".

A repeating trigger, { type: 'every' }, suits reminders such as "every morning at 8", "every 3 days, 16 times, then stop" or "Mon/Wed/Fri at 07:30 until the end of term". It has these fields.

FieldMeaning
unitThe unit of repetition: minute, hour, day or week
intervalRepeat every this many units, 1–1000
times?Times of day as "HH:MM", for day and week
weekdays?Days of the week, 0 = Sunday to 6 = Saturday, for week
startAtWhen the series starts
count?The maximum number of runs
until?The last day of the series (inclusive)

A repeating trigger is worked out as follows. A series is a sequence of periods. Each period is one minute or hour step, one day, or one week starting on Monday. Each period contains slots, formed by combining the times and weekdays. Slots before startAt are skipped. The remaining slots are numbered 0, 1, 2… and cut off by count, until, or both.

The agent and forms use an input format that is easier to write, ScheduleInput, with times as local "YYYY-MM-DD HH:mm" strings. For example, "every 3 days at 09:00, 16 times" is written as:

{ "type": "repeat", "unit": "day", "interval": 3, "times": ["09:00"], "count": 16 }

toTrigger(input) converts this into a trigger. summarize(trigger) returns { schedule, next, total, finished }, for example "每 3 天 09:00 · 共 16 次" plus the next three times. The agent can read these times back to you to confirm before it saves the job.

Actions

There are three kinds of action.

ActionFields
notifytitle, body?, url?
checktool, args?, condition, title, body?, url?
agentprompt (4–1000 characters)

notify sends a notification. It is registered in advance as an exact notification, so no code runs when it fires. url is the in-app route that opens when you tap the notification.

check is a scheduled check. It calls a read-only tool (in the form <plugin>.<tool>), reads the value at condition.path in the result, and sends a notification only if the condition holds. Any {value} in the notification text is replaced by the value it read.

agent runs the agent on a schedule. Each run starts a fresh, read-only agent session with no chat history. If the agent replies NO_REPLY, there is nothing worth telling you. Otherwise, its short answer becomes the notification.

A check condition is written { path, op, value? }. op is one of <, <=, >, >=, ==, !=, changed and always.

This check reminds you to top up when your campus-card balance falls below ¥100:

{
  "type": "check",
  "tool": "wallet.cardBalance",
  "condition": { "path": "balance", "op": "<", "value": 100 },
  "title": "校园卡余额只剩 ¥{value},记得充值"
}

On-time delivery on Android

notify jobs are registered in advance as exact alarms. Alarms are registered for the next 14 days, with at most 64 per job and 400 in total. They are registered again when the app starts, whenever a job changes, when the app comes back to the foreground, and when the background task runs.

check and agent jobs run in JavaScript. While the app is in the foreground, a timer runs them on the minute. When the app is in the background or has been closed by the system, an exact alarm wakes a background (headless) task. That task runs every job that is due, registers the next alarm and removes the placeholder notification.

On-time delivery needs two permissions: the notification permission and the special "Alarms & reminders" access. Android 14 and later deny the second one by default. Turning off battery optimisation is recommended but not required.

Plugins can check and fix these settings with the following functions.

  • services.notify.readiness() reports the state of all three.
  • deliveryBlocker(readiness) names the first thing that prevents on-time delivery.
  • services.notify.fix(kind) opens the matching system prompt or settings screen. Call fix only from a tap.

Limits on unattended runs

Nobody is watching when a scheduled check runs. For this reason, what it can do is limited, every run is logged, and failures are reported to you clearly.

RuleValue
Tools a scheduled run may callread tools only
Enabled jobsAt most 50 (MAX_ACTIVE_JOBS)
Check frequencyAt most every 15 minutes (MIN_CHECK_GAP_MS)
Number of agent jobsAt most 10 (MAX_AGENT_JOBS)
Agent job frequencyAt most once an hour (MIN_AGENT_GAP_MS)
Steps per agent runAt most 8 (AGENT_MAX_STEPS)
Late runsMore than 2 hours late (MAX_LATE_MS) counts as missed
Run logThe last 20 runs (HISTORY_LIMIT)
Failures before a warning2 in a row (FAIL_ALERT_AFTER)
Failures before the job is turned off10 in a row (DISABLE_AFTER)

Some of these rules need a few more words.

  • The tool's effect is checked again at run time. If its mode is ask, the call counts as declined.
  • Duplicate checks are not allowed, meaning checks with the same tool, arguments and condition.
  • A late run counts as missed so that you don't get an out-of-date alert hours later.
  • The run log records the planned and actual time, the result, the value read and any error.
  • After 2 failures in a row, the app tells you once, and again after 6 hours if the job is still failing. After 10 failures in a row, the app turns the job off and tells you.

For plugin authors

  • Make your tools easy to check. Such a tool has the read effect, responds quickly, and has a stable number or boolean field to compare, such as balance, unread or available. List these tools in dataSources so that the agent can find them.
  • Create jobs only in response to something the user did, with services.scheduler.create({ …, source: 'plugin' }). Keep the job id, in meta or in your kv, so that you can update or remove the job later.
  • The SDK exports the scheduler's pure calculation functions, such as occurrencesAfter, nextOccurrence, describeTrigger and evaluate. See SDK → scheduler.

On this page