Skip to main content

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.

sessionRuns inWhen it does not exist
{ key: (input) => string }a session derived from the key, recorded against the running onecreated; the next call with the same key from the same conversation adopts it
{ id: (input) => string }the session with that idrefused. 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:

refusedMeaning
no-entryThe addressed flow declares no entry at that address
flow-not-foundA 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-foundAn id names a session that does not exist, or one that belongs to another user
session-not-addressableAn id names a session on a flow other than the one addressed
key-occupiedThe key derived a session id already held by a record that is not this conversation's run
no-dispatch-operationThis process runs requests but was not set up to dispatch one
dispatch-rejectedThe entry's concurrency policy is reject and its key is held
external-dispatcherAn 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:

ValueMeaning
completedThe run finished. Not the same as the work succeeding
failedThe run itself ended with an error. A worker error doesn't always reach it
abortedCancelled
incompleteStopped 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.