Example 28: Runner and Triggers
What It Is
Runner and Triggers is the host-side loop around a DAG: register bundles, seed state, execute or resume, then project the result back into the application. The Dispatcher browser runner does this with two UI triggers around the same support DAG: customer send and operator resume.
The point is separation. Trigger handling belongs to the application host; flow decisions belong to the DAG.
How It Works
The runner owns host concerns: constructing the dispatcher, registering node/DAG bundles, mapping external input into state, choosing execute or resume, and projecting the final state back into UI or transport output. The DAG owns only flow decisions. Multiple triggers can therefore drive one canonical JSON-LD graph.
That same loop appears in a browser button, CLI command, HTTP handler, queue worker, cron job, or webhook. Only the trigger adapter changes.
Diagrams, Examples, and Outputs
DAG registration and diagram
The DAG is the same for every trigger; the runner owns when it starts. The Dispatcher is the browser-runnable example: customer send and operator resume are two UI triggers around the same registered support-dispatcher DAG.
support-dispatcher runner 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")))Every application that runs a DAG from a UI button, CLI script, HTTP handler, or event loop independently derives the same loop: build a dispatcher, register bundles, seed initial state, call execute or resume, route the outcome, and project a result. The Dispatcher runner is the browser version of that loop.
Run
npm run docs:devOpen The Dispatcher.
What It Lets You Do
Runners let applications separate trigger handling from DAG behavior. Use this when the same graph must start from a browser event, CLI command, HTTP request, queue message, or resume event while keeping registration, state seeding, execution, and projection in one host boundary.
This keeps the DAG portable. You can move a flow from a demo UI to a service endpoint without rewriting the graph as long as the runner supplies the same bundles, state, and resume contract.
Code Samples
The support DAG
The runnable support DAG classifies the message, composes or parks, and converges on send-response.
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();Browser run trigger
The customer Send button seeds DispatcherState, registers the live nodes and DAG, then executes support-dispatcher.
async function ask(): Promise<void> {
if (isRunning.value || customerQuery.value.trim().length === 0 || activeBackend.value === null) return;
const queryText = customerQuery.value.trim();
customerQuery.value = '';
isRunning.value = true;
terminalVariant.value = 'pending';
parked.value = null;
trace.value = [];
// Seed the stream with THIS run's customer message so it shows immediately and
// the Operator pane quotes the message actually being escalated. Each run uses
// a fresh DispatcherState (empty conversation until send-response/decline
// appends), so without this the pane would surface a prior run's stale turn.
conversation.value = [{ 'role': 'customer', 'text': queryText, 'ts': Date.now() }];
logger.clear();
logger.note(`run start — message: "${queryText}" · humanMode: ${String(humanMode.value)}`);
await dagGraph.value?.reset();
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();
const state = new DispatcherState();
state.message = queryText;
state.humanMode = humanMode.value;
state.classificationMode = classificationMode.value;
state.language = visitorLanguage;
const dispatcher = new DispatcherBrowserObserver(logger, services);
dispatcher.registerBundle({
'nodes': [setup, classifyMessage, aiCompose, parkForOperator, sendResponse, decline],
'dags': [supportDispatcherDAG],
});
activeAbortController = new AbortController();
try {
await dispatcher.execute('support-dispatcher', state, { 'signal': activeAbortController.signal });
} catch (error) {
logger.error(
LogFault.create()
.component('dispatcher-runner')
.operation('execute')
.status('failed')
.name(error instanceof Error ? error.constructor.name : 'Error')
.message(`execute error: ${error instanceof Error ? error.message : String(error)}`)
.context({})
.build(),
);
} finally {
await dispatcher.destroy();
activeAbortController = null;
isRunning.value = false;
}
}Browser resume trigger
The operator Send response button restores the parked checkpoint and resumes the same DAG from the parked cursor.
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';
}
}Trigger mapping
The same runner pattern applies outside the browser:
| Browser trigger | Runner equivalent |
|---|---|
| Customer Send | request/event trigger calls run |
| Operator Send response | request/event trigger calls resume |
| Config toggles | seedState input mapping |
| Conversation panel | projectResult view projection |
Details for Nerds
- Run loop ownership. The browser runner owns dispatcher construction, bundle registration, state seeding, execution, and projection.
- Separate triggers, same DAG. Customer send and operator resume trigger different entry actions around the same DAG document.
- Resume path. The runner captures and restores checkpoint state before calling
dispatcher.resume. - Import path. The reusable class-based runner surface for non-browser adapters ships through
@studnicky/dagonizer/runner.
Related Concepts
- Reference: Runner - Full API surface for DagRunner and all trigger variants
- Reference: Contracts - TriggerInterface adapter contract
- Example 08: Checkpoint and Resume - DagRunner.resume() picks up from a checkpoint cursor
- Authoring DAGs