Calling a flow without a transport
Most flows run because something came in over HTTP: a user hit an endpoint, your
Next.js route handed the request to createFlowApiRouter, and the runtime took
it from there. A transport is just that inbound edge — the thing that turns an
outside request into a flow run.
But not every flow run starts with a request. A nightly cron wants to kick off a digest. A worker draining a queue wants to process each job through a flow. A custom integration reacts to an external event and runs a flow in-process. This code lives outside any transport, and it still needs a sanctioned way in.
That way is runAction, the same action-level entry point the HTTP layer calls
under the hood. You hand it the flow, the action, an input, a resolved userId,
and the stores; it runs the action to completion and returns the result. Two
options on it make it ergonomic for non-HTTP callers: an onItem callback to
observe items as they happen, and a requestId you can read back off the
result.
One rule governs the whole thing — execution is a flow-level concern. There is
no block-level equivalent. If you want to exercise a single block, that is the
job of @flow-state-dev/testing, not runAction.
When to reach for it
- Background jobs — a scheduled task that runs a flow on a timer.
- Cron handlers — the nightly digest, the weekly rollup.
- Queue consumers — a worker that runs a flow per drained message.
- Custom integrations — code reacting to an external event that wants a flow run in-process, with no HTTP round-trip to your own server.
When not to
Anything a user triggers should go through HTTP. runAction does not
authenticate and it does not shape a response — it is a bare execution seam, not
an endpoint.
It also carries the same trust boundary as the HTTP layer: you supply a
resolved userId — a user identity you have already verified belongs to the
caller. runAction takes that identity at face value. Verifying it is your job.
See authentication for the full trust model.
Example
import { runAction, createFilesystemStores } from "@flow-state-dev/server";
import { digestFlow } from "./flows/digest";
const stores = createFilesystemStores({
rootDir: ".flow-state",
developmentOnly: true
});
const result = await runAction({
flow: digestFlow,
actionName: "run",
input: { since: "2026-05-01" },
userId: "user_42", // already resolved + verified by your job
sessionId: "nightly-digest",
source: "manual", // recorded on the request; defaults to "http"
stores,
runtimeConfig: {}, // modelResolver, settings, logger, … (optional)
onItem: (item) => console.log(item.type, item.id)
});
console.log("ran", result.requestId);
if (result.error) throw result.error;
You already hold stores — they are the same registry you built to construct
your server. runtimeConfig carries instance-level config (a modelResolver,
settings, a logger); without a modelResolver in it, a generator block fails at
run time exactly as it would through the HTTP layer.
The result, and fire-and-forget
runAction resolves when the action reaches a terminal state. The
ExecutionResult it returns carries the run's output, the items it
produced, a durationMs, an error when the run failed, and the requestId —
the id of the run, for correlating logs or attaching a stream.
To fire-and-forget, don't await: drop the await and let the run proceed. The
run is durable either way — items and events persist to the stores you passed.
One caveat: an un-awaited run that fails produces an unhandled promise
rejection, so attach a .catch if a failure should be observed.
If you want the requestId before the run finishes — to attach a live stream
while it is still running — pass your own requestId in rather than reading it
off the result:
const requestId = crypto.randomUUID();
void runAction({ ...opts, requestId, stores }); // fire, don't await
// A separate HTTP server over the same `stores` can now stream this run by
// opening its GET-stream route for `requestId`.
Because the run persists against requestId, a separate HTTP server backed by
the same stores can stream it live. The job starts the flow; a dashboard
watches it.
onItem mirrors the live stream
The optional onItem callback fires for every item as it is added, updated, and
done — the same live fan-out that feeds connected SSE clients. That includes
transient items (live-only items that are shown in real time but never
persisted). If you compare onItem against the persisted item log afterward,
the transient ones will be present in the former and absent from the latter,
exactly as they are for an HTTP client. Don't re-filter them. Listener
exceptions are isolated and never break the run.
If you are calling runAction to chain flow A's output into flow B, you
probably want a single flow with two actions instead. This path is for crossing
the no-transport boundary, not for stitching flows together in application code.