Example 11: Operator Hand-Off
What It Is
Operator Hand-Off is how an application parks a running DAG at a human boundary, stores the cursor and state, and resumes later when the operator supplies the missing input.
The Dispatcher browser demo is the concrete application: a customer turn parks at park-for-operator, the UI captures a checkpoint, and the operator pane resumes the same DAG from the parked cursor.
How It Works
The first execution routes to a parked output and returns an ExecutionResult with parked metadata. The application persists the checkpoint and correlation key outside the DAG. A later actor restores state, writes the external response, and calls dispatcher.resume(...) from the recorded cursor. The parked node does not know whether the resumer is a browser operator, queue worker, or cloud handler.
The hand-off boundary is serialized execution state, not a callback. That keeps browser demos, queue workers, webhooks, and serverless continuations on the same runtime contract.
Diagrams, Examples, and Outputs
DAG registration and diagram
The browser-runnable hand-off is The Dispatcher: a customer turn runs until park-for-operator, the execution parks with a cursor, and the operator turn resumes the same DAG from that cursor. The low-level DAGHandoff queue envelope remains the distribution primitive; the in-browser demo shows the same state pass-over at the user-facing escalation boundary.
support-dispatcher hand-off DAG
7 placements{
"@context": {
"@version": 1.1,
"name": {
"@id": "https://noocodec.dev/ontology/dag/name"
},
"version": {
"@id": "https://noocodec.dev/ontology/dag/version"
},
"entrypoints": {
"@id": "https://noocodec.dev/ontology/dag/entrypoints",
"@container": "@index"
},
"nodes": {
"@id": "https://noocodec.dev/ontology/dag/nodes",
"@container": "@set"
},
"outputs": {
"@id": "https://noocodec.dev/ontology/dag/outputs"
},
"node": {
"@id": "https://noocodec.dev/ontology/dag/node"
},
"dag": {
"@id": "https://noocodec.dev/ontology/dag/dag"
},
"body": {
"@id": "https://noocodec.dev/ontology/dag/body"
},
"source": {
"@id": "https://noocodec.dev/ontology/dag/source"
},
"sources": {
"@id": "https://noocodec.dev/ontology/dag/sources",
"@container": "@index"
},
"itemKey": {
"@id": "https://noocodec.dev/ontology/dag/itemKey"
},
"execution": {
"@id": "https://noocodec.dev/ontology/dag/execution"
},
"concurrency": {
"@id": "https://noocodec.dev/ontology/dag/concurrency"
},
"throttle": {
"@id": "https://noocodec.dev/ontology/dag/throttle"
},
"reservoir": {
"@id": "https://noocodec.dev/ontology/dag/reservoir"
},
"gather": {
"@id": "https://noocodec.dev/ontology/dag/gather"
},
"dagReference": {
"@id": "https://noocodec.dev/ontology/dag/dagReference",
"@type": "@id"
},
"DagReference": {
"@id": "https://noocodec.dev/ontology/dag/DagReference"
},
"from": {
"@id": "https://noocodec.dev/ontology/dag/from"
},
"path": {
"@id": "https://noocodec.dev/ontology/dag/path"
},
"candidates": {
"@id": "https://noocodec.dev/ontology/dag/candidates",
"@container": "@set"
},
"candidateDag": {
"@id": "https://noocodec.dev/ontology/dag/candidateDag",
"@type": "@id"
},
"selectedDag": {
"@id": "https://noocodec.dev/ontology/dag/selectedDag",
"@type": "@id"
},
"resultField": {
"@id": "https://noocodec.dev/ontology/dag/resultField"
},
"policy": {
"@id": "https://noocodec.dev/ontology/dag/policy"
},
"reducer": {
"@id": "https://noocodec.dev/ontology/dag/reducer"
},
"outcome": {
"@id": "https://noocodec.dev/ontology/dag/outcome"
},
"phase": {
"@id": "https://noocodec.dev/ontology/dag/phase"
},
"stateMapping": {
"@id": "https://noocodec.dev/ontology/dag/stateMapping"
},
"container": {
"@id": "https://noocodec.dev/ontology/dag/container"
},
"DAG": {
"@id": "https://noocodec.dev/ontology/dag/DAG"
},
"Placement": {
"@id": "https://noocodec.dev/ontology/dag/Placement"
},
"SingleNode": {
"@id": "https://noocodec.dev/ontology/dag/SingleNode"
},
"ScatterNode": {
"@id": "https://noocodec.dev/ontology/dag/ScatterNode"
},
"EmbeddedDAGNode": {
"@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
},
"GatherNode": {
"@id": "https://noocodec.dev/ontology/dag/GatherNode"
},
"TerminalNode": {
"@id": "https://noocodec.dev/ontology/dag/TerminalNode"
},
"PhaseNode": {
"@id": "https://noocodec.dev/ontology/dag/PhaseNode"
}
},
"@id": "urn:noocodec:dag:support-dispatcher",
"@type": "DAG",
"name": "dag:support-dispatcher",
"version": "1",
"entrypoints": {
"main": "urn:noocodec:dag:support-dispatcher/node/classify-message"
},
"nodes": [
{
"@id": "urn:noocodec:dag:support-dispatcher/node/setup",
"@type": "PhaseNode",
"name": "dag:support-dispatcher/node/setup",
"node": "urn:noocodec:node:dispatcher-setup",
"phase": "pre"
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/classify-message",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/classify-message",
"node": "urn:noocodec:node:classify-message",
"outputs": {
"routine": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
"escalate": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
"off-topic": "urn:noocodec:dag:support-dispatcher/node/decline"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/ai-compose",
"node": "urn:noocodec:node:ai-compose",
"outputs": {
"drafted": "urn:noocodec:dag:support-dispatcher/node/send-response"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/park-for-operator",
"node": "urn:noocodec:node:park-for-operator",
"outputs": {
"parked": "urn:noocodec:dag:support-dispatcher/node/end",
"ready": "urn:noocodec:dag:support-dispatcher/node/send-response"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/send-response",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/send-response",
"node": "urn:noocodec:node:send-response",
"outputs": {
"sent": "urn:noocodec:dag:support-dispatcher/node/end"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/decline",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/decline",
"node": "urn:noocodec:node:decline",
"outputs": {
"declined": "urn:noocodec:dag:support-dispatcher/node/end"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/end",
"@type": "TerminalNode",
"name": "dag:support-dispatcher/node/end",
"outcome": "completed"
}
]
}Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
%% dag:support-dispatcher (v1)
entry_main(["main"])
entry_main --> urn_noocodec_dag_support-dispatcher/node/classify-message
urn_noocodec_dag_support-dispatcher/node/setup(["dag:support-dispatcher/node/setup (pre)"])
urn_noocodec_dag_support-dispatcher/node/classify-message["dag:support-dispatcher/node/classify-message"]
urn_noocodec_dag_support-dispatcher/node/classify-message -->|routine| urn_noocodec_dag_support-dispatcher/node/ai-compose
urn_noocodec_dag_support-dispatcher/node/classify-message -->|escalate| urn_noocodec_dag_support-dispatcher/node/park-for-operator
urn_noocodec_dag_support-dispatcher/node/classify-message -->|off-topic| urn_noocodec_dag_support-dispatcher/node/decline
urn_noocodec_dag_support-dispatcher/node/ai-compose["dag:support-dispatcher/node/ai-compose"]
urn_noocodec_dag_support-dispatcher/node/ai-compose -->|drafted| urn_noocodec_dag_support-dispatcher/node/send-response
urn_noocodec_dag_support-dispatcher/node/park-for-operator["dag:support-dispatcher/node/park-for-operator"]
urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|parked| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|ready| urn_noocodec_dag_support-dispatcher/node/send-response
urn_noocodec_dag_support-dispatcher/node/send-response["dag:support-dispatcher/node/send-response"]
urn_noocodec_dag_support-dispatcher/node/send-response -->|sent| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/decline["dag:support-dispatcher/node/decline"]
urn_noocodec_dag_support-dispatcher/node/decline -->|declined| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/end((("dag:support-dispatcher/node/end")))This example hands control from the customer-facing execution to an operator-facing continuation. The parked execution result carries the cursor and correlation key; the browser captures a checkpoint, restores state, writes the operator response, and resumes the DAG.
Run
npm run docs:devOpen The Dispatcher, enable HUMAN GATE, send a customer message, then answer it in the Operator pane.
What It Lets You Do
Operator hand-off lets applications split one workflow across actors or processes while keeping the DAG as the source of truth. Use it when a customer-facing run must stop at a boundary, preserve state and cursor, then resume from an operator, queue worker, webhook handler, or serverless continuation.
Key concept
The grain of a hand-off is execution state, not a callback. The first Dispatcher call runs until the park point. The second operator action restores state from the parked result and resumes from the cursor. The parked node does not know who resumes it.
dispatcher.execute('urn:noocodec:dag:support-dispatcher', state)
│
└─ park-for-operator routes 'parked'
│
└─ Checkpoint.capture(...) stores state + cursor
│
└─ operator response → restore state → dispatcher.resume(...)This is the browser equivalent of a serverless handler resuming work from a queue envelope: serialized state plus a cursor is the hand-off boundary.
Code Samples
Read the snippets with the diagrams nearby so the TypeScript behavior, JSON-LD graph shape, and runtime output line up as one contract.
Key APIs
| Symbol | Import | Role |
|---|---|---|
Checkpoint.capture | @studnicky/dagonizer/checkpoint | Captures parked state and cursor |
CheckpointRestoreAdapter | @studnicky/dagonizer/checkpoint | Restores DispatcherState from the snapshot |
dispatcher.resume | @studnicky/dagonizer | Continues from the parked cursor |
result.parked | ExecutionResultType | Carries cursor and correlation key |
Queue-backed hand-off uses the same state snapshot/cursor idea across a transport boundary.
Browser resume trigger
The browser hand-off stores the parked result in memory. A distributed transport uses a DAGHandoff envelope and a HandoffChannelInterface implementation instead of the in-page operator state.
async function sendOperatorResponse(): Promise<void> {
// Use the non-reactive store: parked.value is just the cursor signal.
const pe = parkedExecution;
if (pe === null || operatorInput.value.trim().length === 0) return;
const responseText = operatorInput.value.trim();
operatorInput.value = '';
isRunning.value = true;
trace.value = [...trace.value, {
'node': 'operator',
'ts': Date.now(),
'variant': 'start',
}];
logger.note(`operator response captured — resuming from cursor: ${pe.cursor}`);
const services = buildServices();
const setup = new SetupNode();
const classifyMessage = new ClassifyMessageNode(services);
const aiCompose = new AiComposeNode(services);
const parkForOperator = new ParkForOperatorNode();
const sendResponse = new SendResponseNode();
const decline = new DeclineNode();
await dagGraph.value?.reset();
let restored: { state: DispatcherState; dagName: string; cursor: string } | null = null;
try {
// pe.result was stored outside Vue reactivity so it retains class identity.
const ckpt = await Checkpoint.capture(pe.dagName, pe.result);
restored = ckpt.restoreState(
CheckpointRestoreAdapter.wrap((snap) => DispatcherState.restore(snap)),
);
} catch (err) {
logger.error(
LogFault.create()
.component('dispatcher-runner')
.operation('checkpoint-restore')
.status('failed')
.name(err instanceof Error ? err.constructor.name : 'Error')
.message(`checkpoint restore failed: ${err instanceof Error ? err.message : String(err)}`)
.context({})
.build(),
);
isRunning.value = false;
return;
}
restored.state.response = responseText;
const dispatcher = new DispatcherBrowserObserver(logger, services);
dispatcher.registerBundle({
'nodes': [setup, classifyMessage, aiCompose, parkForOperator, sendResponse, decline],
'dags': [supportDispatcherDAG],
});
activeAbortController = new AbortController();
try {
await dispatcher.resume(restored.dagName, restored.state, restored.cursor, {
'signal': activeAbortController.signal,
});
} catch (error) {
logger.error(
LogFault.create()
.component('dispatcher-runner')
.operation('resume')
.status('failed')
.name(error instanceof Error ? error.constructor.name : 'Error')
.message(`resume error: ${error instanceof Error ? error.message : String(error)}`)
.context({})
.build(),
);
} finally {
await dispatcher.destroy();
activeAbortController = null;
isRunning.value = false;
parkedExecution = null;
parked.value = null;
if (leftActiveKey.value === 'operator') leftActiveKey.value = 'customer';
}
}See Distribution and Cloud for the serverless handler pattern, Step Functions wiring, and idempotency guidance.
Details for Nerds
- Parked result hand-off.
result.parkedis the hand-off record between the customer turn and the operator turn. - Snapshot fidelity.
Checkpoint.capturestores the state shape needed to resume after UI or process interruption. - Cursor resume.
dispatcher.resume(dagName, state, cursor)re-enters atpark-for-operator. - Domain ownership. The operator writes
state.response; the DAG routesreadyand sends the response.
Related Concepts
- Guide: Distribution and Cloud - serverless handler pattern, Step Functions wiring, registryVersion handshake
- Example 12: Worker Containers - run a scatter-dag-body over a real WorkerThreadContainer pool
- Reference: Entities, DAGHandoff
- Reference: Contracts, HandoffChannelInterface