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 handing a claimed row off, 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-5.4-mini",
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 |
{ 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, or a hired seat the sending session may not open: one pinned to another organization, or a user-owned seat that belongs to another user. The refusal is the same in both cases, so flow-not-found doesn't tell you which one you hit |
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 |
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 | An id delivery on a deployment that hands work to an external queue. A key dispatch is unaffected |
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 seat 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 →
Seats that hand off.
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.
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 sessionClient.listSessions({
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 seat that hands off. 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 seat'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 no runs yet has no status field at all. Absence means "nothing has
run", which is different from any of the values above.
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 request expired without an answer reads active indefinitely,
because nothing discharges an approval except answering it.
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 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 run a dispatcher() started reads source: "internal", or source: "task"
when a task-board seat handed the work off, and carries a metadata.dispatch
bag:
{
"source": "task",
"actionName": "implement",
"metadata": {
"dispatch": {
"type": "task",
"action": "implement",
"from": { "block": "hand-off-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 task hand-off. 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