Schedules and signals
Five-field cron in UTC or an IANA time zone, a tick-driven scheduler with dedupe and catch-up, verified GitHub, Slack and HMAC webhooks, and when to use the durable scheduler instead.
Die Dokumentationsseiten selbst sind auf Englisch. Navigation, Suche und UI-Texte folgen der gewählten Sprache.
@deuz-sdk/core/schedule starts work from two kinds of outside events: time
(cron schedules) and inbound webhooks (signals). It is edge-safe. The scheduler
never reads the host clock on its own; you pass the time, or it reads the
injected deps.clock. Signature checks use WebCrypto (crypto.subtle), so they
run on Workers, Vercel Edge, Deno and Node alike.
Cron expressions
parseCron reads the standard five fields — minute, hour, day of month, month,
day of week — and evaluates them in UTC.
import { nextOccurrence, occurrencesBetween, parseCron } from '@deuz-sdk/core/schedule';
const weekdays = parseCron('0 9 * * MON-FRI');
console.log(weekdays.daysOfWeek); // [1, 2, 3, 4, 5]
const from = Date.parse('2026-01-03T12:00:00Z'); // a Saturday
console.log(new Date(nextOccurrence(weekdays, from)).toISOString());
// 2026-01-05T09:00:00.000Z
// The window excludes `from` and includes `to`, so back-to-back windows never overlap.
const hours = occurrencesBetween('0 */6 * * *', from, from + 86_400_000);
console.log(hours.map((ms) => new Date(ms).toISOString()));| Syntax | Meaning |
|---|---|
* | every value |
1,15 | a list |
1-5 | an inclusive range |
*/15, 10-50/10 | every nth value of the field or of a range |
5/15 | from 5 to the field maximum, every 15 |
JAN–DEC, SUN–SAT | month and weekday names, any case; 7 is also Sunday |
@hourly, @daily, @weekly, @monthly, @yearly | the usual macros (@midnight, @annually too) |
When both day fields are restricted, a day matches if either one does:
0 0 13 * FRI fires on every 13th and every Friday. A day field that starts
with * (including */2) counts as unrestricted, and then both must match —
the Vixie cron rule. Expressions that can never fire, such as 0 0 30 2 *, are
rejected when parsed. previousOccurrence(cron, beforeMs) walks backwards.
Run schedules from a host cron trigger
createScheduler turns schedules into runs. Each firing is an occurrence with
a key ${id}@${at} (at is the scheduled minute in epoch milliseconds), and a
run happens only after claim(key) resolves true. Call tick() from your
platform's cron trigger:
import { generateText } from '@deuz-sdk/core';
import { createScheduler } from '@deuz-sdk/core/schedule';
import { createMockModel } from '@deuz-sdk/core/testing';
const model = createMockModel({ responses: [{ text: 'Three PRs merged overnight.' }] });
const scheduler = createScheduler({
schedules: [
{
id: 'morning-digest',
cron: '0 9 * * MON-FRI',
async run(occurrence) {
const { text } = await generateText({ model, prompt: 'Summarise the night.' });
console.log(occurrence.key, text);
},
},
],
});
// e.g. a Vercel cron route or a Cloudflare `scheduled` handler that fires every minute
const result = await scheduler.tick(Date.parse('2026-01-05T09:00:20Z'));
for (const occurrence of result.occurrences) {
console.log(occurrence.id, occurrence.status); // morning-digest ran
}tick never throws for your code: a failing run or claim comes back as
{ status: 'failed', phase: 'run' | 'claim', error } for that occurrence, and
the others still run. A claim that answers false gives status: 'duplicate'.
A failed run keeps its claim, so no tick or process runs that occurrence
again: a run may have done part of its work before it threw. To let another
process retry it, give the key back yourself in the run's catch with
claim.release(occurrence.key); createInMemoryClaim() and the
durable claims have release.
Each tick evaluates the window (from, now]. The first tick of a scheduler
looks back lookbackMs (default 60 000 ms, one cron minute, so a trigger that
fires a few seconds late still catches its minute). Every later tick starts
where the previous one ended.
Catch-up after downtime
When a window holds several due occurrences of one schedule — the process was
down, or a tick ran late — catchUp decides what runs:
catchUp | Runs |
|---|---|
'latest' (default) | only the newest due occurrence |
'all' | every due occurrence, oldest first, keeping the newest maxCatchUp (default 100) |
'none' | the newest one only if it is at most graceMs old (default 60 000; start uses its interval if larger) |
A restarted process knows only its lookback window. To recover occurrences
missed while it was down, raise lookbackMs and use a durable claim, so
occurrences that already ran are reported as duplicates.
import { createInMemoryClaim, createScheduler } from '@deuz-sdk/core/schedule';
const claim = createInMemoryClaim(); // stands in for a durable claim, such as ops.claims
const ran: string[] = [];
const schedules = [
{ id: 'hourly', cron: '0 * * * *', run: (o: { key: string }) => void ran.push(o.key) },
];
const before = createScheduler({ schedules, claim, catchUp: 'all', lookbackMs: 3 * 3_600_000 });
await before.tick(Date.parse('2026-01-01T02:30:00Z')); // runs 00:00, 01:00, 02:00
// The process restarts; the new scheduler looks back over the same hours.
const after = createScheduler({ schedules, claim, catchUp: 'all', lookbackMs: 3 * 3_600_000 });
const result = await after.tick(Date.parse('2026-01-01T03:10:00Z'));
console.log(result.occurrences.map((o) => o.status)); // duplicates, then 03:00 ran
console.log(ran.length); // 4Durable claims from the ops stores
The default claim is an in-memory set: it dedupes overlapping ticks inside one
process, not across processes or isolates. On a serverless host every tick may
run in a fresh isolate, so give the scheduler a claim backed by storage all of
them share. The SQLite and Postgres ops stores carry one, and since 2.3 so does
the Redis ops store (createRedisOpsStore from @deuz-sdk/core/ops/redis):
claims grants a key once across every process on the same file, database or
Redis key prefix, and claims.release(key) gives it back. On SQLite and
Postgres the grant is one statement (INSERT … ON CONFLICT DO NOTHING RETURNING: the key is granted when the row comes back, so an injected SQLite
driver need not report a change count); on Redis it is one Lua script. On a Postgres session that defaults to
REPEATABLE READ or SERIALIZABLE, the loser of a race meets a serialization
failure (40001); the store runs the statement again, and it answers false.
import { createScheduler } from '@deuz-sdk/core/schedule';
import { createSqliteOpsStore } from '@deuz-sdk/core/ops/sqlite';
// Every process opens the same file; createPostgresOpsStore({ client: pool })
// from '@deuz-sdk/core/ops/postgres' does the same on a shared database.
const ops = createSqliteOpsStore({ path: 'ops.sqlite' });
const scheduler = createScheduler({
schedules: [{ id: 'nightly-sync', cron: '0 2 * * *', run: (o) => console.log('sync', o.key) }],
claim: ops.claims,
// Recover what a restart missed; occurrences that already ran are duplicates.
catchUp: 'all',
lookbackMs: 6 * 3_600_000,
});
const result = await scheduler.tick(Date.parse('2026-01-05T02:00:20Z'));
console.log(result.occurrences.map((o) => o.status)); // ['ran'], then 'duplicate' elsewhere
await ops.close();The same ops.claims is a dedupe for handleSignal, below.
Claimed keys accumulate
deuz_claims keeps one row per granted key (key, and claimed_at in epoch milliseconds: the
store's clock on SQLite, the database clock on Postgres). A key longer than 512 characters (too
long for a Postgres index) or holding a NUL (which Postgres text rejects) is kept as sha256:
plus its hex SHA-256 on both stores, and still claims and releases exactly. release removes a
row, and since 2.3 so does claims.prune(before), which deletes the keys granted before before
and returns how many. A pruned key can be claimed again, so prune on your own schedule and keep
every key a tick could still look back to (lookbackMs) and every delivery id a sender could
still redeliver: await ops.claims.prune(cutoff). The Redis ops store keeps its keys in one
sorted set, by the server time they were granted, and prunes them the same way. See
Retention.
Dedupe with a swarm run
A durable run store is a claim too: use the occurrence key as the run id, and treat the store's create conflict as "already claimed".
import { createAgent } from '@deuz-sdk/core/agent';
import { createScheduler } from '@deuz-sdk/core/schedule';
import type { ScheduleDefinition } from '@deuz-sdk/core/schedule';
import { createInMemorySwarmStore, createSwarm, SwarmConflictError } from '@deuz-sdk/core/swarm';
import { createMockModel } from '@deuz-sdk/core/testing';
const writer = createAgent({ model: createMockModel({ responses: [{ text: 'report' }] }) });
const swarm = createSwarm({
agents: { writer: { agent: writer, version: 'writer-v1' } },
store: createInMemorySwarmStore(), // shared by every process: SQLite, Postgres or Redis in production
});
const schedules: ScheduleDefinition[] = [
{
id: 'weekly-report',
cron: '0 8 * * MON',
async run(occurrence) {
try {
const handle = await swarm.run({
scope: 'reports',
runId: occurrence.key, // the create fails if any process already started it
tasks: [{ id: 'write', agent: 'writer', prompt: 'Write the weekly report.' }],
});
const outcome = await handle.result;
console.log(occurrence.key, outcome.run.status);
} catch (error) {
if (!(error instanceof SwarmConflictError)) throw error;
console.log(occurrence.key, 'already claimed elsewhere');
}
},
},
];
// Two processes tick the same minute. The swarm store is the claim, so each
// scheduler's own claim can simply say yes.
for (const name of ['a', 'b']) {
const scheduler = createScheduler({ schedules, claim: () => true });
await scheduler.tick(Date.parse('2026-01-05T08:00:30Z'));
console.log('process', name, 'done');
}
// weekly-report@1767600000000 completed, then: already claimed elsewhereAny storage with an atomic "insert if absent" works the same way as a claim:
a unique-key insert in SQL (what ops.claims is), a KV put with a
precondition, or a durable run store's create. Give such a function a
release(key) that deletes the key if you want handleSignal to free it when a
dispatch fails. Whatever release returns is awaited and ignored, so
Object.assign(claim, { release: (key) => redis.del(key) }) fits as written.
Long-running processes
In a Node worker, start ticks immediately and then every intervalMs on
deps.clock.setTimeout, until the signal aborts. The returned promise settles
after the in-flight tick finishes.
import { createScheduler } from '@deuz-sdk/core/schedule';
const scheduler = createScheduler({
schedules: [{ id: 'heartbeat', cron: '* * * * *', run: (o) => console.log('beat', o.at) }],
});
const controller = new AbortController();
const stopped = scheduler.start({
intervalMs: 30_000,
signal: controller.signal,
onTick: (result) => console.log(result.occurrences.length, 'occurrences'),
});
// on shutdown
controller.abort();
await stopped;Time zones and the durable scheduler (2.4)
createScheduler and the cron functions above stay UTC-only. 2.4 adds
zone-aware cron reading to this subpath, with no dependency (zones come from
Intl) and the same results whatever the host's own time zone is:
import { nextZonedOccurrence, timeZoneOffset, zonedOccurrencesBetween } from '@deuz-sdk/core/schedule';
const newYork = { timezone: 'America/New_York' };
const iso = (ms: number) => new Date(ms).toISOString();
// 2026-03-08: New York skips 02:00-03:00. 'shift' (default) runs 02:30 at 03:00 EDT.
const spring = zonedOccurrencesBetween('30 2 * * *', Date.parse('2026-03-07T12:00:00Z'), Date.parse('2026-03-09T12:00:00Z'), newYork);
console.log(spring.map((o) => [iso(o.at), o.shifted ?? false]));
// [ [ '2026-03-08T07:00:00.000Z', true ], [ '2026-03-09T06:30:00.000Z', false ] ]
// 2026-11-01: 01:00-02:00 happens twice. 'both' runs 01:30 at each instant.
const fall = zonedOccurrencesBetween('30 1 1 11 *', Date.parse('2026-10-31T12:00:00Z'), Date.parse('2026-11-01T12:00:00Z'), { ...newYork, dstOverlap: 'both' });
console.log(fall.map((o) => [iso(o.at), o.repeated ?? false]));
// [ [ '2026-11-01T05:30:00.000Z', false ], [ '2026-11-01T06:30:00.000Z', true ] ]
console.log(iso(nextZonedOccurrence('0 9 * * MON-FRI', Date.parse('2026-10-16T12:00:00Z'), { timezone: 'Europe/Istanbul' })));
// 2026-10-19T06:00:00.000Z (09:00 in Istanbul, UTC+3, no DST)
console.log(timeZoneOffset('America/New_York', Date.parse('2026-07-01T00:00:00Z')) / 3_600_000); // -4| Function | Returns |
|---|---|
zonedOccurrencesBetween(cron, fromMs, toMs, options?, limit = 1000) | { at, wall, shifted?, repeated? } for each UTC instant in (fromMs, toMs], earliest first; wall is the matched local time read as UTC |
nextZonedOccurrence(cron, afterMs, options?) | The first UTC instant strictly after afterMs |
timeZoneOffset(zone, atMs) | The zone's wall clock minus UTC at that instant, in ms |
assertTimeZone(zone) | The zone, or a TypeError for an unknown one |
options takes timezone (default 'UTC'), dstGap ('shift', the default,
or 'skip') and dstOverlap ('first', the default, or 'both'); see
Time zones and DST.
The durable scheduler in
@deuz-sdk/core/goal/runtime
uses these rules. It is a separate API, and createScheduler does not change:
createScheduler (here) | createDurableScheduler (/goal/runtime) | |
|---|---|---|
| Time | UTC only | UTC or an IANA zone, with DST policies |
| Cursor | In memory; a restart knows only lookbackMs | In the store, moved with the delivery in one transaction |
| What it starts | Your run callback, after claim(key) | An event in a goal's inbox, which a goal supervisor applies |
| A failed run | Keeps its claim and is not retried | The supervisor retries what the native run marks retryable |
| Several processes | Safe only with a durable claim | Safe: each occurrence is delivered once |
Use createScheduler for a cron trigger that calls a function, such as a
digest or a retention pass. Use
createDurableScheduler when the work is a goal that must survive restarts,
must not run twice and should be retried.
Verify webhooks
Each verifier reads the request body once and answers
{ ok: true, body } or { ok: false, reason }, where reason is one of
missing-signature, malformed-signature, bad-signature,
missing-timestamp, malformed-timestamp, stale-timestamp or body-used.
Signatures are compared in constant time.
import {
verifyGitHubWebhook,
verifyHmacSignature,
verifySlackRequest,
} from '@deuz-sdk/core/schedule';
// GitHub: X-Hub-Signature-256 = "sha256=" + hex HMAC-SHA256 of the raw body.
const github = await verifyGitHubWebhook(
new Request('https://example.com/hooks/github', {
method: 'POST',
body: 'Hello, World!',
headers: {
'x-hub-signature-256':
'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17',
},
}),
"It's a Secret to Everybody",
);
console.log(github); // { ok: true, body: 'Hello, World!' }
// Slack: v0 signature over "v0:{timestamp}:{body}", with a replay window.
// `now` is required: pass your clock's time (edge code never reads the host clock).
const slack = await verifySlackRequest(
new Request('https://example.com/slack', { method: 'POST', body: 'text=hi' }),
'signing-secret',
{ now: Date.parse('2026-01-05T09:00:00Z'), toleranceSeconds: 300 },
);
console.log(slack); // { ok: false, reason: 'missing-timestamp' }
// Anything else signed with an HMAC: pick the algorithm, encoding and prefix.
const custom = await verifyHmacSignature({
body: '{"event":"paid"}',
signature: 'sha256=not-a-real-signature',
secret: 'whsec',
algorithm: 'SHA-256',
encoding: 'hex',
prefix: 'sha256=',
});
console.log(custom); // { ok: false, reason: 'malformed-signature' }Turn a signal into work
handleSignal verifies, dedupes and dispatches one request, and answers with
the status the sender expects:
| Status | When |
|---|---|
401 | the verifier rejected the request ({ ok: false, reason }) |
200 | dedupe(key) answered false: a redelivery; nothing is dispatched |
202 | dispatch resolved |
500 | verify, key, dedupe or dispatch threw; a failed dispatch first frees the key |
The key defaults to sha256: plus the hex SHA-256 of the body; derive a better
one from the sender's delivery id when there is one.
import { createInMemoryClaim, handleSignal, verifyGitHubWebhook } from '@deuz-sdk/core/schedule';
// One process only; across instances use a durable claim such as ops.claims.
const dedupe = createInMemoryClaim();
export async function POST(request: Request): Promise<Response> {
return handleSignal(request, {
verify: (r) => verifyGitHubWebhook(r, 'webhook-secret'),
key: ({ request: r }) => r.headers.get('x-github-delivery') ?? undefined,
dedupe,
async dispatch({ body, key }) {
const event = JSON.parse(body) as { action?: string };
console.log('start run', key, event.action);
},
});
}Dedupe happens before dispatch
The key is claimed before dispatch runs. When dispatch throws, handleSignal gives the key
back through the claim's release and answers 500, so the sender's retry dispatches again.
createInMemoryClaim() and the ops stores' durable claims have release; a claim that is a
plain function does not, and then the retry is answered as a duplicate. A dispatch that threw
after starting work may see that work again on the retry, so keep dispatch short — enqueue the
work, or start a durable run whose run id is the key — or skip dedupe and let an idempotent
dispatch (a swarm run with runId: key, ignoring SwarmConflictError) absorb redeliveries.
Budgets
Persistent token and USD budgets per user, thread or organisation, shared by every run and process, with rolling windows, warnings, hold recovery and retention.
Goals
Versioned goal records with acceptance criteria and evidence, explicit waiting reasons, budgets and deadlines, and wakeup and event envelopes, moved forward by pure transitions.