Example 32: Dispatcher CLI
What It Is
Dispatcher CLI proves the support DAG is not a browser-only demo. The command-line runner exercises the same flow as The Dispatcher page: routine handling, escalation parking, checkpoint capture, operator resume, and the human-mode trolley switch.
The graph stays the same. The trigger and projection layer changes from Vue state to terminal output.
How It Works
The CLI constructs the same dispatcher, registers the same node bundle, seeds state from scripted inputs, and calls execute or resume exactly as the browser runner does. The only difference is trigger source and projection target: terminal output replaces Vue state.
That makes it a useful template for server handlers. Replace scripted CLI inputs with an HTTP request, queue message, or scheduled job and the DAG contract remains unchanged.
Diagrams, Examples, and Outputs
DAG registration and diagram
The command-line Dispatcher runner proves the browser hand-off flow is not tied to the Vue demo. It registers the same support-dispatcher DAG, executes a routine customer message, parks an escalated message, captures a checkpoint, injects an operator response, resumes from the parked cursor, and then repeats the flow with the human-mode trolley switch enabled.
The CLI and browser runner use the same canonical JSON-LD DAG. The graph shape is unchanged across UI, CLI, and server handlers; only the trigger and service wiring differ.
support-dispatcher CLI 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")))Run
npx tsx examples/32-dispatcher.tsUse this page when you want the Dispatcher behavior without the browser UI, or when you need a compact script for adapting the support flow to a server handler.
What It Lets You Do
The Dispatcher CLI lets applications run the same HITL support flow without the browser UI. Use it as the compact proof that the support DAG is portable across browser triggers, command-line scripts, and server handlers.
It is also the shortest path for debugging the support workflow: no DOM, no panels, just registration, execution, checkpoint, resume, and printed outcomes.
Code Samples
The CLI runner is the complete runnable scenario file. It wires the LLM adapter cascade, registers the Dispatcher nodes, runs three scenarios, and demonstrates checkpoint/resume without a browser.
/**
* 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`);Details for Nerds
- Same DAG, different trigger. Browser buttons and CLI scenarios both call
dispatcher.executeanddispatcher.resumearound the same registered DAG. - Routine path. A normal support question routes through
ai-composeandsend-responsewithout parking. - Escalation path. Refund and billing messages park at
park-for-operator, capture a checkpoint, and resume after an operator response is written into state. - Trolley switch.
humanMode = trueforces even routine messages to the operator path, making the human gate explicit and testable. - Provider wiring. The CLI resolves an LLM adapter through the same adapter cascade pattern used by the runnable demos.
Related Concepts
- The Dispatcher - browser-runnable version of the same support flow
- Example 31: HITL Park-and-Correlate - parked result, checkpoint capture, and resume mechanics
- Example 28: Runner and Triggers - UI trigger model for customer send and operator resume
- Guide: HITL Park-and-Correlate