Deuz SDK 2.4 ist da — versioniertes Gedächtnis und ein Kontext-Compiler, ein Goal-Supervisor und dauerhafter Scheduler, ein lokaler Docker-Runner und dauerhafte Chat-Streams. Neu in 2.4
Deuz SDK
Modules

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()));
SyntaxMeaning
*every value
1,15a list
1-5an inclusive range
*/15, 10-50/10every nth value of the field or of a range
5/15from 5 to the field maximum, every 15
JAN–DEC, SUN–SATmonth and weekday names, any case; 7 is also Sunday
@hourly, @daily, @weekly, @monthly, @yearlythe 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:

catchUpRuns
'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); // 4

Durable 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 elsewhere

Any 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
FunctionReturns
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)
TimeUTC onlyUTC or an IANA zone, with DST policies
CursorIn memory; a restart knows only lookbackMsIn the store, moved with the delivery in one transaction
What it startsYour run callback, after claim(key)An event in a goal's inbox, which a goal supervisor applies
A failed runKeeps its claim and is not retriedThe supervisor retries what the native run marks retryable
Several processesSafe only with a durable claimSafe: 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:

StatusWhen
401the verifier rejected the request ({ ok: false, reason })
200dedupe(key) answered false: a redelivery; nothing is dispatched
202dispatch resolved
500verify, 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.

Auf dieser Seite