Scheduled work
An extension can do work when nobody is looking: a nightly digest, a periodic sync, a check for something overdue. You register a callback with an interval during activation, and the host runs it while the app is running.
After this page you can register a job, write an interval the parser accepts, and — more importantly — know the four things this scheduler does not do, so you build around them instead of discovering them in production.
Declare it
Section titled “Declare it”Two pieces. The permission in the manifest, and the registration in code.
{ "permissions": ["cron"], "activationEvents": ["onStartupFinished"]}import type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { if (!ctx.api.cron) { ctx.log.warn('cron permission not granted'); return; }
const job = ctx.api.cron.schedule('daily-digest', '0 9 * * *', async () => { ctx.log.info('running the daily digest'); // … });
ctx.api.disposables.add(job);}The cron permission is enforced, not advisory: without it ctx.api.cron is
undefined and there is nothing to call. In the marketplace the user sees it as
“Scheduled tasks”.
onStartupFinished matters as much as the permission. Registrations are not
persisted anywhere — they exist only while your activated code does — so a job
comes back after a restart only because activate() runs again and registers it
again. An extension that activates lazily on some other event may never register
its job at all.
There is no manifest key for a schedule. Nothing is declarative here.
The interval string
Section titled “The interval string”One parameter, interval, accepting two different formats. The host tries a cron
expression first, then a duration.
A cron expression, five or six fields. Six fields means the first one is seconds:
┌───────────── minute (0–59) │ ┌─────────── hour (0–23) │ │ ┌───────── day of month (1–31) │ │ │ ┌─────── month (1–12 or jan–dec) │ │ │ │ ┌───── day of week (0–7 or sun–sat; 0 and 7 are both Sunday) │ │ │ │ │ 0 9 * * * every day at 09:00| You write | It means |
|---|---|
0 9 * * * |
Every day at 09:00 |
*/30 * * * * |
Every 30 minutes |
0 */6 * * * |
Every 6 hours |
0 9 * * 1 |
Mondays at 09:00 |
0 9 * * mon-fri |
Weekdays at 09:00 |
0 0,12 * * * |
Midnight and midday |
30 8 1 * * |
08:30 on the first of the month |
*/15 * * * * * |
Every 15 seconds (six fields) |
Supported syntax: *, ranges (1-5), lists (1,3,5), steps (*/5 and
1-20/2), and case-insensitive day and month names, long or short.
Not supported: L, W, #, ?, and the @daily / @hourly macros. There is
also no timezone parameter — jobs run in the host machine’s local time.
A duration, matching <number><unit> where the unit is s, m, h, or
d:
ctx.api.cron.schedule('poll', '5m', handler); // every 5 minutesctx.api.cron.schedule('sync', '12h', handler); // every 12 hoursA duration job is a plain repeating timer whose phase starts at registration, so
'5m' means “five minutes after activation, and every five minutes after that”,
not “on the hour and every five minutes”.
What the job runs in
Section titled “What the job runs in”The handler is a plain JavaScript callback in your extension’s main half — never
the renderer, which has no access to cron at all. It closes over the ctx you
were handed at activation, so everything on ctx.api is available inside it:
typed data, secrets, HTTP, notifications, and the AI surface. A job can ask a
model something and act on the answer.
const data = await ctx.api.data.defineSchema(schema);
const job = ctx.api.cron.schedule('overdue-scan', '0 9 * * *', async () => { const overdue = await data.tasks.findMany({ where: { status: q.ne('done'), dueAt: q.lt(new Date()) }, }); if (overdue.length === 0) return; ctx.api.notifications?.show({ title: `${overdue.length} overdue`, body: overdue.slice(0, 3).map((t) => `• ${t.title}`).join('\n'), });});Whatever the handler touches still needs its own permission. The example above
needs notifications as well as cron.
Managing jobs
Section titled “Managing jobs”schedule(id: string, interval: string, handler: () => Promise<void>): CronHandlerunOnce(id: string, handler: () => Promise<void>): Promise<void>cancel(id: string): voidlist(): CronJobInfo[]schedule returns a handle that is disposable:
interface CronHandle { id: string; dispose(): void }Add it to ctx.api.disposables and the host cancels the job when your extension
deactivates or reloads. dispose() is idempotent.
Scheduling an id that already exists silently replaces the previous job. That is what makes a re-registration on reload safe, and it also means a typo in an id gives you two jobs rather than an error.
cancel(id) takes the id you passed to schedule — the plain one, not
handle.id, which carries an internal prefix.
list() returns what the host knows about your jobs:
interface CronJobInfo { id: string; interval: string; lastRun: number | null; nextRun: number; status: 'active' | 'paused' | 'error'; lastError?: string;}Two caveats. status is 'active' until a handler throws and 'error'
afterwards; 'paused' exists in the type but is never produced. And nextRun is
only meaningful for duration-style jobs — for a cron-expression job it stays at
the value it had when the job was registered, so do not build anything on it.
What happens when the app is not running
Section titled “What happens when the app is not running”The tick is lost. There is no catch-up and no missed-run replay for extension
jobs. If a 0 9 * * * job’s window passes while the app is closed, that day’s run
does not happen — not at 09:00, and not at launch. If the app is restarted every
four minutes, a '5m' job never fires at all, because the timer’s phase restarts
with the process.
There is no server-side scheduler for extension work either. Nothing runs in the cloud on your extension’s behalf. A WAMP process has to be alive.
Design around it rather than hoping:
- Make jobs idempotent and keyed on what they have already done, not on the assumption that they ran yesterday.
- Store the last successful run in your own typed data or storage, and have the
handler catch up from there — “process everything since
lastRunAt” survives a week of the app being closed; “process yesterday” does not. - Treat the schedule as approximately how often, never as exactly when.
Failure and concurrency
Section titled “Failure and concurrency”No retries, no backoff. If a handler throws, the host catches it, logs it,
and marks the job 'error' with the message on lastError. The job keeps its
schedule and runs again at the next tick. Nothing is retried sooner, and nothing
is surfaced to the user — there is no toast, no badge, and list() is only
readable from your own main half. If a failure needs to be visible, make it
visible yourself: raise a notification, or record it somewhere your page can
read.
Ticks can overlap. There is no overlap guard on either the cron or the
duration path. A handler that takes eleven minutes on a '5m' schedule will have
a second copy start before the first finishes. If that is a problem — and for
anything that writes, it usually is — hold your own flag:
let running = false;
const job = ctx.api.cron.schedule('sync', '5m', async () => { if (running) { ctx.log.warn('previous sync still running, skipping this tick'); return; } running = true; try { await syncEverything(); } finally { running = false; }});Timeouts. There is none. A handler that never resolves ties up nothing else, but it also never reports and its flag never clears. Put your own deadline around anything that talks to a network.
The alternative: a scheduled agent
Section titled “The alternative: a scheduled agent”The cron API schedules your code. If what you actually want on a schedule is
an agent doing a task, an agent definition can carry its own trigger, and that
declaration lives in a file rather than in memory.
Add a triggers block to the frontmatter of an agent your extension ships in
agents/<id>.md:
---name: Weekly Digestdescription: Summarizes the week's activity and writes it to the digest note.model: balancedtools: [file]triggers: - kind: cron schedule: '0 9 * * 1' timezone: 'Europe/Amsterdam' jitter_sec: 60---
Every Monday, summarize the previous week and append it to `digest.md`.Keep it under 200 words.How this differs from ctx.api.cron:
ctx.api.cron.schedule |
Agent triggers |
|
|---|---|---|
| Runs | Your callback | An agent turn, with its tools |
| Declared in | Code, at activation | A file that ships with your extension |
| Survives restart | Only because activate() re-registers it |
Yes — rebuilt from the definition |
| Cron fields | 5 or 6 | 5 only; a 6-field schedule is rejected with a warning and skipped |
| Timezone | No | Yes, timezone as an IANA name |
| Jitter | No | jitter_sec, 0–300, added randomly to each fire |
| Permission | cron |
None |
| Fires while app is closed | No | No |
kind is mandatory on every trigger entry, and only cron and webhook are
recognized — anything else is warned about and skipped, which means a typo gives
you an agent that quietly never fires. Invalid schedules are skipped the same
way, so check the logs after adding one.
Note that jitter_sec defaults to its maximum of 300 seconds when omitted, not
to zero. Set it explicitly to 0 if you want the fire time to be exact.
See Agents and skills for the rest of the agent definition format.
A complete scheduled job
Section titled “A complete scheduled job”{ "name": "Digest", "version": "1.0.0", "engines": { "wamp": "^12.0.0" }, "description": "Emails a daily summary of open work.", "main": "dist/main.js", "permissions": ["cron", "network", "notifications"], "activationEvents": ["onStartupFinished"]}import type { PluginContext } from '@wamp/extension-sdk';
const LAST_RUN_KEY = 'digest.lastRunAt';
export async function activate(ctx: PluginContext): Promise<void> { if (!ctx.api.cron) { ctx.log.warn('digest: cron permission not granted, nothing scheduled'); return; }
let running = false;
const runDigest = async () => { if (running) return; running = true; try { // Catch up from the last success rather than assuming yesterday ran. const since = (await ctx.api.storage.get<number>(LAST_RUN_KEY)) ?? Date.now() - 86_400_000; const summary = await buildSummary(since); if (summary.itemCount > 0) { ctx.api.notifications?.show({ title: 'Daily digest', body: `${summary.itemCount} items since the last digest.`, }); } await ctx.api.storage.set(LAST_RUN_KEY, Date.now()); } catch (err) { // Nothing retries this, so make the failure visible. ctx.log.error('digest failed', err); ctx.api.notifications?.show({ title: 'Digest failed', body: err instanceof Error ? err.message : String(err), }); } finally { running = false; } };
ctx.api.disposables.add(ctx.api.cron.schedule('digest.daily', '0 9 * * *', runDigest));
// A "run now" command, sharing the same body. ctx.api.disposables.add( ctx.api.commands.register({ id: 'digest.runNow', title: 'Digest: run now', handler: () => ctx.api.cron!.runOnce('digest.manual', runDigest), }), );}Every hard edge on this page is handled in those forty lines: the permission is checked, the job is disposable, overlap is guarded, the catch-up window comes from stored state rather than from an assumption, and the failure is surfaced because nothing else will surface it.
- Typed data — where a job usually reads and writes.
- Agents and skills — the full agent definition format, including triggers.
- Permissions — what else the handler may need declared.