DAGBuilder
What It Is
DAGBuilder is the chainable TypeScript API for writing a Dagonizer graph in code. Each call appends a placement with an explicit IRI to the JSON-LD DAG document, and each route map is checked against the node's declared output union before the graph ever runs.
Use it when your workflow belongs in source control beside the nodes it invokes. The result is still the canonical JSON-LD DAG, so builder-authored DAGs can be serialized, visualized, embedded, packaged as plugins, and registered by the dispatcher without conversion.
How It Works
Every builder method appends a typed placement to an in-memory DAG document. The builder tracks the first placement IRI as the default entrypoint, narrows route maps from node output unions, emits JSON-LD placement types, materializes route targets to placement IRIs, and returns a plain DAG from .build(). The dispatcher registers that returned object directly.
DAGBuilder is the chainable TypeScript factory for JSON-LD DAG documents: ETL pipelines, transformation chains, agent loops, embedded DAGs, scatter bodies, and fixed sequences all use the same surface. Each .node() call narrows the routes map from the node TOutput union, so misspelled or missing routes are compile errors before the DAG runs.
See Authoring DAGs for how DAGBuilder emits the canonical JSON-LD DAG object that serialization, validation, visualization, and dispatch all consume.
Diagrams, Examples, and Outputs
Type-safe output routing
When the node declares a narrow TOutput union, .node() enforces exhaustive routing at compile time:
// ClassifyNode declares TOutput = 'on_topic' | 'off_topic'.
// Every key of the union must appear in the routes map — TypeScript enforces
// exhaustiveness. A missing key is a compile error before the DAG runs.
export const typeSafeRoutingDag = new DAGBuilder(typeSafeDAGIri, '1')
.node(placement(typeSafeDAGIri, 'classify'), new ClassifyNode(), {
on_topic: placement(typeSafeDAGIri, 'respond'),
off_topic: placement(typeSafeDAGIri, 'respond'),
// omitting either key ↑ is a TS compile error: property missing in routes
})
.node(placement(typeSafeDAGIri, 'respond'), new RespondNode(), { success: placement(typeSafeDAGIri, 'end') })
.terminal(placement(typeSafeDAGIri, 'end'))
.build();The same runnable source builds the JSON-LD DAG below. The Mermaid diagram is generated from that object, so route changes in code change both panes together:
Example 02 builder DAG
3 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:chat",
"@type": "DAG",
"name": "dag:chat",
"version": "1",
"entrypoints": {
"main": "urn:noocodec:dag:chat/node/classify"
},
"nodes": [
{
"@id": "urn:noocodec:dag:chat/node/classify",
"@type": "SingleNode",
"name": "dag:chat/node/classify",
"node": "urn:noocodec:node:classify",
"outputs": {
"on_topic": "urn:noocodec:dag:chat/node/respond",
"off_topic": "urn:noocodec:dag:chat/node/respond"
}
},
{
"@id": "urn:noocodec:dag:chat/node/respond",
"@type": "SingleNode",
"name": "dag:chat/node/respond",
"node": "urn:noocodec:node:respond",
"outputs": {
"success": "urn:noocodec:dag:chat/node/end"
}
},
{
"@id": "urn:noocodec:dag:chat/node/end",
"@type": "TerminalNode",
"name": "dag:chat/node/end",
"outcome": "completed"
}
]
}Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
%% dag:chat (v1)
entry_main(["main"])
entry_main --> urn_noocodec_dag_chat/node/classify
urn_noocodec_dag_chat/node/classify["dag:chat/node/classify"]
urn_noocodec_dag_chat/node/classify -->|on_topic| urn_noocodec_dag_chat/node/respond
urn_noocodec_dag_chat/node/classify -->|off_topic| urn_noocodec_dag_chat/node/respond
urn_noocodec_dag_chat/node/respond["dag:chat/node/respond"]
urn_noocodec_dag_chat/node/respond -->|success| urn_noocodec_dag_chat/node/end
urn_noocodec_dag_chat/node/end((("dag:chat/node/end"))).placeholder(iri, outputs, routes)
Register a PlaceholderNode stub in one call. The node routes every execution to the first declared output unconditionally. Use during development to stub an unimplemented placement; replace with a concrete MonadicNode subclass when ready.
import { DAGBuilder } from '@studnicky/dagonizer';
const dagIri = 'workflow:my-dag';
const processIri = `${dagIri}/node/process`;
const endIri = `${dagIri}/node/end`;
const dag = new DAGBuilder(dagIri, '1')
.placeholder(processIri, ['success', 'error'], { success: endIri, error: endIri }, { name: 'process' })
.terminal(endIri, { name: 'end', outcome: 'completed' })
.build();Import: PlaceholderNode is available from @studnicky/dagonizer if you need to construct one directly.
What It Lets You Do
Use when
Use DAGBuilder when the DAG lives in TypeScript source and you want route maps, embedded DAG references, scatter bodies, phase nodes, and terminal placement IRIs checked before runtime. Use raw JSON-LD loading when the graph is external configuration.
Code Samples
Basic usage
Example 02 registers two nodes and builds a two-step chat flow:
import {
Batch,
DAGBuilder,
DAGIdentity,
MonadicNode,
NodeOutput,
NodeStateBase,
RoutedBatch,
} from '@studnicky/dagonizer';
import type { SchemaObjectType } from '@studnicky/dagonizer';export class ClassifyNode extends MonadicNode<ChatState, 'on_topic' | 'off_topic'> {
readonly name = 'classify';
readonly '@id' = 'urn:noocodec:node:classify';
readonly outputs = ['on_topic', 'off_topic'] as const;
override get outputSchema(): Record<'on_topic' | 'off_topic', SchemaObjectType> {
return { 'on_topic': { 'type': 'object' }, 'off_topic': { 'type': 'object' } };
}
override async execute(batch: Batch<ChatState>) {
const entries: Array<readonly ['on_topic' | 'off_topic', Batch<ChatState>]> = [];
for (const item of batch) {
const state = item.state;
state.topic = state.input.toLowerCase().includes('weather') ? 'off_topic' : 'on_topic';
const output = NodeOutput.create(state.topic);
for (const error of output.errors) state.collectError(error);
entries.push([output.output, Batch.from([item])]);
}
return RoutedBatch.create(entries);
}
}
export class RespondNode extends MonadicNode<ChatState, 'success'> {
readonly name = 'respond';
readonly '@id' = 'urn:noocodec:node:respond';
readonly outputs = ['success'] as const;
override get outputSchema(): Record<'success', SchemaObjectType> {
return { 'success': { 'type': 'object' } };
}
override async execute(batch: Batch<ChatState>) {
for (const item of batch) {
const state = item.state;
state.reply = state.topic === 'on_topic'
? `Echo: ${state.input}`
: `I only talk about coding, not the weather.`;
}
return RoutedBatch.create(NodeOutput.create('success').output, batch);
}
}export const dag = new DAGBuilder(chatDAGIri, '1')
// First .node() call → entrypoint is set to 'classify' automatically.
.node(placement(chatDAGIri, 'classify'), new ClassifyNode(), {
"on_topic": placement(chatDAGIri, 'respond'),
"off_topic": placement(chatDAGIri, 'respond'),
})
// routes for 'respond' must cover exactly { success }, no more, no less.
.node(placement(chatDAGIri, 'respond'), new RespondNode(), { "success": placement(chatDAGIri, 'end') })
.terminal(placement(chatDAGIri, 'end'))
.build(); // materialises the canonical JSON-LD DAG documentconst dispatcher = new Dagonizer<ChatState>();
dispatcher.registerNode(new ClassifyNode());
dispatcher.registerNode(new RespondNode());
dispatcher.registerDAG(dag); // same API as with a literal; build() returns a valid DAG
const state = new ChatState();
state.input = 'What is a generic type parameter?';
await dispatcher.execute(dagIri, state);
process.stdout.write('\nBuilder DAG: same shape as 01-linear, constructed via DAGBuilder (02-builder)\n');
process.stdout.write(` input: "${state.input}"\n`);
process.stdout.write(` reply: "${state.reply}"\n`);
process.stdout.write(`\n built DAG @id: ${dag['@id']}\n`);
process.stdout.write(` nodes: ${dag.nodes.map(n => `${n['@type']}(${n.name})`).join(' → ')}\n`);
process.stdout.write('\nLesson: DAGBuilder.build() produces the canonical JSON-LD DAG document;\n');
process.stdout.write(' routes are exhaustiveness-checked.\n');The first .node() call sets the main entrypoint automatically. Call .entrypoints({ main: placementIri }) to override or declare multiple entry roots.
Details for Nerds
Scatter
.scatter() places a ScatterNode in the parent flow. A scatter isolates a state clone per source item, runs a body (a registered node or a sub-DAG) in the clone, emits clone outcome records, and routes on the aggregate outcome via an outcome reducer. source is a required positional argument. Fan-in belongs to a separate GatherNode; scatter emits records and the gather folds them under its own placement IRI.
Fan-in is a first-class .gather() placement. If a fan-out is side-effect-only, route scatter directly to the next placement and skip the gather:
// Side-effect-only fan-out: no GatherNode is wired after scatter, so clone
// state does not flow back into the parent.
export const scatterDiscardDag = new DAGBuilder(notifyDAGIri, '1')
.scatter(
placement(notifyDAGIri, 'fan-out'),
'targets',
new NotifyNode(),
{
'all-success': placement(notifyDAGIri, 'end'),
'partial': placement(notifyDAGIri, 'end'),
'all-error': placement(notifyDAGIri, 'end'),
'empty': placement(notifyDAGIri, 'end'),
},
{
execution: { mode: 'item', concurrency: 10 },
},
)
.terminal(placement(notifyDAGIri, 'end'))
.build();Heterogeneous fan-out — running different logic per item — is expressed by authoring the source as a descriptor array and writing a body node that dispatches on state.metadata.currentItem:
// Heterogeneous fan-out: ScoutDispatchNode reads state.metadata.currentItem
// to route per-provider logic. The engine is indifferent to whether each clone
// runs identical or different logic; that is the implementer's choice.
export const scatterHeterogeneousDag = new DAGBuilder(searchDAGIri, '1')
.scatter(
placement(searchDAGIri, 'scout'),
'scoutProviders', // state field: ['openlibrary', 'googlebooks', 'wikipedia']
new ScoutDispatchNode(),
{
'any-success': placement(searchDAGIri, 'collect-scout-results'),
'all-error': placement(searchDAGIri, 'end'),
'empty': placement(searchDAGIri, 'end'),
},
{
reducer: 'any-success',
execution: { mode: 'item', concurrency: 3 },
},
)
.gather(
placement(searchDAGIri, 'collect-scout-results'),
{ [placement(searchDAGIri, 'scout')]: {} },
{ strategy: 'collect', target: 'scoutResults' },
{
success: placement(searchDAGIri, 'merge'),
error: placement(searchDAGIri, 'end'),
empty: placement(searchDAGIri, 'end'),
},
)
.node(placement(searchDAGIri, 'merge'), new MergeNode(), { success: placement(searchDAGIri, 'end') })
.terminal(placement(searchDAGIri, 'end'))
.build();scoutDispatchNode reads state.metadata.currentItem and routes to the matching scout logic. Whether bodies are identical or all different is the implementer's choice; the engine is indifferent.
Generate-collect pattern
Each source item gets one clone. After all clones finish, the gather.mapping writes produced artifacts back in source-index order:
// Generate-collect: each clone produces one artifact; gather.mapping writes
// produced artifacts back to the parent keyed by source index. The 'candidate'
// clone field accumulates into the parent's 'candidates' array.
export const scatterMapDag = new DAGBuilder(batchDAGIri, '1')
.scatter(
placement(batchDAGIri, 'generate'),
'providers',
new GenerateNode(),
{
'all-success': placement(batchDAGIri, 'collect-candidates'),
'partial': placement(batchDAGIri, 'collect-candidates'),
'all-error': placement(batchDAGIri, 'end'),
'empty': placement(batchDAGIri, 'end'),
},
{
execution: { mode: 'item', concurrency: 4 },
},
)
.gather(
placement(batchDAGIri, 'collect-candidates'),
{ [placement(batchDAGIri, 'generate')]: {} },
{ strategy: 'map', mapping: { 'candidate': 'candidates' } },
{
success: placement(batchDAGIri, 'select'),
error: placement(batchDAGIri, 'end'),
empty: placement(batchDAGIri, 'end'),
},
)
.node(placement(batchDAGIri, 'select'), new SelectNode(), { success: placement(batchDAGIri, 'end') })
.terminal(placement(batchDAGIri, 'end'))
.build();gather.strategy: 'partition' groups clones by their output token:
// gather.strategy 'partition' groups clone results by their output token.
// Each partition key maps to a parent-state field that receives matching clones.
export const scatterPartitionDag = new DAGBuilder(batchDAGIri, '1')
.scatter(
placement(batchDAGIri, 'process-items'),
'items',
new ProcessNode(),
{
'all-success': placement(batchDAGIri, 'partition-results'),
'partial': placement(batchDAGIri, 'partition-results'),
'all-error': placement(batchDAGIri, 'partition-results'),
'empty': placement(batchDAGIri, 'end'),
},
{
execution: { mode: 'item', concurrency: 4 },
},
)
.gather(
placement(batchDAGIri, 'partition-results'),
{ [placement(batchDAGIri, 'process-items')]: {} },
{ strategy: 'partition', partitions: { success: 'processed', error: 'failed' } },
{
success: placement(batchDAGIri, 'end'),
error: placement(batchDAGIri, 'end'),
empty: placement(batchDAGIri, 'end'),
},
)
.terminal(placement(batchDAGIri, 'end'))
.build();The full signature is shown in the scatter placement example:
export const dag: DAGType = {
'@context': DAG_CONTEXT,
'@id': 'urn:noocodec:dag:scrape',
'@type': 'DAG',
"name": 'scrape',
"version": '1',
"entrypoints": { "main": 'urn:noocodec:dag:scrape/node/probe-all' },
"nodes": [
{
'@id': 'urn:noocodec:dag:scrape/node/probe-all',
'@type': 'ScatterNode', // iterate source, run node per clone
"name": 'probe-all',
"body": { "node": 'urn:noocodec:node:probe' }, // registered node IRI invoked per clone
"source": 'urls', // state field to read the items array from
"itemKey": 'url', // metadata key each item is written under
"execution": { "mode": "item", "concurrency": 2 }, // max clones in-flight simultaneously
// Aggregate outputs: reflect final distribution, not per-clone results.
// all-success: every clone returned 'ok'
// partial: mix of ok and fail
// all-error: every clone returned 'fail'
// empty: source array was empty
"outputs": {
'all-success': 'urn:noocodec:dag:scrape/node/gather-probes',
"partial": 'urn:noocodec:dag:scrape/node/gather-probes',
'all-error': 'urn:noocodec:dag:scrape/node/gather-probes',
"empty": 'urn:noocodec:dag:scrape/node/end',
},
},
{
'@id': 'urn:noocodec:dag:scrape/node/gather-probes',
'@type': 'GatherNode',
"name": 'gather-probes',
sources: { "urn:noocodec:dag:scrape/node/probe-all": {} },
"gather": {
"strategy": GatherStrategyNames.PARTITION, // route clones by their output key
"partitions": { "ok": 'succeeded', "fail": 'failed' }, // output key → state field name
},
"outputs": {
"success": 'urn:noocodec:dag:scrape/node/end',
"error": 'urn:noocodec:dag:scrape/node/end',
"empty": 'urn:noocodec:dag:scrape/node/end',
},
},
{
'@id': 'urn:noocodec:dag:scrape/node/end',
'@type': 'TerminalNode',
"name": 'end',
"outcome": 'completed',
},
],
};ScatterOptionsType<TState>:
| Field | Type | Description |
|---|---|---|
itemKey? | string | Metadata key the clone reads for the current item. Default 'currentItem'. |
execution? | { mode: 'item', concurrency?, throttle? } | { mode: 'reservoir', concurrency?, reservoir } | Unified concurrency-limiting policy — the exact wire shape ScatterNode.execution accepts. Default: { mode: 'item', concurrency: 1 }. See ScatterNode for the full item vs reservoir semantics. |
inputs? | Partial<Record<string, Path<TState>>> | Parent → clone field copy before the body runs. Becomes stateMapping.input on the entity. Keys are child-state keys; values are parent-state dotted paths. |
reducer? | string | Outcome reducer name. Defaults to 'aggregate'. Built-in reducers: 'aggregate', 'terminal', 'all-success', 'any-success'. Custom reducers registered via OutcomeReducers.register are referenceable by name. |
Path<T> enumerates valid dotted-path strings over a state shape recursively. For example Path<{ user: { name: string; age: number } }> resolves to 'user' | 'user.name' | 'user.age'. Arrays contribute ${number} and ${number}.${ElementPath} paths. The depth cap is 8 levels; deeper nesting resolves to string. The type is exported from the @studnicky/dagonizer/builder subpath.
The inputs option in a scatter call uses Path<TState> to constrain parent dotted paths at compile time:
// inputs copies parent-state fields into each clone before the body runs.
// Keys are child-state field names; values are parent-state dotted paths.
// Path<TState> enumerates valid dotted paths (e.g. 'user', 'user.name').
export const scatterInputsDag = new DAGBuilder(chatDAGIri, '1')
.scatter(
placement(chatDAGIri, 'classify-all'),
'inputs',
new ClassifyNode(),
{
'all-success': placement(chatDAGIri, 'respond'),
'partial': placement(chatDAGIri, 'respond'),
'all-error': placement(chatDAGIri, 'end'),
'empty': placement(chatDAGIri, 'end'),
},
{
inputs: { 'input': 'input' }, // clone.input ← parent.input (dotted path)
},
)
.node(placement(chatDAGIri, 'respond'), new RespondNode(), { success: placement(chatDAGIri, 'end') })
.terminal(placement(chatDAGIri, 'end'))
.build();When body is a NodeInterface, the impl is registered automatically and the placement emits body: { node: body.name }.
When body is { dag: 'workflow:child' }, the placement runs a full registered sub-DAG per clone. The value is a DAG IRI or CURIE resolved through the DAG context, not a display name. Pair with the container key on the raw scatter entity to dispatch each clone to an isolate (worker thread, child process). See Distribution and Cloud for the DagContainerBase authoring guide and the DagonizerOptionsType.containers binding.
For patterns where nodes across multiple scatter placements accumulate to shared mutable state (agent memory, audit log), see Shared state.
Embedded DAG
.embed() places an EmbeddedDAGNode in the parent flow. It invokes a registered sub-DAG exactly once (cardinality 1) and routes the parent on the child's terminal outcome (success | error). options.inputs seeds the child from the parent before it runs; options.outputs copies child fields back into the parent after the child completes.
Use .embed() for the unified DAG reference surface:
- a DAG IRI or CURIE string
- a
DAGobject - a runtime reference object
{ from: 'state', path: 'state.path', candidates: [...] }
// Child DAG literal: routes 'done' to a TerminalNode (well-formed).
export const childDAG: DAGType = {
'@context': DAG_CONTEXT,
'@id': childTerminalsDagIri,
'@type': 'DAG',
"name": 'child-for-terminals',
"version": '1',
"entrypoints": { "main": placement(childTerminalsDagIri, 'child-work') },
"nodes": [
{
'@id': 'urn:noocodec:dag:child-for-terminals/node/child-work',
'@type': 'SingleNode',
"name": 'child-work',
"node": 'urn:noocodec:node:child-work',
"outputs": { "done": placement(childTerminalsDagIri, 'child-end') },
},
{
'@id': 'urn:noocodec:dag:child-for-terminals/node/child-end',
'@type': 'TerminalNode',
"name": 'child-end',
"outcome": 'completed',
},
],
};
export const dag4 = new DAGBuilder(embeddedTerminalsDagIri, '1')
.embed<GateState, GateState>(placement(embeddedTerminalsDagIri, 'run'), childTerminalsDagIri, {
'success': placement(embeddedTerminalsDagIri, 'end-ok'),
'error': placement(embeddedTerminalsDagIri, 'end-fail'),
}, {
// Seed the child's shouldPass from parent state before the child DAG runs.
'inputs': { 'shouldPass': 'shouldPass' },
})
.terminal(placement(embeddedTerminalsDagIri, 'end-ok'))
.terminal(placement(embeddedTerminalsDagIri, 'end-fail'), { outcome: 'failed' })
.build();The pattern with inputs and outputs field mapping is shown in the embedded DAG parent:
export const parent: DAGType = {
'@context': DAG_CONTEXT,
'@id': parentDAGIri,
'@type': 'DAG',
"name": 'parent',
"version": '1',
"entrypoints": { "main": placement(parentDAGIri, 'invoke') },
"nodes": [
{
'@id': placement(parentDAGIri, 'invoke'),
'@type': 'EmbeddedDAGNode', // nested DAG invocation, cardinality 1
"name": 'invoke',
"dag": childDAGIri, // run the registered child DAG
// stateMapping: seeds child fields from parent, and copies child fields back
"stateMapping": {
// inputs: seeds child state before the body runs
// { childKey: parentPath }
"input": { "payload": 'seed' }, // child.payload ← parent.seed
// outputs: writes child fields back to parent after the body returns
// { parentPath: childKey }
"output": { "result": 'payload' }, // parent.result ← child.payload
},
// Routes for the EmbeddedDAGNode outcome (success / error)
"outputs": { "success": placement(parentDAGIri, 'end'), "error": placement(parentDAGIri, 'end-error') },
},
{
'@id': placement(parentDAGIri, 'end'),
'@type': 'TerminalNode',
"name": 'end',
"outcome": 'completed',
},
{
'@id': placement(parentDAGIri, 'end-error'),
'@type': 'TerminalNode',
"name": 'end-error',
"outcome": 'failed',
},
],
};TypedEmbeddedDAGOptionsType<TChildState, TParentState>:
| Field | Type | Description |
|---|---|---|
inputs? | Partial<Record<keyof TChildState, ParentPath<TParentState>>> | Child-state key → parent dotted path. Copied into the child before it runs. |
outputs? | Partial<Record<ParentPath<TParentState>, keyof TChildState>> | Parent dotted path → child-state key. Copied back into the parent after the child completes. |
Supply TChildState and TParentState to narrow path strings at compile time; both default to NodeStateInterface, which accepts any string.
EmbeddableDAGType is the public input type for .embed():
type EmbeddableDAGType =
| string
| DAGType
| {
readonly from: 'state' | 'item';
readonly path: string;
readonly candidates: readonly [string, ...string[]];
};Normalization is direct:
stringbecomes{ dag: value }DAGTypebecomes{ dag: value['@id'] }{ from, path, candidates }becomes{ dag: { '@type': 'DagReference', from, path, candidates } }
That means application code can treat embedded local DAGs, plugin-exported DAGs, tool DAGs, and runtime-selected DAGs the same way. The builder always emits the canonical EmbeddedDAGNode JSON-LD shape.
Runtime DAG resolution with DagReference
Both .scatter() and .embed() accept a runtime-resolved DAG reference in addition to a build-time IRI string. This is the engine's primitive for recursion, tools-as-DAGs, plugin composition, and self-reference: the DAG to run is chosen from state or item data at execution time rather than being hard-coded at authoring time. Dynamic references always declare their candidate DAG IRI set so registration, validation, graph projection, plugin discovery, and runtime dispatch agree.
.embed() with a state-sourced DagReference
Pass { from: 'state', path: 'statePath', candidates: [...] } as the dag argument. At execution time the engine reads the dotted state path and resolves the resulting string as a registered DAG IRI. If the resolved IRI is outside the candidate set or unregistered, the placement routes to error without throwing.
// The DAG to invoke is stored in state.selectedDag at runtime.
const dagIri = 'workflow:parent';
const p = (placement: string) => `${dagIri}/node/${placement}`;
builder.embed(
p('invoke'),
{ from: 'state', path: 'selectedDag', candidates: ['workflow:child-a', 'workflow:child-b'] },
{ success: p('next'), error: p('end-fail') },
);.scatter() with an item-sourced DagReference as the body
Pass { dag: { from: 'item', path: 'targetDag', candidates: [...] } } as the body argument. Each scatter clone resolves the item path to a DAG IRI and runs that DAG as its body. Values outside the candidate set route the clone to error.
const dagIri = 'workflow:fan-out';
const p = (placement: string) => `${dagIri}/node/${placement}`;
builder.scatter(
p('fan-out'),
'items',
{ dag: { from: 'item', path: 'targetDag', candidates: ['workflow:child-a', 'workflow:child-b'] } },
{ 'all-success': p('merge'), 'all-error': p('end-fail'), partial: p('merge'), empty: p('end') },
).gather(
p('merge'),
{ [p('fan-out')]: {} },
{ strategy: 'discard' },
{ success: p('end'), error: p('end-fail'), empty: p('end') },
);Both variants are the engine's only recursive primitive: a node can write a DAG IRI into state (or inherit one from its placement's source item) and the engine resolves it at the point of invocation. This enables trampoline flows and polymorphic fan-out without hard-coded route branches in the topology.
.terminal(name, options?)
export const dag1 = new DAGBuilder(completedDagIri, '1')
.node(placement(completedDagIri, 'step-a'), new StepANode(), {
'ok': placement(completedDagIri, 'end'),
})
.terminal(placement(completedDagIri, 'end')) // outcome defaults to 'completed'
.build();Appends a TerminalNode placement. When the engine reaches it, the flow ends with the declared outcome. The default is 'completed'. Passing { outcome: 'failed' } marks the state as failed before resolving.
TerminalNodes carry no outputs map. They are placement-only constructs with no backing NodeInterface. Every output of every node in a DAG must route to another placement IRI; a TerminalNode placement is the only valid flow endpoint.
Routing embedded-DAG outputs to a terminal placement
An EmbeddedDAGNode placement targets terminal placement IRIs directly:
// Child DAG literal: routes 'done' to a TerminalNode (well-formed).
export const childDAG: DAGType = {
'@context': DAG_CONTEXT,
'@id': childTerminalsDagIri,
'@type': 'DAG',
"name": 'child-for-terminals',
"version": '1',
"entrypoints": { "main": placement(childTerminalsDagIri, 'child-work') },
"nodes": [
{
'@id': 'urn:noocodec:dag:child-for-terminals/node/child-work',
'@type': 'SingleNode',
"name": 'child-work',
"node": 'urn:noocodec:node:child-work',
"outputs": { "done": placement(childTerminalsDagIri, 'child-end') },
},
{
'@id': 'urn:noocodec:dag:child-for-terminals/node/child-end',
'@type': 'TerminalNode',
"name": 'child-end',
"outcome": 'completed',
},
],
};
export const dag4 = new DAGBuilder(embeddedTerminalsDagIri, '1')
.embed<GateState, GateState>(placement(embeddedTerminalsDagIri, 'run'), childTerminalsDagIri, {
'success': placement(embeddedTerminalsDagIri, 'end-ok'),
'error': placement(embeddedTerminalsDagIri, 'end-fail'),
}, {
// Seed the child's shouldPass from parent state before the child DAG runs.
'inputs': { 'shouldPass': 'shouldPass' },
})
.terminal(placement(embeddedTerminalsDagIri, 'end-ok'))
.terminal(placement(embeddedTerminalsDagIri, 'end-fail'), { outcome: 'failed' })
.build();When the child DAG exits with a failed terminal, the error output arrives at the end-fail placement IRI, which marks the parent flow failed. Without a terminal placement, the author would need a dedicated SingleNode to call state.markFailed(). The terminal collapses that to one .terminal(placementIri, { outcome: 'failed' }) call.
Example, two explicit terminals
export const dag2 = new DAGBuilder(explicitTerminalsDagIri, '1')
.node(placement(explicitTerminalsDagIri, 'check'), new CheckNode(), {
'pass': placement(explicitTerminalsDagIri, 'end-ok'),
'fail': placement(explicitTerminalsDagIri, 'end-fail'),
})
.terminal(placement(explicitTerminalsDagIri, 'end-ok'))
.terminal(placement(explicitTerminalsDagIri, 'end-fail'), { outcome: 'failed' })
.build();Running with state.shouldPass = true produces lifecycle.variant = 'completed'; running with false produces 'failed'.
.phase(name, phase, node)
export const dagIri = 'urn:noocodec:dag:phase-demo' as const;
const placement = (placementIdentifier: string): string => DAGIdentity.placementId(dagIri, placementIdentifier);
const preSetupNode = new PreSetupNode();
const computeNode = new ComputeNode();
const postAuditNode = new PostAuditNode();
export const dag = new DAGBuilder(dagIri, '1')
// 'pre' phase: runs before the entrypoint in declaration order.
.phase(placement('setup'), 'pre', preSetupNode)
// Main loop: compute is the entrypoint (first .node() call).
.node(placement('compute'), computeNode, { 'done': placement('end') })
.terminal(placement('end'))
// 'post' phase: runs after the main loop drains on every exit path.
.phase(placement('audit'), 'post', postAuditNode)
.build();Appends a PhaseNode placement: a lifecycle-attached task that runs around the main DAG loop rather than inside it. phase: 'pre' placements run before the entrypoint in DAG declaration order. phase: 'post' placements run after the main loop drains, in DAG declaration order, on every exit path (completion, abort, timeout, terminal-failed, node throw).
PhaseNodes carry no outputs. They never route to other placements. They are not the main-loop entrypoint either; .phase() deliberately does not set entrypoint.
Pre-phase semantics
Pre-phase placements run before the entrypoint. They can mutate state and the entrypoint observes those mutations. If a pre-phase throws, the run aborts: lifecycle becomes failed, the main loop never executes, and post-phases still run (so cleanup work attached to post still gets a chance).
Post-phase semantics
Post-phase placements run after the main loop drains. They run on every exit path. If a post-phase throws, the engine collects a warning on state (code: 'POST_PHASE_FAILED') and continues to the next post-phase. The lifecycle is not changed.
ExecutionResult.executedNodes ordering
Pre-phase names appear at the start of executedNodes; post-phase names appear at the end (only when the placement completed successfully). Main-loop nodes appear in between.
Phase hooks
The dispatcher invokes onPhaseEnter(dagName, 'pre' | 'post', placementName, state) immediately before each phase placement runs and onPhaseExit(...) immediately after. Override these protected methods in a Dagonizer subclass to observe phase boundaries. See Observability.
Example
export const dagIri = 'urn:noocodec:dag:phase-demo' as const;
const placement = (placementIdentifier: string): string => DAGIdentity.placementId(dagIri, placementIdentifier);
const preSetupNode = new PreSetupNode();
const computeNode = new ComputeNode();
const postAuditNode = new PostAuditNode();
export const dag = new DAGBuilder(dagIri, '1')
// 'pre' phase: runs before the entrypoint in declaration order.
.phase(placement('setup'), 'pre', preSetupNode)
// Main loop: compute is the entrypoint (first .node() call).
.node(placement('compute'), computeNode, { 'done': placement('end') })
.terminal(placement('end'))
// 'post' phase: runs after the main loop drains on every exit path.
.phase(placement('audit'), 'post', postAuditNode)
.build();.entrypoints()
By default the first added node is the entrypoint. Override explicitly:
// By default the first .node() call sets the entrypoint automatically.
// Call .entrypoints({ main: placementIri }) to override — useful when resuming from a mid-flow
// checkpoint or when adding setup nodes that should be skipped on replay.
export const entrypointOverrideDag = new DAGBuilder(demoDAGIri, '1')
.node(placement(demoDAGIri, 'setup'), new SetupNode(), { success: placement(demoDAGIri, 'main') })
.node(placement(demoDAGIri, 'main'), new MainNode(), { success: placement(demoDAGIri, 'end') })
.entrypoints({ main: placement(demoDAGIri, 'main') }) // skip 'setup' on resume; 'main' becomes the entry
.terminal(placement(demoDAGIri, 'end'))
.build();.build()
build() materialises the accumulated nodes and returns a DAG. It throws an Error if no entrypoint has been set (no placements added and .entrypoints(...) not called).
The returned object is identical to one written by hand. Pass it directly to dispatcher.registerDAG().
Related Concepts
- Authoring DAGs - DAGBuilder as the code factory for JSON-LD DAG documents
- Subclassing state - define the state class your nodes mutate
- Shared state - decision matrix for inputs/gather versus stores; checkpoint integration
- Schema and JSON loading - load DAGs from JSON instead of building them in code
- Visualization - render the built DAG as Mermaid or Cytoscape
- Example 02: DAGBuilder - runnable end-to-end example
- Reference, Dagonizer
- Reference, Entities,
DAG,SingleNode,ScatterNode,EmbeddedDAGNode