Dispatched work
Some work outlives the turn that asked for it: a long research pass, a document being drafted, an implementation running for an hour. That work runs in its own session, the record the framework keeps for one conversation, holding its state, its resources, and the history of every request that ran in it. A session a dispatcher started is called a dispatch run. It is a session of its flow like any other, and it records the conversation it was started from.
This page covers how a flow starts that work, and the HTTP surface for reading it afterwards. List a flow's sessions with its dispatch runs included to find one, or ask a conversation which runs it started.
Starting one is server-side only. There is no endpoint for it. A job begins
inside a running request, either from a dispatcher() block or from a task
board dispatching a claimed row, which starts one. See Work that outlives the
turn for how the two
relate to the other kinds of background work.
Starting a job from a flow
A flow declares the work a job can run under internal.actions, beside
actions. An internal entry has the same shape as an action, but no client can
call it. The only way in is a dispatcher() block inside the same flow.
import { defineFlow, dispatcher, generator, handler } from "@flow-state-dev/core";
import { z } from "zod";
const summarizeDocument = generator({
name: "summarize-document",
model: "openai/gpt-6-luna",
inputSchema: z.object({ documentId: z.string() }),
prompt: "Summarize the document.",
});
const acknowledge = handler({
name: "acknowledge",
inputSchema: z.object({ reason: z.string() }),
execute: async (input) => ({ noted: input.reason }),
});
// One job per document. The same documentId from the same conversation
// lands on the same job.
const summarizeInBackground = dispatcher({
name: "summarize-in-background",
action: "summarize",
inputSchema: z.object({ documentId: z.string() }),
session: { key: (input) => input.documentId },
});
// Deliver into a session that already exists.
const nudgeCoordinator = dispatcher({
name: "nudge-coordinator",
action: "acknowledge",
inputSchema: z.object({ coordinatorSessionId: z.string(), reason: z.string() }),
session: { id: (input) => input.coordinatorSessionId },
payload: (input) => ({ reason: input.reason }),
});
export default defineFlow({
kind: "documents",
actions: {
upload: { block: summarizeInBackground },
nudge: { block: nudgeCoordinator },
},
internal: {
actions: {
summarize: { block: summarizeDocument },
acknowledge: { block: acknowledge },
},
},
})();
A dispatcher is a handler. Run it, in a sequencer step, as a generator's tool,
or as an action's root block, and it sends one request to action and returns
as soon as the runtime has accepted it. It does not wait for the work.
// what the dispatcher returns
{ sessionId: "dsx_9f2c1a", requestId: "req_c41e", adopted: false }
sessionId is the session the work runs in, requestId the run it became, and
adopted whether that session already existed. The entry validates the payload
against its own inputSchema on arrival; payload shapes it, and defaults to
the dispatcher's input as-is.
session decides which session that is.
session | Runs in | When it does not exist |
|---|---|---|
{ key: (input) => string } | a session derived from the key, recorded against the running one | created; the next call with the same key from the same conversation adopts it |
{ key, state: (input) => object } | the same, created with that initial state | created with that state, if the target flow's stateSchema and createCheck accept it. state can also be a fixed object. A run that already exists keeps its own state. A task board's dispatcher takes state beside session instead; see Which session a task runs in |
{ id: (input) => string } | the session with that id | refused. Nothing is created |
A key run is a job in every sense on this page: it runs the same flow as the
same user, keeps its own state and history, records the session it was started
from, and appears in both listings below. The key is scoped to the conversation,
so the same key from a different conversation is a different run. An id target
has to be a session of this flow kind that belongs to this user.
defineFlow checks every dispatcher it can reach and throws at definition time
when action names an entry the flow does not declare. An action named
summarize does not stand in for internal.actions.summarize; each map is
looked up on its own.
A refusal at run time throws DispatchRefusedError, with code: "dispatch-refused" and a refused field to branch on:
refused | Meaning |
|---|---|
no-entry | The addressed flow declares no entry at that address |
flow-not-found | A flowKind names a flow this server has not registered |
session-not-found | An id names a session that does not exist, or one that belongs to another user |
session-not-addressable | An id names a session on a flow other than the one addressed |
key-occupied | The key derived a session id already held by a record that is not this conversation's run |
create-refused | The addressed flow refused to create the run's session: its stateSchema or session.createCheck refused the state the dispatcher named, or the lack of one |
no-dispatch-operation | This process runs requests but was not set up to dispatch one |
dispatch-rejected | The entry's concurrency policy is reject and its key is held |
external-dispatcher | A delivery into an existing session, by id or a { from: true } reply, from a process whose runner hands work to another process and can't hold a session's concurrency policy across processes: a flow runner passed directly, or a worker adapter that doesn't supply one. bullmqWorker supplies one, so it doesn't refuse. A reply from a run on a worker-only worker goes in process. A key dispatch is unaffected |
On a queue-backed deployment, an id delivery runs in whichever worker picks it
up, and it waits for the recipient session's concurrency policy like any other
run into that session. If the session is deleted and created again while the
delivery waits in the queue, the worker drops the delivery rather than run it in
the new session.
Every refusal is decided before anything starts, so a .rescue() on the
dispatcher can branch on refused knowing no run has started. A key or
id function that returns an empty string throws a plain Error naming the
block.
A task board worker can start a job the same way: a dispatcher({ action, session }) under workers sends each claimed row to one of the flow's task.actions
entries, and that run lands in the same listings. See Task board →
Assignees that dispatch.
Starting a job on another flow
A server usually runs more than one flow. Add flowKind and the dispatcher
resolves its action on that flow's internal.actions instead of its own. The
value is the target's instance id: its kind for an ordinary flow, the copy's own
id ("review-east") for a flow that runs as several named copies. The bare kind
of such a flow addresses nothing.
const notifyBilling = dispatcher({
name: "notify-billing",
flowKind: "billing", // the other flow
action: "charge", // billing's internal.actions.charge
inputSchema: z.object({ orderId: z.string() }),
session: { key: (input) => input.orderId },
});
The job starts and returns immediately. Its session records the conversation that
started it, but it belongs to the instance it was sent to. It runs that
instance's entry, starts with that instance's session-state defaults, and its
flowId in the listing is that instance's. Whatever the job needs travels in the
payload; the two flows share no state. Two copies of one
definition count as two instances here: a run sent to review-east is
review-east's, and the same conversation dispatching to review-west gets a
second run rather than adopting the first.
defineFlow can't check this address the way it checks a same-flow one. It sees
one flow at a time, and the flow you named is defined somewhere else. So the
check happens when the dispatch runs, against the flows the server has
registered: flow-not-found if there is no such flow, no-entry if there is
and it declares no such entry. Both are ordinary DispatchRefusedError
refusals — nothing retries, and nothing quietly falls back to an entry of the
same name on the sending flow.
Both flows have to be registered on the same server. This addresses another flow, not another service.
To hear back, the other flow replies the same way it would within one flow —
a { from: true } dispatcher — pointed at your flowKind:
// on the billing flow
const confirmToSender = dispatcher({
name: "confirm-to-sender",
flowKind: "orders",
action: "confirm",
inputSchema: z.object({ orderId: z.string() }),
session: { from: true },
});
The runtime supplies the session to reply into, from the dispatch it stamped;
you supply the flow. If they disagree — the sender's session is not owned by the
instance you named — the reply is refused session-not-addressable rather than
delivered somewhere else. Naming a sibling copy of the sender's definition is
still a disagreement: ownership is by instance, not by kind. A task dispatcher may take flowKind the same way.
Waiting for the answer
A dispatch returns before the work it started has run. That suits a job that outlives the turn. When the next step needs the result instead, wait for it:
const checkLinks = dispatcher({
name: "check-links",
action: "check",
inputSchema: z.object({ docId: z.string() }),
session: { key: (input) => input.docId },
wait: true,
});
With wait: true the block's output is how the dispatched request ended:
{ status: "completed", output, requestId }, { status: "failed", error, requestId }, or
{ status: "running", requestId } at the deadline, five minutes by default. Nothing is stopped
at the deadline; the work runs on. The request parks without holding anything open and
survives a server restart. Waits nest up to five deep, counting task waits too, and a wait that
would go deeper is refused before anything is dispatched.
A worker waits on a task the same way: it files it, then calls getTask({ id, wait: true }).
Both need durable execution, the durability sweeper and a store that keeps wakes (see
Retention and cleanup). The task tool,
its deadline and its errors are on
the task board.
Finding dispatched runs on a flow
GET /sessions returns the sessions a person started. Pass
include=dispatch-runs for the sessions dispatchers ran work in as well:
GET /api/flows/sessions?flowKind=reports&include=dispatch-runs
Rows are whole session records. One a dispatcher started carries
parentSessionId — the bare id of the conversation it was started from — beside
the topic and coordinate labels described below. Sort a listing on
parentSessionId to group each conversation's runs under it.
const sessions = await fsd.sessions.list({
flowKind: "reports",
include: "dispatch-runs",
});
The listing is scoped to the caller before the parameter is read. An
authenticated caller sees their own sessions, in their own organization and
tenant; the userId query filter can narrow that and never widen it, and there
is no orgId query parameter at all, because an organization is never a
caller's to name. The parameter adds the dispatcher-started rows inside that
scope and changes nothing about it, so a run belonging to another principal,
another organization or another tenant is absent from the response either way. A
value the route does not recognise is a 400 naming what it accepts.
With no resolvePrincipal configured there is no principal to scope to, and the
query filters are the only ones there are — the same caveat that governs every
management endpoint. See Without a
resolver.
Leave the parameter off and the response holds the sessions a person started.
Which runs a conversation started
GET /api/flows/sessions/sess_abc/children
The /children route is the provenance index for one conversation: which runs
were started from it, and what state each one's work reached. A row is a
ChildSessionSummary, one per dispatch run. Reach for it when you have a
conversation in hand and want its work; reach for the listing above when you want
a flow's runs without naming a conversation first.
{
"children": [
{
"id": "dsx_9f2c1a",
"parentSessionId": "sess_abc",
"topic": "task|10:issue-work|3:t42",
"coordinate": "task:implement",
"status": "active",
"flowId": "review-east",
"createdAt": 1770000000000,
"updatedAt": 1770000042000
},
{
"id": "dsx_1c7b40",
"parentSessionId": "sess_abc",
"topic": "acme-corp",
"coordinate": "internal:summarize",
"status": "completed",
"createdAt": 1769999000000,
"updatedAt": 1769999900000
}
]
}
Those eight fields are the whole row. The route sends this named set rather than
a session record, so there is no flowKind, userId, title or metadata on
it. flowId is the instance that owns the run, the one a cross-flow dispatch
was sent to; it is the address to read or re-enter the run through, and is
absent on a row that records no owner.
topic and coordinate are display labels. coordinate is the entry the run
was dispatched to, <type>:<target>: internal:summarize for an internal entry,
task:implement for a task-board worker that dispatches. topic is the key the
run's session was derived from — what a dispatcher()'s session: { key }
function returned, or the composed key a task worker's session policy produced.
Nothing routes, authorizes or identifies from either label, and both are
optional, as are status and flowId. Guard all four with == null. A row with no labels is
a dispatch run, same as any other; it just carries nothing to display.
What status tells you
active means the job isn't finished. It covers a job waiting in a queue, a job
running right now, and a job paused waiting for someone to approve something.
The endpoint does not distinguish those. If you need to know which,
open the job and read its history, or read the task board the job is working
from.
Every other value is how the job's last run ended:
| Value | Meaning |
|---|---|
completed | The run finished. Not the same as the work succeeding |
failed | The run itself ended with an error. A worker error doesn't always reach it |
aborted | Cancelled |
incomplete | Stopped short of finishing, usually on a budget |
A job doing a task board's work runs that board's worker, and a worker that
throws records the failure on its own task. What that does to the run around it
is the board's
onError
setting. On the default, "skip", the run finishes and the job reads
completed. On "fail" the error propagates and the job reads failed.
So a board left on the default reports a job that succeeded for work that broke.
The task carries the failure under either setting, which makes the board the
thing to watch when the question is whether the work came out right, and the
job's status the thing to watch when the question is whether anything is still
running. A failed task reads errored, with the message in its error
field.
That write goes through the worker's claim on the task, so it lands only while the task is still the worker's to write. One cancelled in the meantime, or completed by the worker itself earlier in the run, or handed to another worker after the claim lapsed, keeps the status it already has and records nothing. See Recording a result that may no longer apply.
A job with nothing dispatched to it yet has no status field at all. That
absence is different from any of the values above: a job that has been
dispatched but is still waiting in a queue already reads active.
A job that failed and was retried successfully reads completed; the failed
attempt is still there in the job's own history.
active describes what the system recorded, not what a worker is doing right
now. If a worker's process dies mid-job the row keeps reading active until a
client continues the run or a retry supersedes it — the framework marks the run
recoverable, it does not restart it for you. A job whose approval expired
without an answer doesn't stay active on that account: on durable hosts, the
durability sweeper carries the run on as if the approval had been rejected.
The gated tool doesn't run, the model is told the approval is no longer valid,
and the job then settles however that run ends. See
Retention and cleanup.
What a stopped process leaves behind
A process can stop while background work is still running, and the work is then
cancelled without being settled. So a job can read active with nothing running
it, and the request records under it can read in-progress, for a while after the
process is gone. Neither is a stuck row.
The task clears itself: it is taken back once its lease has lapsed and some
worker claims it again. The request record is made recoverable rather than
cleared — a sweep marks it interrupted, the status a run can be resumed from,
running at runtime start, on a timer while a server is up, and on demand when a
client asks.
Marking it is where the framework stops. Nothing continues or re-runs the work
on your behalf, deliberately: restarting a request nobody asked to restart is
how the same job runs twice. So the row keeps reading active — an
interrupted run is unfinished and still continuable — until a client resumes
or retries it, or a later run supersedes it. The one exception is a request
interrupted inside a worker's call that had sent its message but not yet parked:
the durability sweeper continues it, and the replay waits on the message already
sent instead of sending a second one (see
a wait that died before it
parked). The sweep waits
until a record's heartbeat has been quiet longer than the staleness threshold,
so a job that has simply gone quiet for a second is never mistaken for an
abandoned one.
That protection is the heartbeat, so it doesn't cover a flow that turns the
heartbeat off. With request: { heartbeatIntervalMs: 0 } nothing refreshes the
run's active-request registry entry, so a run lasting longer than the staleness
threshold will be marked interrupted while it is still going — which also
offers it for resume, so the same work can be started a second time. Keep the
heartbeat on for any flow whose requests outlive the threshold.
The process that walked away mostly doesn't settle the record on its way out:
dispose() cancels background work rather than
marking it finished or failed. One case doesn't follow that yet — work still
waiting behind a concurrency limit when shutdown reaches it is recorded
aborted without ever having started — so read a terminal status after a
shutdown as a record of what the process did, not as proof the work ran. And if
nothing ever runs against that store again, nothing sweeps it, and the row stays
as it is.
For the thresholds, see Connection resilience. For the lease and the abandonment allowance, see the lease and when a job keeps being abandoned.
Reading one run's history
Each row's id addresses a session, so every session endpoint works on it:
GET /api/flows/sessions/dsx_9f2c1a/requests
That returns the run's own requests, with the item log for each when you ask for
it (?include_items=true). A finished request also carries a result with its
error, if any, and the action's return value when you ask for that too
(?include_result_output=true). See
fsd.sessions.requests
for the shape.
A run a dispatcher() started reads source: "internal", or source: "task"
when a task-board worker dispatched the work, and carries a metadata.dispatch
bag:
{
"source": "task",
"actionName": "implement",
"metadata": {
"dispatch": {
"type": "task",
"action": "implement",
"from": { "block": "dispatch-implement", "sessionId": "sess_abc" },
"key": "task|10:issue-work|3:t42",
"taskId": "t42"
}
}
}
type and action are the entry the run executes, from names the block that
sent it and the session it was running in, key is the session key the run
was derived from, and taskId the board row on a dispatched task. key is absent
when the dispatcher delivered into an existing session by id, and taskId is
absent on an internal dispatch.
The runtime assembles that bag from values it derived itself; a request body
cannot write it. It is still labels rather than authority — use taskId to
stitch a run to a board row in a view, not to key an authorization or a
settlement on. Read the bag only when source is "internal" or "task": an
application can put whatever it likes in metadata on its own requests,
including a key named dispatch, and source is the field a request body
cannot set.
A run with either source cannot be re-entered from outside. retry,
continue, and resume on its request id answer 404, the same as for a
request that does not exist.
A run can dispatch work of its own. Those runs are sessions of the flow like any
other: they appear in the flow's listing with include=dispatch-runs, and
calling /children on the run that started them returns them.
Paging a conversation's runs
Pass limit (1–100, default 25) and offset (0–10000). Values outside those
ranges get a 400 naming the accepted range rather than a silently clamped
page. A host can lower the ceiling with
maxChildSessionListLimit.
Rows come back newest-created first. A run that starts a request while you are paging will not shuffle the pages under you. A run created while you are paging can be missed, or can shift a later page by one — if you need exactness there, fetch a single page large enough to hold the whole set.
What the runs endpoint won't do
It won't apply access rules of its own. The same rules that govern reading
the conversation named in the path govern reading its runs. That is how
every session-addressed route works: session detail, state, resource content,
the debug endpoints. A conversation in another tenant answers 404. One with no
runs answers 200 with an empty list. Whether one belonging to another user
answers 403 depends on your resolvePrincipal. With none configured the
management endpoints stay open, so a caller holding a conversation id can read
its runs. See Without a resolver.
It won't list background work across conversations. It answers for the one
conversation in its path. For a flow's runs without naming a conversation, list
the flow's sessions with include=dispatch-runs.
It won't tell you whether a worker process is alive. See the note on
status above.
It won't start anything. Whether work is dispatched at all is declared in the flow. A caller can list runs, never create one.
It won't return a run's state, resources, or journal. Rows carry identity, labels, timestamps and status. Fetch the session itself if you need more.
Related
- Work that outlives the turn — how this relates to the other things this framework calls background work
- Client overview — the same calls from an app
- Claude Code SDK agent — running a coding agent in a dispatch run, and what it records there
- Codex SDK agent — the other coding agent, and how a run in a dispatch run stays resumable when it is cancelled
- DevTool — reading a run's stream and trace while you develop
- Engine setup — the full HTTP route table
- Authentication — how addressed routes scope by owner
- Persistence — where sessions and requests are stored