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:
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:
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.resolvePrincipalwhen the dispatch endpoint is hit. Proves the caller is the trusted scheduler. Typically a system principal like{ userId: "system" }. - Schedule principal —
schedule.principalon 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:
| Code | Meaning |
|---|---|
| 202 | Accepted: the action was dispatched |
| 200 | duplicate (that scheduled time already fired, or a later one did) or skipped (overlap policy) |
| 400 | Schedule 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) |
| 401 | Gateway auth failed |
| 404 | Flow or schedule not found |
| 405 | Wrong method (only POST is supported) |
| 500 | Resolver threw, or input function threw |
| 503 | Flow 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 gets400 not_a_scheduled_time, and a scheduled time that hasn't arrived yet gets400 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
setIntervalyourself. 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.providedboolean. The host's own UI surfaces dynamic schedules; it already owns the storage.
For host-side wiring, see:
- Scheduled actions on Vercel Cron
- Scheduled actions on Cloud Scheduler
- Scheduled actions on EventBridge Scheduler
- Dynamic scheduled actions
- Keeping a flow running — webhooks, schedules and dispatches on one page, with where each setting lives.
- Schedule index — when polling tick fan-out needs an index.