Skip to main content

Scheduled actions

@flow-state-dev/scheduled adds a dispatch endpoint that lets an external scheduler invoke a flow action on a schedule. The framework owns the dispatch contract, validation, two-phase auth, and provenance. The host owns the actual scheduler (Vercel Cron, Cloud Scheduler, EventBridge, GitHub Actions, node-cron) and any storage that backs dynamic schedules.

There are two kinds of schedules:

  • Static — declared in flow source as a typed map. Useful for framework-level cron jobs like a daily digest or a nightly cleanup.
  • Dynamic — looked up at dispatch time by a resolver hook. Useful for per-user reminders, per-record alerts, and follow-ups an agent decides to schedule at runtime. Definitions live in a host-owned store (a flow-state resource collection, a database table, an external service); the framework never owns schedule storage.

A flow can ship both at once.

Install​

pnpm add @flow-state-dev/scheduled

Mounting the adapter​

The scheduled adapter is just another entry in adapters on createFlowState, the canonical setup entrypoint:

lib/flowstate.ts
import { createFlowState, inMemoryStores } from "@flow-state-dev/engine";
import { createScheduledTransportAdapter } from "@flow-state-dev/scheduled";

export const flowstate = createFlowState({
flows: { billing: billingFlow },
stores: { default: { primary: inMemoryStores() } },
adapters: [createScheduledTransportAdapter()],
});

Turn the handle into route handlers with a platform handler — the dispatch endpoint mounts under the same router:

app/api/flows/[...path]/route.ts
import { flowstate } from "@/lib/flowstate";
import { createVercelNextHandler } from "@flow-state-dev/vercel/next";

export const { GET, POST, PATCH, DELETE } = createVercelNextHandler(flowstate);

Mounted alongside any other adapters. The HTTP adapter is always mounted; MCP, webhooks, and scheduled coexist on the same router.

Static schedules​

A static schedule is a record on the flow's schedules.static map. Each entry carries its handler inline (the shared action core) instead of naming an entry in flow.actions. Same model the webhook transport uses. Cron strings and input shapes are validated when the flow is registered, so a malformed cron or a bad input shape surfaces at boot, not at dispatch.

import { defineFlow, defineScheduleBinding } from "@flow-state-dev/core";

defineFlow({
kind: "billing",
schedules: {
static: {
"monthly-invoices": defineScheduleBinding({
cron: "0 0 1 * *",
block: generateMonthlyInvoices,
description: "First of the month, 00:00 UTC"
})
}
}
});

defineScheduleBinding is the schedule sibling of defineWebhookBinding. It's a compile-time convenience — a plain object literal works too. A schedule handler lives only on schedules.static, so it has no HTTP or MCP caller surface; declare a block in both schedules.static and flow.actions (same reference) if you want it reachable both ways.

Cron strings are POSIX 5-field: minute hour day-of-month month day-of-week. The framework validates the syntax but does not run the schedule. The host's scheduler decides when to fire and POSTs the dispatch endpoint at the right time.

Dynamic schedules​

A dynamic schedule is resolved at dispatch time by the schedules.resolve hook. The hook receives the schedule id from the URL and returns a ScheduleConfig (or null to 404 the dispatch). The config carries the handler block inline, the same as a static entry.

A block can't be serialized into a stored row, so the persisted record keeps a kind discriminator string instead. The resolver maps that kind back to a real block. The createResourceCollectionScheduleResolver helper takes a blocks map for exactly this:

import { createResourceCollectionScheduleResolver } from "@flow-state-dev/scheduled";

defineFlow({
kind: "reminders",
schedules: {
resolve: createResourceCollectionScheduleResolver({
collection: schedulesCollection,
blocks: { sendDigest, sendReminder } // persisted `kind` → block
})
}
});

Writing the resolver by hand follows the same shape: read the row, look the block up by its kind, and return the config. An unknown kind returns null (404).

resolve: async (scheduleId, ctx) => {
const row = await db.schedules.findById(scheduleId);
if (!row || !row.enabled) return null;
const block = blocks[row.kind];
if (!block) return null;
return {
cron: row.cron,
block,
input: row.input,
principal: { userId: row.userId, orgId: row.orgId }
};
}

The host backs the resolver with whatever store fits. For schedules held in a flow-state resource collection, the dynamic schedules guide covers the reference helper and end-to-end wiring. For SQL or external services, write the resolver directly.

Schedules in a user's data​

A schedule saved in a user-scoped collection belongs to a user inside one organization. Use createResourceCollectionScheduleResolver there. Its schedule id names the organization, the user and the key, <orgId>/<userId>/<key>, and formatScheduleId(orgId, userId, key) builds one. Encode the whole id as one path segment: encodeURIComponent(formatScheduleId(orgId, userId, key)). It reads the row from that user's data in that organization, and returns null when the id names no organization or the row names a different one. On a hired worker only one member can reach, it also returns null for an id naming another user.

If you write the resolver by hand, pass the organization to resolveUserStorageKey from @flow-state-dev/engine. It throws when the organization is missing or blank. defaultParseScheduleId splits the id the same way the helper does, and keeps a key that contains / whole:

import { resolveUserStorageKey } from "@flow-state-dev/engine";
import { defaultParseScheduleId } from "@flow-state-dev/scheduled";

resolve: async (scheduleId, ctx) => {
const parsed = defaultParseScheduleId(scheduleId); // { orgId, userId, collectionKey }, or null
if (parsed === null) return null;
const scopeId = resolveUserStorageKey(parsed.userId, parsed.orgId, { id: ctx.flowKind, isolateUserState: false });
const row = await ctx.stores.resourceState.get("user", scopeId, `schedules/${parsed.collectionKey}`);
if (row?.state.orgId !== parsed.orgId) return null;
// map its `kind` to a block, return the config as above
}

isolateUserState: false is right for a schedule collection declared without flowIsolation: true, which is the default.

Durable dynamic schedules don't recover across crashes​

A dynamic schedule's action core is produced by the resolver at dispatch time and carried on the dispatch envelope. It's never persisted — a block can't be serialized, and the stored row holds only the kind discriminator. So if a durable dynamic schedule is mid-run when the process crashes, there's no persisted coordinate to re-resolve the handler from, and the run is not recovered.

Static schedules don't have this problem. Their handler is reachable from a stable coordinate (schedules.static[scheduleId]), so a durable static schedule recovers normally. If you need crash recovery for a durable scheduled action, make it static.

Effective principal​

A scheduled action runs as the principal returned by the schedule, not as the caller that hit the dispatch endpoint. Two principals are at play:

  • Gateway principal — established by authentication.resolvePrincipal when the dispatch endpoint is hit. Proves the caller is the trusted scheduler. Typically a system principal like { userId: "system" }.
  • Schedule principal — schedule.principal on the resolved config. The target user the action runs as. Per-user reminders set this to the owning user; static framework-level schedules usually omit it.

The runtime uses schedule.principal ?? gatewayPrincipal. So a dynamic resolver that returns { principal: { userId: "u_abc" } } runs the action as u_abc, even though the dispatch was authenticated as the system scheduler.

The organization a schedule fires into​

A principal names a user and an organization, and the two fall back separately.

A static schedule may name a target user and leave the organization to the gateway. Its principal is written in your flow definition, so it is your own trusted config rather than stored data.

A dynamic schedule fires into the organization it was created under. That value lives on the stored row, so read it off the row instead of letting it fall back:

principal: { userId: row.userId, orgId: row.orgId }

Omit it and the action lands in whichever organization fired the beat, which is rarely the one the schedule belongs to.

createResourceCollectionScheduleResolver reads it for you and refuses to go without it. A collection made with defineScheduleCollection (from @flow-state-dev/scheduled) records it for you. Its create stores the organization the creating run belongs to, so you pass only { cron, kind, enabled }. A stored schedule with no organization makes the resolver return null, so the dispatch 404s and the schedule stays put until an operator attributes it. See Which organization a record belongs to for the attribution recipe.

The dispatch endpoint​

POST /api/flows/:flowKind/schedules/:scheduleId/dispatch

The body is optional. It carries one field, the scheduled time this fire is for:

{
"nominalFireTime": "2026-06-01T09:00:00Z"
}

Send the time your scheduler fired for, or send no body at all. Without a time, the fire counts against the latest scheduled time that is already due. Either way the endpoint fires each scheduled time at most once (see At most once per scheduled time).

Response codes:

CodeMeaning
202Accepted: the action was dispatched
200duplicate (that scheduled time already fired, or a later one did) or skipped (overlap policy)
400Schedule id pattern invalid, resolved schedule malformed, nominalFireTime not a scheduled time of the cron (not_a_scheduled_time) or not due yet (not_yet_due), or the body carries idempotencyKey (idempotency_key_removed)
401Gateway auth failed
404Flow or schedule not found
405Wrong method (only POST is supported)
500Resolver threw, or input function threw
503Flow unregistered between resolution and dispatch

The endpoint is fire-and-forget. A 202 means the action started; it does not mean it finished. Action errors after dispatch land on the RequestRecord and surface in DevTool, not in the dispatch response body. The action field on the request record is the handler block's name, recorded for provenance — a scheduled handler is never reachable through the action endpoint.

Example call:

curl -X POST https://app.example.com/api/flows/billing/schedules/monthly-invoices/dispatch \
-H "Authorization: Bearer ${FSDEV_SCHEDULER_SECRET}" \
-H "Content-Type: application/json" \
-d '{"nominalFireTime":"2026-06-01T00:00:00Z"}'

For a dynamic schedule the URL carries the resolver-defined id. The default resource-collection helper uses <orgId>/<userId>/<key>. Encode the whole id as one path segment: encodeURIComponent(formatScheduleId(orgId, userId, key)).

curl -X POST https://app.example.com/api/flows/reminders/schedules/acme%2Fu_abc%2Fweekly-digest/dispatch \
-H "Authorization: Bearer ${FSDEV_SCHEDULER_SECRET}" \
-H "Content-Type: application/json" \
-d '{"nominalFireTime":"2026-06-01T09:00:00Z"}'

Authenticating dispatch​

The dispatch endpoint goes through host.resolvePrincipal like every other inbound route. The canonical pattern is a shared bearer secret between the host scheduler and the dispatch endpoint. createBearerSecretPrincipalResolver, exported from @flow-state-dev/engine, does the constant-time comparison.

import { createBearerSecretPrincipalResolver } from "@flow-state-dev/engine";

defineFlow({
kind: "billing",
authentication: {
resolvePrincipal: createBearerSecretPrincipalResolver({
secret: process.env.FSDEV_SCHEDULER_SECRET!,
principal: { userId: "system" }
}),
requireUser: true
},
schedules: {
static: {
"monthly-invoices": { cron: "0 0 1 * *", block: generateMonthlyInvoices }
}
}
});

Most flows that adopt scheduling already have a resolvePrincipal that handles HTTP traffic (JWT verification, session lookup). Branch on ctx.source instead of replacing the existing resolver:

authentication: {
resolvePrincipal: async (ctx) => {
if (ctx.source === "scheduled") {
return verifyScheduleSecret(ctx);
}
return verifyJwtFromHeader(ctx);
}
}

Replacing the existing resolver with a scheduler-only one breaks HTTP routes. Branching on ctx.source keeps both paths working.

Overlap policy​

onOverlap controls what happens when a previous run of the same schedule id is still in flight:

  • "skip" (default) — return 200 with { status: "skipped", reason: "in_flight" }, do not dispatch.
  • "allow" — dispatch concurrently; new request runs alongside the existing one.

onOverlap is the scheduled-specific spelling of a concurrency policy keyed on the schedule id: "skip" is reject, "allow" is allow.

Skip is best-effort. If two ticks arrive within a few milliseconds of each other, both can pass the in-flight check before either calls host.dispatch. When both are for the same scheduled time, only one of them fires (next section).

At most once per scheduled time​

Every schedule fires each scheduled time at most once, however many times that time is POSTed: a retry, a second host behind the same cron, or a process that restarted between two deliveries. The endpoint keeps one record per schedule in the host's store, the latest time it fired, and checks it before the schedule's handler runs.

Which time a fire counts against:

  • With nominalFireTime, the scheduled time it names. A time up to five seconds off still lands on the scheduled time it is nearest, so a host clock a second early is fine. A time that is no scheduled time of the cron gets 400 not_a_scheduled_time, and a scheduled time that hasn't arrived yet gets 400 not_yet_due.
  • Without it, the latest scheduled time already due, or the one due within the next five seconds, so a sender whose clock runs a second ahead of the app's still lands on the time it is for. Never a time further ahead, so an early or manual POST can't take a time before it arrives.

A fire for a time that already fired, or for a time earlier than one that did, answers 200 { status: "duplicate", scheduleId, nominalFireTime } and runs nothing. That includes a late retry of a missed time once the next time has fired. A manual POST with no body runs only if the latest due time hasn't fired yet.

A carried time more than one period behind the latest due time answers 200 { status: "duplicate", reason: "stale" }, runs nothing, and logs a warning on the app's server. That is what a scheduler body with a fixed nominalFireTime gets once its time has passed: if a schedule stops firing, look for that warning, and send no body instead.

A fire that isn't admitted (an overlap skip, a concurrency refusal, a 503) leaves its time unfired, so the host's retry of it still runs, unless a later time fired in the meantime.

The record is a floor for the schedule whatever its cron: after you change a schedule's cron or time zone, a time at or before the last one it fired still answers duplicate.

The cron must be exactly five fields (minute hour day-of-month month day-of-week). A sixth field would be read as seconds, so a schedule with one is refused: 400 invalid_schedule at dispatch, and an error at registration for a static one. So is a timezone that isn't an IANA zone, such as a typo in "Europe/London".

One case the record can't cover: two independent schedulers firing the same time, where the first is answered 503 after the second was already told duplicate. The first gives its time back, the second never retries, and that time doesn't run unless the first's retry does.

The guarantee holds across processes on a store with compare-and-set across processes: SQLite and Postgres. On the memory store, and on the filesystem store, it holds within one process only.

The body takes no idempotencyKey: the scheduled time is the key. A body that sends one is refused with 400 idempotency_key_removed, and the adapter takes no idempotencyWindowMs or idempotencyOptions (it throws, naming the option, when given one).

Listing schedules​

GET /api/flows/:flowKind/schedules

Returns the static schedules as an array, plus a dynamic.provided flag. You configure schedules.static as a record; the listing flattens it, and each key becomes that entry's id. The flag tells operators whether a resolver is wired up without exposing the dynamic data (which lives in host-owned storage and isn't the framework's to enumerate).

{
"static": [
{
"id": "monthly-invoices",
"cron": "0 0 1 * *",
"timezone": "UTC",
"action": "generateMonthlyInvoices",
"description": "First of the month, 00:00 UTC",
"enabled": true
}
],
"dynamic": { "provided": true }
}

The action field holds the handler block's name (provenance for the listing, not a resolver key). The listing endpoint goes through host.resolvePrincipal and respects the flow's requireUser setting.

What v1 doesn't do​

A few cuts kept the surface honest:

  • No fan-out. One dispatch fires exactly one runAction. If your scheduler wants to send 1000 reminders at 9am, it issues 1000 dispatch calls with 1000 schedule ids. One-tick-many-invocations is a separate design.
  • No framework-side scheduler loop. The framework receives dispatches; it does not fire them. Wire up Cloud Scheduler, EventBridge, Vercel Cron, or a small setInterval yourself. The integration guides walk through each.
  • No durable missed-window handling. If the host scheduler misses a tick, the framework does not catch up. Retries are the host scheduler's responsibility, and a retry only runs while no later scheduled time has fired. There is no backfill.
  • No cross-tenant enumeration of dynamic schedules. The listing endpoint reports static schedules only and a dynamic.provided boolean. The host's own UI surfaces dynamic schedules; it already owns the storage.

For host-side wiring, see:

Deploying​