Skip to content

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
DAG JSON-LD registered with the dispatcher
{
  "@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 generated from the same DAG
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

bash
npm run docs:dev

Open 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.

ts

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.

vue
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.

vue
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 triggerRunner equivalent
Customer Sendrequest/event trigger calls run
Operator Send responserequest/event trigger calls resume
Config togglesseedState input mapping
Conversation panelprojectResult 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.

Watched over by the Order of Dagon.