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 orgScope — 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. Its orgScope is the second axis and works the same way: omitted means org-level, so the run mints a NULL-scoped thread that every scope in the tenant can read — set it to mint the thread inside a scope instead. It applies to new_thread delivery only, and is rejected at validation alongside existing_thread, where the target thread's own scope is already authoritative. 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.