Monitoring is periodic. Give the schedule a plane to live on.
An agent whose whole value is noticing — a weekly rank check, a daily anomaly sweep, a digest — has nowhere to say "again next week". The durable execution plane runs a job once; it has no verb for time. The recurrence plane adds exactly that verb: a host registers a schedule, the plane fans it out across tenants and enqueues an agent run at-most-once per occurrence, and one authenticated cron route drives all of it. Wire no schedules and your deployment is byte-identical — the table, the plane and the route are all opt-in.
What a schedule is
A schedule is a cadence plus a fan-out plus a terminal. The cadence is a 5-field cron or a fixed everyMs interval (exactly one; declaring both, or neither, throws at registration). The terminal is one of two mutually exclusive shapes, and the type system enforces the choice:
| Terminal | Fan-out | What it does |
|---|---|---|
| agent | bindings (array or resolver) | enqueues a real agent run per binding, through the same audience gate every caller passes. Each binding names a tenant, an owner user, an agent and a prompt. |
| run | tenants (optional) | calls a plain closure. For framework housekeeping that has no user and no agent — the reaper, a session sweep. No SwarmContext, no enqueue gate. |
They are exclusive at the type level on purpose: one shape for both is how the first draft got its context wrong. A run(ctx) closure cannot construct a SwarmContext, so an agent schedule written in the run shape could never reach the enqueue gate at all.
Define one
The cadence is the timer; the bindings are the fan-out. Each binding becomes one occurrence per fire, keyed on (scheduleId, tenantId, bindingId, scheduledFor, kind). Registering the same id again replaces the definition rather than duplicating it.
import type { ScheduleDef } from "@nightowlsdev/runner-background";
// One weekly rank check, fanned out to two tenants. The cadence is the timer;
// the bindings are the fan-out — one occurrence row per (tenant, binding, instant).
export const weeklyRankCheck: ScheduleDef = {
id: "seo.weekly-rank-check", // re-registering this id REPLACES, never duplicates
cron: "0 9 * * 1", // 09:00 every Monday...
timezone: "Europe/London", // ...in THIS zone. A cron with no zone is a DST bug waiting for October.
jitterMs: 30_000, // spread the fan-out so N tenants do not hit one API in the same second
overrun: "skip", // if last week's run is still going, do not start a second
catchUp: "skip-missed", // came up late? fire only if still fresh, never replay a backlog
agent: {
bindings: [
{
bindingId: "acme",
tenantId: "org_acme",
ownerUserId: "user_acme_owner", // the REAL user whose rail the run lands in — no synthetic actor
agentSlug: "seo-researcher",
message: "Run this week's rank check and flag any position drop over 3.",
delivery: { mode: "new_thread" },
},
{
bindingId: "globex",
tenantId: "org_globex",
ownerUserId: "user_globex_owner",
agentSlug: "seo-researcher",
message: "Run this week's rank check and flag any position drop over 3.",
delivery: { mode: "new_thread" },
},
],
},
};bindings can be a resolver () => Promise<ScheduleBinding[]> instead of a static array, which is how multi-tenant fan-out reads its tenant set from the database at tick time. Every resolved set is snapshot-validated on every tick — duplicate (tenantId, bindingId) pairs, empty values, or a reserved params.schedule key fail that schedule's whole tick contribution and claim nothing, rather than letting some bindings fire while others silently collide on one row.
// The OTHER terminal: a plain closure, for framework housekeeping that has no user and no agent.
// A run: schedule gets no SwarmContext and never touches the enqueue gate.
const sessionSweep: ScheduleDef = {
id: "mcp.session-cleanup",
everyMs: 15 * 60_000, // XOR with cron — declaring both, or neither, throws at registration
// Size this ABOVE the closure's worst-case runtime, or the row reconciles to stale-claim
// while the closure is still running and the next occurrence starts beside it (window W3).
staleClaimAfter: "PT2M",
tenants: ["org_acme", "org_globex"], // optional fan-out; omit for one tenantless unit
run: async ({ tenantId, scheduledFor }) => {
await cleanupExpiredSessions(tenantId, scheduledFor);
},
};The occurrence ledger: at-most-once, by claim
A timer that fires once is a wish. Vercel Cron and Trigger schedules can both double-fire, so at-most-once cannot rest on hope — it rests on a row. The plane holds an OccurrenceLedger interface and the host injects an implementation: the shipped Postgres one over storage-supabase migration 0031 (nightowls.schedule_occurrences), or the exported in-memory one for tests and dev.
// The occurrence KEY. Five columns, all in the primary key — the claim IS the insert,
// so the key is the entire at-most-once mechanism.
type OccurrenceKey = {
scheduleId: string;
tenantId: string;
bindingId: string;
scheduledFor: string; // the resolved canonical UTC instant of this fire
kind: "scheduled" | "manual"; // a manual triggerNow at the same instant is a DIFFERENT key
};
// The row walks a state machine, and "open" is not a value — it is the ABSENCE of an outcome:
// scheduled: claimed -> prepared(runId) -> enqueued -> terminal("ok" | "failed" | "skipped")
// run: claimed -------------------------------> terminal
// Every transition is a conditional single statement gated on "outcome IS NULL", returning a
// boolean: did THIS call win? A producer that lost to a stale-reconciling replica stops before
// it enqueues anything. That is the prepare-fence.To claim is to insert the key; the primary key arbitrates. A second insert of the same key — a double-fired cron, a racing replica, the same host's next tick after a crash — hits on conflict do nothing and loses. That is why at-most-once per occurrence key is absolute: no timeline fires one key twice.
The deadline is persisted, not recomputed. The single most load-bearing column is stale_at: written at claim time as claimed_at + the effective staleClaimAfter and compared against thereafter. A stateless, deadline-indexed scan settles orphaned claims — "open rows whose stale_at < now", oldest first — with no cursor and no process state, so a cold-started replica that has never heard of the schedule reconciles exactly what a warm one would, and a 4-hour-override closure can never be settled early by the 1-hour plane default.
Catch-up, overrun, and the windows the plane is honest about
A tick resolves the current occurrence, checks two gates, and either fires or records why it did not. catchUp decides what a late start does: skip-missed (default) is fresh-only — the occurrence fires only while now ≤ due + lateGrace (PT10M by default), so a host that was down for three days does not send a three-day-old digest at breakfast on the fourth. A resume staler than the grace window produces zero fires, not one. A host that wants the stale one asks for it: run-once, or a longer lateGrace. Older-than-current occurrences never fire under either policy.
overrun decides what happens when the previous occurrence is still going: skip (default) or queue. "Still going" is read through the runOutcome oracle, and the counter-intuitive case is the one the reaper-migration proves: a suspended HITL run reads as running. A parked approval ask is alive, and starting a second copy of a weekly sweep while the first waits on a human is exactly what this gate exists to prevent.
Three windows, declared rather than hidden. Per-occurrence-key at-most-once is absolute in all of them; these are the narrow places where per-stream single-activity is bounded, not perfect. W1: a producer paused after prepare and resumed after its row was settled can enqueue one resurrected run — bounded at one per event, narrowed to a double-coincidence by a pre-enqueue re-read, observable. W2: manual triggerNow fires bypass overrun by design. W3: a run: closure that outlives its staleClaimAfter overlaps its successor once per expiry — the reason that horizon is a per-schedule override you size above the closure's worst case.
The run lands in a real user's rail
A scheduled agent run is not special-cased. The plane builds a trusted SwarmContext server-side from the binding — tenantId, userId from ownerUserId, agentSlug, a fresh or existing threadId from delivery, the minted runId, and audiences — and passes it through the same createBackgroundRunner gate every run uses (agents.head + callerMayReach + enqueueRun).
Two consequences follow. There is no synthetic actor: the run belongs to ownerUserId, a real person, because a system:* user's runs appear in nobody's list. And a binding's audiences is enforced, not advisory — omitted means unrestricted (the framework's universal default), and set means this binding can only reach agent heads within that audience. The binding's params ride in context beneath a trusted schedule triple the plane stamps and the binding cannot forge.
Wire the host cron
The plane is a pure function of its injected clock and ledger; it carries no timer. A platform cron drives it over one authenticated route, and because the plane fans out internally, a host stops needing one cron entry per periodic feature.
import {
createSchedulePlane,
createPgOccurrenceLedger,
createRunOutcome,
reaperSchedule,
type EnqueueAgentRun,
} from "@nightowlsdev/runner-background";
import { createScheduleHandler } from "@nightowlsdev/runner-nextjs";
import type { RunStatus } from "@nightowlsdev/core";
// Host-owned. `enqueueAgentRun` is your background runner's own enqueue seam:
// const enqueueAgentRun = (input, ctx) => runner.enqueue(input, ctx);
declare const pool: import("pg").Pool;
declare const enqueueAgentRun: EnqueueAgentRun;
declare const storage: { runs: { get(tenantId: string, runId: string): Promise<{ status: RunStatus } | null> } };
declare const reap: () => Promise<number>;
declare const cronSecret: string;
// The plane has ZERO runtime deps: the ledger, the enqueue seam and the run-outcome oracle
// are all injected. There is no timer in here — the host drives ticks from the route below.
const plane = createSchedulePlane({
ledger: createPgOccurrenceLedger({ pool }), // over storage-supabase migration 0031
enqueueAgentRun,
runOutcome: createRunOutcome(storage), // maps run status; suspended reads as "running"
schedules: [reaperSchedule({ reap })], // the reaper, migrated onto the plane
});
// ONE ROUTE, EVERY SCHEDULE. Point Vercel Cron / GitHub Actions / any pinger at this GET.
export const { GET } = createScheduleHandler({ tick: () => plane.tick(), cronSecret });Set the cronSecret in production. Unlike the reaper route — which is safe open because its sweep only ever fails already-stalled runs — this endpoint starts real work: an unauthenticated caller can drive agent runs, spend model budget and hit rate-limited third-party APIs. With cronSecret set, the request must carry Authorization: Bearer <secret>; the response is no-store so a cached 200 never fools a cron into believing it ticked.
reaperSchedule is the first of the framework's cron-delegating features to move onto the plane, and the reason it comes first: it is in-package, idempotent, and already ships both a route and an interval, so migrating it exercises the run: terminal at zero blast radius. A host that wires no schedules keeps startReaper behaving byte-identically.
Fire one now
triggerNow is the explicit operator re-fire, for testing and for "run it now". It targets a whole schedule, one tenant's units, or a single binding (a bindingId without a tenantId throws — a binding is only unique within a tenant).
// The explicit operator re-fire. Bypasses quiescence, enabled, catch-up, overrun and throttle,
// so N invocations are N runs BY DESIGN (window W2) — but it still claims, so a same-instant
// double loses one claim. It also reaches DISABLED schedules and bindings.
await plane.triggerNow({ scheduleId: "seo.weekly-rank-check" }); // all current units
await plane.triggerNow({ scheduleId: "seo.weekly-rank-check", tenantId: "org_acme" }); // one tenant's units
// -> { fired, failed, duplicate }A manual fire claims with kind: "manual" and scheduledFor set to the invocation instant, so it is a distinct key from any scheduled fire at the same moment and both stay separately attributable in the ledger. Being an override, it reaches disabled schedules and disabled bindings.
API reference
- ScheduleDef: the definition, with its two mutually-exclusive terminals (agent / run) and its per-schedule overrun, catchUp, lateGrace and staleClaimAfter knobs. From @nightowlsdev/runner-background.
- createSchedulePlane(opts) returns { tick, triggerNow, register, listSchedules }. Zero runtime deps — ledger, enqueue seam, run-outcome oracle, clock and rate limit are injected. runScheduleTick is the one-shot sugar.
- OccurrenceLedger + createInMemoryOccurrenceLedger (tests/dev) + createPgOccurrenceLedger({ pool }) over migration 0031.
- createRunOutcome(source) / mapRunStatus: the canonical run-status mapping, with running and suspended both reading as "running".
- reaperSchedule({ reap }): the stuck-run watchdog as a run: schedule — the reference for the one-cron-route pattern.
- createScheduleHandler({ tick, cronSecret }) from @nightowlsdev/runner-nextjs returns { GET }: Bearer-authenticated, no-store, returns the tick's counters.
By design, not shipped as a table. Bindings are code- or resolver-registered, not rows — schedule_occurrences is the only table FR-057 creates, and it is the ledger, not a schedule store. The plane leaves DurableBackend untouched: three vendor schedulers cannot yield one DST / catch-up / at-most-once semantic, so the timer stays above the backend seam.
Where to go next
The SEO crew
The monitoring agent this plane exists to make shippable: a weekly rank check that schedules itself instead of asking the adopter to wire a cron.
Approval modes
Why a suspended HITL run counts as alive, and blocks its own scheduled successor until a human answers.
Audiences
What a binding's audiences restricts, and why omitting it means unrestricted.
Building on Night Owls? See the source on GitHub.