The Dispatcher
What It Is
The Dispatcher is a runnable demo: a real browser-executed DAG application, not a decorative diagram. It is a warm-handoff customer support demo powered by Dagonizer: HITL Park-and-Correlate, checkpoint/resume, and a trolley switch that forces human routing. It runs in your browser with Ollama or a cloud provider API key.
Use it to see human review without losing DAG state or auditability. The customer flow parks with a cursor, the operator flow resumes from that cursor, and the same graph records both halves.
How It Works
The runner wires real node classes, real DAG documents, and browser UI observers together. The visual panes listen to dispatcher lifecycle events, so the page shows execution rather than replaying a canned animation.
Architecture
support-dispatcher
phase('setup', 'pre', SetupNode) ← stamps runId metadata; never gates
classify-message
→ 'routine' → ai-compose → send-response → end (AI path)
→ 'escalate' → park-for-operator
→ 'parked' [engine intercepts → lifecycle: awaiting-input]
→ 'ready' → send-response → end (operator path)
→ 'off-topic' → decline → endThe 'parked' output from ParkForOperatorNode is mapped to 'end' in the DAGBuilder call to satisfy TypeScript's route-exhaustiveness check, but the engine intercepts 'parked' before consulting the route map. That target is never reached at runtime.
Diagrams, Examples, and Outputs
The live demo is the main diagram. Its graph, state panes, traces, backend selectors, operator panel, and outputs are all evidence from the running system.
Branches and gates
Three exit paths, each producing a different outcome:
| Path | Trigger | Terminal branch | What happens |
|---|---|---|---|
| Routine | Classifier (embedder or LLM) resolves routine; humanMode off | ai-compose → send-response → end | LLM composes a reply; flow completes in one execution |
| Escalated | Classifier resolves escalate, or humanMode on, or classification error | park-for-operator parks | Flow suspends; operator tab activates; operator responds; resume continues to send-response → end |
| Off-topic | Classifier resolves off-topic, or blank message | decline → end | Polite refusal; flow completes immediately |
In embedder mode (the default), the on-device embedder classifies first; the LLM only steps in when the embedder is unavailable or its top score misses the confidence floor. In llm mode, the LLM classifies every message directly.
CLI run
npx tsx examples/32-dispatcher.tsThe CLI demo runs all three scenarios in sequence: routine (AI), escalated (park → operator reply → resume), and trolley-forced (humanMode = true). Output shows the lifecycle variant, escalation reason, correlation key, cursor, and full conversation history after each run.
What this proves
The Dispatcher proves HITL Park-and-Correlate in a real browser flow: a DAG can park, expose a correlation key, capture a checkpoint, wait for a human, restore state, and resume through the same JSON-LD graph.
Classification runs on-device by default: an offline MiniLM embedder computes cosine similarity between the message and three intent anchors (routine, escalate, off-topic) — instant, with no LLM round-trip and no adapter-timeout exposure. The LLM composes the reply on the routine branch, and also serves as the classification path when the embedder is unavailable, when it isn't confident about a message, or when the Config toggle is set to llm mode. The trolley switch and escalation routing are deterministic overrides on top of whichever classifier decided.
It runs on the same @studnicky/dagonizer engine as The Archivist and The Cartographer. What changes is the domain primitive: this demo exercises the HITL Park-and-Correlate lifecycle — state.park() → awaiting-input lifecycle state → Checkpoint.capture → dispatcher.resume().
Try it live below. Type a customer message and click Send. Switch to the Config tab to flip the trolley switch and see how routing changes.
Watch the DAG pane while the flow executes: nodes light cyan while running, edges flash on traversal, and skipped branches remain dim. When the flow parks, the Operator tab activates automatically — type a response and click Send response to checkpoint-and-resume the suspended execution.
What It Lets You Do
Use the Dispatcher when you want to see the awkward middle of automation: a model can help, but a person sometimes has to decide. The graph makes that handoff explicit instead of hiding it in UI state.
For application teams, this page answers the operational question: can a DAG pause, expose a correlation key, wait for an external decision, and resume without pretending the pause never happened? The demo shows the whole loop.
What to try
Send a routine support message, then send a refund or billing escalation. Toggle HUMAN GATE to force the operator path, answer from the Operator tab, and watch the DAG resume from the parked cursor instead of restarting.
Code Samples
The Dispatcher source is small enough to read in one sitting. Start with the DAG, then inspect the parking node and CLI runner that prove the checkpoint/resume path.
Canonical support DAG
const setup = new PlaceholderNode<DispatcherState, 'ready'>('urn:noocodec:node:dispatcher-setup', ['ready']);
const classifyMessage = new PlaceholderNode<DispatcherState, 'routine' | 'escalate' | 'off-topic'>('urn:noocodec:node:classify-message', ['routine', 'escalate', 'off-topic']);
const aiCompose = new PlaceholderNode<DispatcherState, 'drafted'>('urn:noocodec:node:ai-compose', ['drafted']);
const parkForOperator = new PlaceholderNode<DispatcherState, 'parked' | 'ready'>('urn:noocodec:node:park-for-operator', ['parked', 'ready']);
const sendResponse = new PlaceholderNode<DispatcherState, 'sent'>('urn:noocodec:node:send-response', ['sent']);
const decline = new PlaceholderNode<DispatcherState, 'declined'>('urn:noocodec:node:decline', ['declined']);
const supportDispatcherDagIri = 'urn:noocodec:dag:support-dispatcher' as const;
const placement = (placementIdentifier: string): string =>
DAGIdentity.placementId(supportDispatcherDagIri, placementIdentifier);
export const supportDispatcherDAG: DAGType = new DAGBuilder(supportDispatcherDagIri, '1')
// Pre-phase: stamps runId before the entrypoint runs.
.phase(placement('setup'), 'pre', setup)
// Entrypoint: classify the inbound message.
.node(placement('classify-message'), classifyMessage, {
'routine': placement('ai-compose'),
'escalate': placement('park-for-operator'),
'off-topic': placement('decline'),
})
// Routine branch: AI composes a canned reply -> send -> done.
.node(placement('ai-compose'), aiCompose, {
'drafted': placement('send-response'),
})
// Escalation branch: HITL suspension point.
.node(placement('park-for-operator'), parkForOperator, {
'parked': placement('end'),
'ready': placement('send-response'),
})
// Shared convergence: both routine and escalated paths flow through send-response.
.node(placement('send-response'), sendResponse, {
'sent': placement('end'),
})
// Off-topic branch: decline and close.
.node(placement('decline'), decline, {
'declined': placement('end'),
})
.terminal(placement('end'), { 'outcome': 'completed' })
.build();Park and resume node
/**
* ParkForOperatorNode: HITL suspension point.
*
* On FIRST call (state.response is empty):
* - Generates a correlationKey.
* - Calls state.park(correlationKey) to transition lifecycle → awaiting-input.
* - Routes 'parked' (intercepted by the engine; flow suspends).
*
* On RESUME call (state.response is non-empty, set by the human operator):
* - Routes 'ready' → send-response continues the flow normally.
*
* The 'parked' output is not wired in the DAG — the engine intercepts it
* before routing and surfaces `result.parked` with the correlationKey and cursor.
*/
import { Batch, MonadicNode, NodeOutput } from '@studnicky/dagonizer';
import type { ItemType, NodeContextType, NodeOutputType, RoutedBatchType, SchemaObjectType } from '@studnicky/dagonizer';
import type { DispatcherState } from '../DispatcherState.ts';
export class ParkForOperatorNode extends MonadicNode<DispatcherState, 'parked' | 'ready'> {
readonly name = 'park-for-operator';
readonly '@id' = 'urn:noocodec:node:park-for-operator';
readonly outputs = ['parked', 'ready'] as const;
override get outputSchema(): Record<'parked' | 'ready', SchemaObjectType> {
return {
'parked': { 'type': 'object' },
'ready': { 'type': 'object' },
};
}
override async execute(
batch: Batch<DispatcherState>,
_context: NodeContextType,
): Promise<RoutedBatchType<'parked' | 'ready', DispatcherState>> {
const acc = new Map<'parked' | 'ready', ItemType<DispatcherState>[]>();
for (const item of batch) {
const result = this.routeItem(item.state);
for (const error of result.errors) {
item.state.collectError(error);
}
const bucket = acc.get(result.output);
if (bucket === undefined) {
acc.set(result.output, [item]);
} else {
bucket.push(item);
}
}
const routed = new Map<'parked' | 'ready', Batch<DispatcherState>>();
for (const [output, items] of acc) {
routed.set(output, Batch.from(items));
}
return routed;
}
private routeItem(state: DispatcherState): NodeOutputType<'parked' | 'ready'> {
if (state.response.length > 0) {
// Operator has filled in the response; resume the flow.
return NodeOutput.create('ready');
}
// First call: park and await human input.
const correlationKey = 'escalation:' + Date.now().toString();
state.park(correlationKey);
return NodeOutput.create('parked');
}
}CLI runner
/**
* 32-dispatcher: HITL park-and-correlate with a trolley switch + real LLM.
*
* Demonstrates the Nocodec Support dispatcher — a customer support
* warm-handoff demo that shows the HITL park-and-correlate primitive
* with a trolley switch.
*
* Domain: Nocodec — fictional bookstore.
* Routine queries (order status, store hours, book availability) → AI.
* Escalation triggers (refund, billing, etc.) → auto-escalate to operator.
* Trolley switch: humanMode = true → ALL messages go to operator.
*
* LLM resolved via LlmAdapterCascade (same pattern as runArchivist.ts):
* Ollama (localhost) → API key providers (GEMINI_API_KEY, GROQ_API_KEY, etc.)
* Set OLLAMA_BASE_URL to override the default 127.0.0.1:11434.
*
* Three scenarios:
* 1. Routine query — AI composes and sends without parking.
* 2. Escalated query — parks, operator responds, checkpoint/resume.
* 3. Trolley switch — humanMode = true forces operator even for "store hours?".
*
* DAG definition: examples/the-dispatcher/dag.ts
*
* Run: npx tsx examples/32-dispatcher.ts
*/
import {
Checkpoint,
CheckpointRestoreAdapter,
Dagonizer,
} from '@studnicky/dagonizer';
import {
LlmAdapterCascade,
type CatalogueEntryType,
} from '@studnicky/dagonizer/adapter';
import { OllamaApiAdapter } from '@studnicky/dagonizer-adapter-ollama';
import { DispatcherState } from './the-dispatcher/DispatcherState.js';
import { supportDispatcherDAG } from './the-dispatcher/dag.js';
import { AiComposeNode } from './the-dispatcher/nodes/AiComposeNode.js';
import { ClassifyMessageNode } from './the-dispatcher/nodes/ClassifyMessageNode.js';
import { DeclineNode } from './the-dispatcher/nodes/DeclineNode.js';
import { ParkForOperatorNode } from './the-dispatcher/nodes/ParkForOperatorNode.js';
import { SendResponseNode } from './the-dispatcher/nodes/SendResponseNode.js';
import { SetupNode } from './the-dispatcher/nodes/SetupNode.js';
import { DispatcherLlmClient } from './the-dispatcher/providers/DispatcherLlmClient.js';
import type { DispatcherServices } from './the-dispatcher/services.js';
import { UserLanguage } from './the-dispatcher/language/UserLanguage.js';
// ---------------------------------------------------------------------------
// Env helpers
// ---------------------------------------------------------------------------
class Env {
static get(key: string): string {
if (typeof process === 'undefined') return '';
const raw = process.env[key];
return typeof raw === 'string' ? raw : '';
}
}
const OLLAMA_BASE_URL = Env.get('OLLAMA_BASE_URL') || 'http://127.0.0.1:11434';
// ---------------------------------------------------------------------------
// Adapter cascade: local-first, then keyed providers.
// ---------------------------------------------------------------------------
const catalogue: CatalogueEntryType[] = [];
const ollamaAdapter = new OllamaApiAdapter({ 'baseUrl': OLLAMA_BASE_URL });
const resolvedOllamaModel = await ollamaAdapter.selectChatModel({
...(Env.get('OLLAMA_MODEL').length > 0 ? { 'preferred': Env.get('OLLAMA_MODEL') } : {}),
});
if (resolvedOllamaModel !== null) {
catalogue.push({
'descriptor': {
'provider': 'ollama',
'model': resolvedOllamaModel,
'capabilities': { 'toolUse': 'none', 'structuredOutput': false, 'jsonMode': false },
},
'factory': () => ollamaAdapter,
});
}
const cascade = LlmAdapterCascade.create(catalogue);
const adapter = await cascade.select();
process.stdout.write(`\nLLM backend: ${adapter.id} (${adapter.displayName})\n`);
// ---------------------------------------------------------------------------
// Services: wire the LLM adapter into the Dispatcher service bag. This CLI demo
// runs without an on-device embedder, so `intent` is null — ClassifyMessageNode
// classifies via the LLM. The browser runner provisions an embedder and passes
// a DispatcherIntentClassifier here instead.
// ---------------------------------------------------------------------------
// Detect the operator's language (process.env.LANG in this CLI context) so
// composed replies come back in that language rather than always English.
const dispatcherLanguage = UserLanguage.detect();
process.stdout.write(`language: ${dispatcherLanguage} (${UserLanguage.displayName(dispatcherLanguage)})\n`);
const services: DispatcherServices = {
'llm': new DispatcherLlmClient(adapter, { 'language': dispatcherLanguage }),
'intent': null,
};
// ---------------------------------------------------------------------------
// Setup: one dispatcher instance, shared across all three scenarios.
// ---------------------------------------------------------------------------
const dispatcher = new Dagonizer<DispatcherState>();
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();
dispatcher.registerBundle({
'nodes': [setup, classifyMessage, aiCompose, parkForOperator, sendResponse, decline],
'dags': [supportDispatcherDAG],
});
// ---------------------------------------------------------------------------
// Scenario 1: Routine query — AI handles end-to-end
// ---------------------------------------------------------------------------
process.stdout.write('\n=== The Dispatcher: Nocodec Support ===\n\n');
process.stdout.write('--- Scenario 1: Routine query (AI handles) ---\n');
const routineState = new DispatcherState();
routineState.message = 'What are your store hours?';
routineState.language = dispatcherLanguage;
const routineResult = await dispatcher.execute('urn:noocodec:dag:support-dispatcher', routineState);
process.stdout.write(` lifecycle: ${routineResult.state.lifecycle.variant}\n`);
process.stdout.write(` parked: ${routineResult.parked}\n`);
process.stdout.write(` conversation:\n`);
for (const turn of routineResult.state.conversation) {
process.stdout.write(` [${turn.role}] ${turn.text}\n`);
}
// ---------------------------------------------------------------------------
// Scenario 2: Escalated query — parks, operator responds, checkpoint/resume
// ---------------------------------------------------------------------------
process.stdout.write('\n--- Scenario 2: Escalation → park → operator reply → resume ---\n');
const escalatedState = new DispatcherState();
escalatedState.message = 'I need a refund for my last order';
escalatedState.language = dispatcherLanguage;
// Step 2a: Initial execute — should park (escalation triggered)
const parkedResult = await dispatcher.execute('urn:noocodec:dag:support-dispatcher', escalatedState);
process.stdout.write(` Step 2a — Initial run:\n`);
process.stdout.write(` lifecycle: ${parkedResult.state.lifecycle.variant}\n`);
process.stdout.write(` escalationReason: ${parkedResult.state.escalationReason}\n`);
process.stdout.write(` parked.correlationKey: ${parkedResult.parked?.correlationKey}\n`);
process.stdout.write(` parked.cursor: ${parkedResult.parked?.cursor}\n`);
if (parkedResult.parked === null) {
throw new Error('Expected result.parked to be non-null for escalated message');
}
if (parkedResult.state.lifecycle.variant !== 'awaiting-input') {
throw new Error(`Expected lifecycle awaiting-input, got ${parkedResult.state.lifecycle.variant}`);
}
// Step 2b: Capture checkpoint
const ckpt = await Checkpoint.capture('urn:noocodec:dag:support-dispatcher', parkedResult);
const persisted = ckpt.toJson();
process.stdout.write(`\n Step 2b — Checkpoint captured:\n`);
process.stdout.write(` cursor: ${ckpt.data.cursor}\n`);
// Step 2c: Human operator provides response (out-of-band in real apps)
const operatorResponse = "I've processed your refund. It will appear in 3–5 business days. We apologize for any inconvenience!";
process.stdout.write(`\n Step 2c — Operator responds: "${operatorResponse}"\n`);
// Step 2d: Restore checkpoint, inject operator response, resume
const recalled = Checkpoint.load(JSON.parse(persisted));
const { state: resumedState, dagName, cursor } = recalled.restoreState(
CheckpointRestoreAdapter.wrap((snap) => DispatcherState.restore(snap)),
);
// Inject operator response before resume — ParkForOperatorNode checks this
resumedState.response = operatorResponse;
process.stdout.write(`\n Step 2d — Resume from cursor '${cursor}':\n`);
const finalResult = await dispatcher.resume(dagName, resumedState, cursor);
process.stdout.write(` lifecycle: ${finalResult.state.lifecycle.variant}\n`);
process.stdout.write(` parked: ${finalResult.parked}\n`);
process.stdout.write(` conversation:\n`);
for (const turn of finalResult.state.conversation) {
process.stdout.write(` [${turn.role}] ${turn.text}\n`);
}
// ---------------------------------------------------------------------------
// Scenario 3: Trolley switch — humanMode = true forces all to operator
// ---------------------------------------------------------------------------
process.stdout.write('\n--- Scenario 3: Trolley switch (humanMode = true) ---\n');
// Even a benign "store hours" query must go to operator when switch is active.
const trolleyState = new DispatcherState();
trolleyState.message = 'What are your store hours?';
trolleyState.humanMode = true;
trolleyState.language = dispatcherLanguage;
const trolleyParked = await dispatcher.execute('urn:noocodec:dag:support-dispatcher', trolleyState);
process.stdout.write(` Initial run (humanMode=true):\n`);
process.stdout.write(` lifecycle: ${trolleyParked.state.lifecycle.variant}\n`);
process.stdout.write(` escalationReason: ${trolleyParked.state.escalationReason}\n`);
process.stdout.write(` parked.correlationKey: ${trolleyParked.parked?.correlationKey}\n`);
if (trolleyParked.parked === null) {
throw new Error('Expected trolley switch to park even a routine message');
}
// Resume with operator response
const trolleyCkpt = await Checkpoint.capture('urn:noocodec:dag:support-dispatcher', trolleyParked);
const trolleyRecalled = Checkpoint.load(JSON.parse(trolleyCkpt.toJson()));
const { state: trolleyResumedState, dagName: trolleyDagName, cursor: trolleyCursor } =
trolleyRecalled.restoreState(
CheckpointRestoreAdapter.wrap((snap) => DispatcherState.restore(snap)),
);
trolleyResumedState.response = "We're open Monday–Friday 9am–6pm and Saturday 10am–4pm. [Routed by operator per human mode]";
const trolleyFinal = await dispatcher.resume(trolleyDagName, trolleyResumedState, trolleyCursor);
process.stdout.write(`\n Resume (operator handled):\n`);
process.stdout.write(` lifecycle: ${trolleyFinal.state.lifecycle.variant}\n`);
process.stdout.write(` conversation:\n`);
for (const turn of trolleyFinal.state.conversation) {
process.stdout.write(` [${turn.role}] ${turn.text}\n`);
}
process.stdout.write(`\nLesson: park-and-correlate suspends execution without blocking the engine.\n`);
process.stdout.write(` The trolley switch (humanMode) overrides AI routing for all messages.\n`);
process.stdout.write(` Cursor + correlationKey persist the position; resume() re-enters cleanly.\n`);Source files
| File | Role |
|---|---|
examples/the-dispatcher/DispatcherState.ts | State class: message, response, escalationReason, humanMode, conversation |
examples/the-dispatcher/dag.ts | supportDispatcherDAG - canonical JSON-LD DAG |
examples/the-dispatcher/nodes/ClassifyMessageNode.ts | Keyword scan + trolley switch routing |
examples/the-dispatcher/nodes/AiComposeNode.ts | Canned AI reply (no LLM in the demo) |
examples/the-dispatcher/nodes/ParkForOperatorNode.ts | HITL suspension — state.park() on first enter, 'ready' on resume |
examples/the-dispatcher/nodes/SendResponseNode.ts | Appends customer + agent/operator turns to state.conversation |
examples/the-dispatcher/nodes/DeclineNode.ts | Polite off-topic refusal |
examples/the-dispatcher/nodes/SetupNode.ts | Pre-phase: stamps runId |
examples/32-dispatcher.ts | CLI runner: three scenarios |
Details for Nerds
The trolley switch
state.humanMode = true overrides all content classification. Every message, regardless of its keywords, routes to the operator. This models a real-world "all-human" mode: night shift, compliance hold, SLA escalation. The switch is a boolean field on DispatcherState set externally (in the browser demo, the toggle in the Config tab sets it before execute() fires).
The classifier checks the switch first:
if (item.state.humanMode) {
item.state.escalationReason = 'Human mode active — routed to operator';
escalated.push(item);
continue;
}Nothing in the DAG wiring changes; only the classifier's output changes. The same park-for-operator node handles both content-triggered and switch-triggered escalations.
The classification-mode toggle
A second Config-tab control swaps state.classificationMode between 'embedder' (the default) and 'llm':
'embedder'— the on-device MiniLM embedder computes cosine similarity between the message and three intent anchors. Instant, no LLM round-trip. When the embedder is unavailable in the session, or its top score misses the confidence floor, classification transparently routes to the LLM.'llm'— every message is classified generatively via the active LLM adapter. Slower, since each message loads/queries the model.
The toggle exists so the two strategies can be compared side by side in the demo. The trolley switch still wins over both: humanMode = true forces escalate regardless of classificationMode.
The HITL flow in four steps
Step 1 — execute and park:
const state = new DispatcherState();
state.message = 'I need a refund';
const result = await dispatcher.execute('urn:noocodec:dag:support-dispatcher', state);
// result.state.lifecycle.variant === 'awaiting-input'
// result.parked.correlationKey === 'escalation:<ts>'
// result.parked.cursor === 'park-for-operator'Step 2 — capture the checkpoint:
const ckpt = await Checkpoint.capture('urn:noocodec:dag:support-dispatcher', result);
const json = ckpt.toJson(); // persist to any store (localStorage, DB, etc.)Step 3 — operator provides a response (out-of-band):
The operator reads the customer message from result.state.escalationReason (or the conversation) and types a response. In the browser demo this is the text area in the Operator tab.
Step 4 — restore and resume:
const recalled = Checkpoint.load(JSON.parse(json));
const { state: restoredState, dagName, cursor } = recalled.restoreState(
CheckpointRestoreAdapter.wrap((snap) => DispatcherState.restore(snap)),
);
// Inject the operator's response before resuming.
restoredState.response = 'Your refund is processing. 3–5 business days.';
const finalResult = await dispatcher.resume(dagName, restoredState, cursor);
// finalResult.state.lifecycle.variant === 'completed'
// finalResult.state.conversation — contains customer + operator turnsParkForOperatorNode re-enters on resume, sees state.response.length > 0, and routes 'ready'. The flow continues to send-response, which appends both sides of the exchange to state.conversation.
What to watch in the DAG pane
- Routine path:
setup → classify-message → ai-compose → send-response → end. All five nodes fire; the escalation edges never flash. - Escalation path (first execute):
setup → classify-message → park-for-operator. The flow pauses atpark-for-operator; the node stays active (not completed). - Escalation path (after resume):
park-for-operator → send-response → end. The graph resets; resume re-enters at the parked cursor and the remaining edges fire. - Off-topic path:
setup → classify-message → decline → end. The compose and park branches remain dim.
The Trace tab shows every lifecycle event in chronological order: start, end, and the INFO lines the observer emits at key nodes (classify: escalate — ..., park-for-operator: parked, etc.).
Escalation keywords
ClassifyMessageNode uses a single compiled RegExp for the keyword scan:
const ESCALATION_KEYWORDS =
/\b(refund|billing|account|password|charge|complaint|angry|urgent|manager|supervisor)\b/i;Try any of these in the stream input to trigger the escalation path without flipping the trolley switch.
Related Concepts
Read these next when you want to connect the Dispatcher demo to HITL parking, checkpoint capture, resume, and handoff.
- The Archivist - LLM agent orchestration on the same engine
- The Cartographer - Deterministic ETL on the same engine
- Example 31: HITL Park-and-Correlate - The primitive this demo exercises
- Example 08: Checkpoint and Resume - How Checkpoint.capture and resume() work
- HITL Park-and-Correlate - Guide-level explanation of the park/resume protocol
Dispatcher Feature Map
These numbered examples are owned by the Dispatcher domain because they extend support-workflow operations around human input and external triggers:
| Example | Principle in the runnable Dispatcher |
|---|---|
| Example 11: Operator Hand-Off | Hand-off is the next transport step after park-for-operator: a parked or completed support case can publish a DAGHandoff envelope to another host. |
| Example 21: Per-Node Timeout | Timeout belongs around ai-compose and operator-wait boundaries so slow automation fails without aborting unrelated support runs. |
| Example 23: Checkpoint Store | Parked support state uses the same snapshot/store/restore path as checkpoint persistence. |
| Example 28: Runner and Triggers | Runner triggers belong here for scheduled support automation and request-triggered support flows. |
| Example 31: HITL Park-and-Correlate | The browser demo is the high-level HITL implementation: park-for-operator pauses, stores a cursor, and resumes after a correlated decision. |