NOETIC
Framework

JSON Workflow Runtime

Generate and execute agent workflows as portable JSON definitions.

Quick Example

Define a workflow as a plain JSON document and execute it with parseAndRunWorkflow:

import { parseAndRunWorkflow, AgentHarness, type Tool } from '@noetic-tools/core';

declare const webSearchTool: Tool;

const workflow = {
  version: 1,
  root: {
    kind: 'sequence',
    id: 'research-pipeline',
    steps: [
      {
        kind: 'callModel',
        id: 'gather',
        model: 'openai/gpt-4o',
        instructions: 'Search for recent AI news',
        tools: ['webSearch'],
      },
      {
        kind: 'callModel',
        id: 'summarize',
        instructions: 'Summarize the findings above',
      },
    ],
  },
};

const harness = new AgentHarness({ name: 'runner', params: {} });
const ctx = harness.createContext();
const result = await parseAndRunWorkflow({
  json: workflow,
  harness,
  ctx,
  tools: [webSearchTool],
});

The JSON Workflow Runtime lets you express agent workflows as portable JSON documents that are validated, hydrated into live Steps, and executed by the AgentHarness. Because the format is pure JSON, workflows can be generated by LLMs at runtime, stored in databases, transferred over the wire, or version-controlled alongside configuration.

Nodes pass text to one another, and expressions let a workflow read that text as structured data. Written in JSONata, an expression computes a new value in a compute node, picks a conditional route with expr, or stops a loop with an { "kind": "expr" } until predicate. With an uncapped loop or a recursive subflow, that makes the format Turing complete: a workflow can carry state and run until the state says to stop, bounded by the run's budgets rather than by its shape.

Workflow Document Format

Every workflow document is an envelope with a version field and a root node:

{
  "version": 1,
  "root": { "...": "WorkflowNode" }
}
FieldTypeDescription
version1Schema version. Currently only 1 is supported.
rootWorkflowNodeThe top-level node of the workflow tree.
connectionsRecord<string, WorkflowConnectionRequirement>Optional. The connections the document needs, by name. See Connections.
customItemsRecord<string, …>Optional. Custom items any node may emit.

The root node and all descendant nodes are WorkflowNode objects, each identified by a kind discriminant and a unique id string. The document is validated at runtime by WorkflowDocumentSchema (a Zod schema exported from @noetic-tools/core).

Published JSON Schema

A standard JSON Schema (draft 2020-12) is generated from WorkflowDocumentSchema and published with the package, so editors, CI, and LLM planners can validate documents without importing the runtime. It is available at the ./schema subpath and on disk at @noetic-tools/core/schema:

{
  // your-workflow.json
  "$schema": "https://noetic.tools/schema/noetic-workflow.schema.json",
  "version": 1,
  "root": { "kind": "callModel", "id": "answer", "instructions": "..." }
}

Point your editor or validator at the bundled file (resolve it from the package, e.g. require.resolve('@noetic-tools/core/schema')) to get autocompletion and inline errors while authoring workflows by hand or reviewing LLM-generated ones. The schema is regenerated from the Zod source via bun run gen:schema; a drift-gate test fails CI if the committed artifact falls out of sync.

Node Kinds

Seventeen node kinds are available, plus the acp-agent kind documented in ACP Agent Steps. Leaf nodes (callModel, invokeTool, runCode, compute, decide) perform work; connection nodes (call, wait, verify) act on and check the document's connections; structural nodes (sequence, inParallel, conditional, routeSwitch, loop, spawn, withContext, schedule, subflow) compose them into trees.

Every node takes two optional replay fields (see Replay Identity) plus optional emitItems and customItems fields (see Emitting items):

FieldTypeEffect
durablebooleanfalse on cheap orchestration nodes makes a resumed run re-run them instead of replaying their recorded output.
replayKeytrue | { from: string[] }Replay the node by its input rather than its position. true keys on the whole input; from keys on those top-level properties of the input parsed as JSON (the same lookup over uses). Ignored when durable is false.

The key is the node id plus a hash of the selected input, so two different nodes never share an entry, while nodes that share an id do — write the same leaf id into several worker paths and each worker replays its own batch's answer whichever path picks it up:

{
  "kind": "sequence", "id": "worker-a", "durable": false,
  "steps": [
    { "kind": "invokeTool", "id": "worker-a-pick", "toolName": "pick-batch", "durable": false },
    { "kind": "callModel", "id": "grade", "instructions": "Grade this batch.", "replayKey": true }
  ]
}

With from, a node whose input is not a JSON object logs a warning (INVALID_REPLAY_KEY_INPUT) and runs without replay. Inside a dynamic inParallel (each), node ids get the item index appended, so a keyed node there replays only when its item keeps its index.

Emitting items

By default the user sees a workflow's output only when its last node finishes. Any node can emit items sooner with emitItems. Its items are added to the conversation and streamed through getItemStream at once, so a node inside a loop emits on every iteration, before the loop's until stops it:

FormEmits
"emitItems": trueThe output as a final item. JSON text of an object whose type names a custom item in scope becomes that item; anything else becomes an assistant message.
"emitItems": { "onDone": "<JSONata>" }The text a JSONata expression computes from the output, as an assistant message. No value emits nothing.
"emitItems": { "item": "progress", "onStart": "…", "onDone": "…" }A declared custom item: onStart, read against the input, gives one event; onDone, read against the output, gives the final fields.
{
  "kind": "loop",
  "id": "drain",
  "maxIterations": "infinity",
  "body": {
    "kind": "compute",
    "id": "step",
    "expr": "{ 'n': n - 1 }",
    "emitItems": { "onDone": "'Left: ' & $string(n)" }
  },
  "until": { "kind": "expr", "expr": "n = 0" }
}

Starting from {"n":3}, this emits Left: 2, Left: 1 and Left: 0 as each iteration ends, then the loop returns {"n":0}.

Custom items

Declare custom items in JSON Schema, in the document's root customItems (any node may emit them) or in a node's own customItems (only that node may). Each streams events and one final item under one id, like a model reply, and types the harness's results. See Custom Items.

On resume

A resumed run replays finished nodes from the ledger instead of running them, and puts the items they emitted back on the log without streaming them. To stream them again, for example so a client that reconnects mid-run sees the progress it missed, set emitItemsOnReplay: true on the harness. It also covers nodes inside a parent that replays as a whole, such as the iterations of a finished loop.

Notes

  • A runCode node emits its stdout.
  • A decide node emits its answers object, and its onDone can read it ("stop.value ? 'Done' : 'Trying again'"). When the node is a loop's until, it emits after every verdict.
  • A structural node (sequence, loop, subflow, …) emits its own output once it finishes, after its children.
  • A callModel node's reply is already in the conversation, so emitItems on one adds a second copy.
  • Emitted items are normal conversation items: later callModel nodes see them, and HarnessResponse.items includes them.
  • A failed node emits nothing.

callModel

Call an LLM with instructions and optional tools. Every tools entry is an object. { type, parameters? } is a registered tool name (a client tool resolved from the tool registry) or a reserved server-tool type the provider executes itself (web search, web fetch). { connection, ops? } and { mcp } give the model a connection's tools.

{
  "kind": "callModel",
  "id": "summarize",
  "model": "openai/gpt-4o",
  "instructions": "Summarize the input text in three bullet points",
  "tools": [
    { "type": "myClientTool" },
    { "type": "openrouter:web_search", "parameters": { "maxResults": 6, "searchContextSize": "medium" } }
  ],
  "params": { "temperature": 0.3, "maxTokens": 1024 }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
modelstring'openai/gpt-4o'Model identifier (OpenRouter format).
instructionsstringrequiredSystem/user prompt for the LLM.
toolsobject[]undefinedTool entries. Connection: { "connection": "<name>", "ops"?: [...] } or { "mcp": { "name", "type": "http" | "sse", "url", "headers"? } } (see Connection tools). Client tool: { "type": "<registered-tool-name>" } — type is resolved against the registry; any parameters is ignored (the model supplies call args at runtime). Server tool: { "type": "openrouter:web_search" | "openrouter:web_fetch", "parameters"?: {...} } — run by the provider; parameters keys are camelCase (e.g. maxResults, searchContextSize), unknown keys dropped. Client vs server is decided by the type value.
toolChoice"auto" | "required" | "none" | { tool: string }"auto"How the model may use its tools (see Tool Choice). { "tool": "<registered-tool-name>" } must name a client tool in tools, or hydration throws UNKNOWN_TOOL_CHOICE.
paramsobjectundefinedModel parameters: temperature, topP, maxTokens, serviceTier (auto, default, fast, flex, priority, scale, ultrafast). stopSequences is accepted but deprecated and never sent.

Connection tools

Two more tools entries give the model a connection's tools, under the same names and with the same results as the TypeScript entries they stand for (Attaching at the harness, a turn, or a step):

{
  "kind": "callModel",
  "id": "triage",
  "instructions": "Triage the issue",
  "tools": [
    { "connection": "linear", "ops": ["get_*"] },
    { "mcp": { "name": "docs", "type": "http", "url": "https://mcp.example.com/docs",
               "headers": [{ "name": "Authorization", "value": { "$secret": "docs.token" } }] } }
  ]
}
  • { "connection": "<name>", "ops"?: [...] } picks a harness or turn connection: all its tools, or those whose operations match the ops globs — connectionTools(name, { ops }). Under a connections allowlist the name must be granted and each glob must lie inside a granted one; with no ops it takes exactly the granted operations. A callModel or acp-agent node a document check runs (parseAndRunWorkflow, dynamicWorkflow) gets exactly the tools it lists; with no tools it is offered each granted connection with exactly its granted operations plus the MCP servers the document's connections attach, and its other tools as any step with no tools. Without connections, or with {}, it is offered no connection.
  • { "mcp": { "name", "type": "http" | "sse", "url", "headers"? } } attaches an MCP server to the step, like tools: [mcp({ name, type, url, headers })]: ACP's McpServer shape, as mcp() takes it. A stdio server is { "mcp": { "name", "command", "args"?, "env"? } }; on an acp-agent node it starts where the node's agent runs. Header and environment values are { "$secret" }s only, and there is no cwd or policy. The host's mcp allowlist must grant the URL or exact command; it binds the secrets, sets the policy and connects the server.

The three ways to give a model an MCP server, in JSON:

{
  "version": 1,
  "connections": {
    "linear": { "mcp": { "name": "linear", "type": "http", "url": "https://mcp.linear.app/mcp",
                         "headers": [{ "name": "Authorization", "value": { "$secret": "linear.token" } }] } }
  },
  "root": { "kind": "sequence", "id": "main", "steps": [
    { "kind": "callModel", "id": "triage", "instructions": "Triage ENG-1.",
      "tools": [{ "mcp": { "name": "linear", "type": "http", "url": "https://mcp.linear.app/mcp",
                           "headers": [{ "name": "Authorization", "value": { "$secret": "linear.token" } }] } }] },
    { "kind": "callModel", "id": "read", "instructions": "Summarize ENG-1.",
      "tools": [{ "connection": "linear", "ops": ["get_*"] }, { "type": "search" }] }
  ] }
}

The connections entry is the document's own connection: attached when the document runs, reached by name from every node, and released with the turn. Its key must equal mcp.name, and it takes no from, fresh or setup; a malformed one is CONNECTION_INVALID. The triage node's inline entry is the same server under the same name, so it is the same connection. A { "connection" } pick naming no connection fails with CONNECTION_UNKNOWN when the tool set is built.

An acp-agent node takes the same two entries in tools. A name the harness or the turn already holds for a different connection fails with CONNECTION_NAME_TAKEN, and so, before anything runs, do two different servers the document names under one name. A document run from a step in the middle of a turn attaches its entries when it starts; they are released with the turn.

runCode

Run a serialised code body. Unlike the programmatic runCode (which carries a closure), the JSON form carries execute as a code string. The code is never eval'd in-process (Cloudflare Workers forbid eval); it is dispatched through a subprocess adapter which runs the code and returns its stdout as the step output. The step input is passed to the subprocess on stdin.

{
  "kind": "runCode",
  "id": "transform",
  "execute": "const input = require('fs').readFileSync(0, 'utf8'); process.stdout.write(input.toUpperCase());",
  "retry": { "maxAttempts": 3, "backoff": "exponential", "initialDelay": 100 },
  "subprocess": "sandbox"
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
executestringrequiredSource code dispatched to the subprocess. Receives the step input on stdin; its stdout becomes the step output.
retryobjectundefinedRetry policy: { maxAttempts, backoff: 'fixed' | 'linear' | 'exponential', initialDelay, maxDelay? }.
subprocessstringundefinedNamed subprocess adapter ref, resolved via HydrationContext.resolveSubprocess. When omitted, the harness default (ctx.subprocess) is used.

Execution requires a subprocess adapter. The runCode node does not execute code in the worker process. The host must supply a subprocess adapter capable of running the code body and capturing its stdout. On Node, createLocalSubprocessAdapter() from @noetic-tools/platform-node does: it runs the body with node -e, writes the step input to stdin, and returns stdout. A non-zero exit (or failed handle) surfaces as a thrown error carrying the end of the process's stderr.

invokeTool

Invoke a registered tool directly.

{
  "kind": "invokeTool",
  "id": "calc-step",
  "toolName": "calculator",
  "args": { "expression": "2 + 2" }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
toolNamestringrequiredName of a tool in the tool registry.
argsRecord<string, unknown>{}Arguments passed to the tool's execute function.

compute

Compute a value from the input with a JSONata expression, with no LLM and no subprocess. The input is read as JSON when it parses, else used as the raw string.

{
  "kind": "compute",
  "id": "step",
  "expr": "{ 'n': n - 1, 'sum': sum + n }"
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
exprstringrequiredA JSONata expression, evaluated against the input. Must parse: a syntax error fails the document with WORKFLOW_VALIDATION_FAILED before anything runs.

A string result stays a string; any other value is written as JSON (null as the empty string). The next node reads that JSON back as data, so a chain of compute nodes, or a loop whose body is one, carries a state object from step to step. This loop sums 1 to n and stops once the state says done:

{
  "kind": "loop",
  "id": "count",
  "body": {
    "kind": "compute",
    "id": "step",
    "expr": "n = 0 ? { 'done': true, 'sum': sum } : { 'n': n - 1, 'sum': sum + n }"
  },
  "until": { "kind": "outputContains", "marker": "done" }
}

With input {"n":5,"sum":0} it outputs {"done":true,"sum":15}.

A string result that is itself valid JSON ("5", "true") reads back as that value at the next node; keep strings inside an object to carry them as strings. A result with no JSON form fails the node with EXPRESSION_FAILED: JSONata's "no value" (a path that matched nothing) or a function anywhere in the result.

sequence

Run steps one at a time, in array order: each child starts only after the one before it finishes, and takes that child's output as its input. The node's output is the last child's output, and a child that throws stops the rest. This is how every array of steps runs (see Running Steps in Order); only inParallel runs its children concurrently.

{
  "kind": "sequence",
  "id": "pipeline",
  "steps": [
    { "kind": "callModel", "id": "step-1", "instructions": "Extract key facts" },
    { "kind": "callModel", "id": "step-2", "instructions": "Write a summary from the facts above" }
  ]
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
steps(WorkflowNode | { "<op>": args })[]requiredOrdered list of child nodes (min 1). An entry may also be an operation written by name.

inParallel

Run multiple paths concurrently with configurable merge strategy. Unlike sequence, and every other array of steps (see Running Steps in Order), the paths all start together and each gets the same input. Nodes inside one path still run in order. A fork is either static (a fixed paths array) or dynamic (each: one child per item of a runtime-produced array). Supply exactly one of paths or each.

{
  "kind": "inParallel",
  "id": "parallel-search",
  "mode": "all",
  "paths": [
    { "kind": "callModel", "id": "search-a", "instructions": "Search topic A" },
    { "kind": "callModel", "id": "search-b", "instructions": "Search topic B" }
  ],
  "merge": "concat",
  "concurrency": 3
}

Dynamic fan-out — one child per array item (data-dependent N):

{
  "kind": "inParallel",
  "id": "per-item",
  "mode": "all",
  "over": "items",
  "each": { "kind": "callModel", "id": "worker", "instructions": "Process this item" },
  "merge": "concat"
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
mode'all' | 'race' | 'settle'requiredall waits for every path; race returns the first; settle waits for all but tolerates failures.
pathsWorkflowNode[]—Static child nodes to execute concurrently. Mutually exclusive with each.
eachWorkflowNode—Dynamic body template instantiated once per input array item. Each item is injected as that child's input; template node ids are suffixed -<i> for uniqueness. Mutually exclusive with paths.
overstring | string[]undefinedWith each: selector key into the input JSON object locating the array. When omitted, the input string is parsed as a JSON array directly. A list of connection names instead fans each out over those connections, binding each name to $item (see Fan-out over connections).
merge'last' | 'first' | 'concat''last'How to combine results. concat writes object results (such as a decide node's answers) as JSON. Ignored in race mode.
concurrencynumberundefinedMaximum concurrent paths.

conditional

Route input to one of several targets. Each route tests the input with either a substring (match) or an expression (expr).

{
  "kind": "conditional",
  "id": "router",
  "routes": [
    { "match": "code", "target": { "kind": "callModel", "id": "code-path", "instructions": "Write code" } },
    { "match": "explain", "target": { "kind": "callModel", "id": "explain-path", "instructions": "Explain the concept" } }
  ],
  "default": { "kind": "callModel", "id": "fallback", "instructions": "Handle the request" }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
routes({ match: string } | { expr: string }) plus target: WorkflowNode, as an arrayrequiredOrdered routes, each with exactly one test. The first route whose test passes wins.
defaultWorkflowNodeundefinedFallback node when no route matches.

When the input is an object, or a string holding a JSON object or array (such as a decide node's answers passed on by a sequence), routes match against its string values only, not its keys. For answers, those are each answer's type (so "refusal" matches a refusal), a choice's value, and a score's level. A decide node's answers name every choice in their keys and probability maps, so matching the raw JSON would hit every route. Boolean answers have no string value to match; route on those with routeSwitch.

Expression routes

match suits prose. For structured state, give a route an expr instead: a JSONata test run against the input, parsed as JSON when it parses (else the raw string). The route is taken if the test gives true and skipped if it gives false or no value, such as a path that matches nothing. Any other result, like a number or a string, fails the run with EXPRESSION_FAILED; an expr that does not parse fails validation with WORKFLOW_VALIDATION_FAILED before anything runs. A route with both match and expr, or neither, fails schema validation.

{
  "kind": "conditional",
  "id": "size",
  "routes": [
    { "expr": "n = 0", "target": { "kind": "callModel", "id": "empty", "instructions": "Say there is nothing to do." } },
    { "match": "urgent", "target": { "kind": "callModel", "id": "rush", "instructions": "Handle this now." } },
    { "expr": "n > 3", "target": { "kind": "callModel", "id": "batch", "instructions": "Split the work into batches." } }
  ],
  "default": { "kind": "callModel", "id": "small", "instructions": "Do the work." }
}

Both kinds mix in one node and run in order. An expr can also read a decide node's answers, including boolean ones: after a decide asking refund in a sequence, "expr": "refund.value" takes the route on a yes.

An expr route makes a clean base case for a recursive named workflow. This countdown adds n to total and calls itself until n is 0:

{
  "version": 1,
  "root": {
    "kind": "conditional",
    "id": "check",
    "routes": [
      { "expr": "n = 0", "target": { "kind": "compute", "id": "done", "expr": "{ \"total\": total }" } }
    ],
    "default": {
      "kind": "sequence",
      "id": "step",
      "steps": [
        { "kind": "compute", "id": "next", "expr": "{ \"n\": n - 1, \"total\": total + n }" },
        { "kind": "subflow", "id": "again", "ref": "countdown" }
      ]
    }
  }
}

Run with input {"n":4,"total":0}, it outputs {"total":10}.

decide

Ask a model typed questions about the context. Hydrates into decide. Questions are plain JSON, in the same shape as the programmatic API.

{
  "kind": "decide",
  "id": "triage",
  "model": "typesafe/jev-1.14",
  "instructions": "Support ticket triage.",
  "questions": {
    "destination": {
      "type": "choice",
      "instructions": "Which team should handle this ticket?",
      "choices": { "billing": "Refunds, invoices", "support": "Bugs, outages" }
    },
    "isUrgent": { "type": "boolean", "instructions": "Is the customer reporting downtime?" }
  },
  "view": "full"
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
modelstringharness decisionModelDecision or chat model id.
instructionsstringundefinedShared framing for every question.
questionsRecord<string, DecideQuestion>requiredboolean, choice, and score questions keyed by id. Validated by the same rules as decide().
view'full' | 'layers' | 'none''full'How much of the assembled view the model sees.
samplesnumber1Positive integer. Runs to average on the estimated path.

A decide node outputs the answers object. Inside a sequence, the next node receives it as JSON, so a callModel node can read it. A conditional matches against the answers' string values, and a inParallel with merge: "concat" writes them as JSON. The context selector is a closure and has no JSON form.

routeSwitch

Run a decide node and route the original input to the case its answer picks. Hydrates into the key form of routeSwitch.

{
  "kind": "routeSwitch",
  "id": "route-ticket",
  "decide": {
    "kind": "decide",
    "id": "triage",
    "model": "typesafe/jev-1.14",
    "questions": {
      "destination": { "type": "choice", "choices": ["billing", "support"] }
    }
  },
  "on": "destination",
  "cases": {
    "billing": { "kind": "callModel", "id": "billing", "instructions": "Resolve the billing question" },
    "support": { "kind": "callModel", "id": "support", "instructions": "Debug the reported problem" }
  },
  "minConfidence": 0.6,
  "uncertain": { "kind": "callModel", "id": "ask-human", "instructions": "Ask a human to triage this ticket" }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
decideDecideWorkflowNoderequiredThe decision to route by.
onstringrequiredId of the question to route by.
casesRecord<string, WorkflowNode>requiredOne node per outcome of on: each choice, "true" and "false" for a boolean, or each level label for a score.
minConfidencenumberundefinedIn [0, 1]. Below this confidence, take uncertain.
uncertainWorkflowNoderequiredTaken on refusal or low confidence.

Schema validation checks that on names a question of the decide node, that cases covers every outcome of that question, and that uncertain is present. Only the key form has a JSON node; the function form of on is a closure.

loop

Repeat a body node until a predicate is satisfied.

{
  "kind": "loop",
  "id": "refine",
  "body": { "kind": "callModel", "id": "refine-step", "instructions": "Improve the draft" },
  "until": { "kind": "maxSteps", "n": 5 },
  "maxIterations": 10
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
bodyWorkflowNoderequiredThe node to repeat.
untilUntilPredicaterequiredTermination condition. See Until Predicates.
maxIterationsnumber | "infinity"1000Hard cap on iterations: a positive integer, or exactly "infinity" for none.

JSON cannot encode Infinity, so an uncapped loop spells it "infinity"; the hydrator maps it to Number.POSITIVE_INFINITY. An uncapped loop ends only through its until predicate, an error, or an aborted context, so pair it with a predicate the body's output can satisfy:

{
  "kind": "loop",
  "id": "work-until-done",
  "body": { "kind": "callModel", "id": "work", "instructions": "Take the next step" },
  "until": {
    "kind": "decide",
    "id": "finished",
    "questions": {
      "done": { "type": "boolean", "instructions": "Is the task finished?" }
    }
  },
  "maxIterations": "infinity"
}

spawn

Run a child node in an isolated child context.

{
  "kind": "spawn",
  "id": "background-task",
  "child": { "kind": "callModel", "id": "worker", "instructions": "Process in background" },
  "timeout": 30000
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
childWorkflowNoderequiredThe node to execute in a child context.
timeoutnumberundefinedTimeout in milliseconds.
layersstring[]undefinedContext layer names for the child, resolved from the hydration context registry (same resolution as withContext). Omit to inherit the parent's layers; naming layers replaces them for the child.
steers'forward' | 'hold''hold''forward' lets the child apply steers sent to the session while it runs. 'hold' keeps them queued until the child returns.
{
  "kind": "spawn",
  "id": "worker",
  "child": { "kind": "callModel", "id": "work", "instructions": "Do the unit of work" },
  "layers": ["task-state"]
}

withContext

Wrap a child node with context layers.

{
  "kind": "withContext",
  "id": "with-memory",
  "child": { "kind": "callModel", "id": "agent", "instructions": "..." },
  "layers": ["conversation-summary"]
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
childWorkflowNoderequiredThe node to wrap.
layersstring[]requiredLayer names. Currently layers are referenced by name but not resolved from a registry -- the harness's default layers are inherited instead.

schedule

Execute a step on a fixed interval (periodic scheduling).

{
  "kind": "schedule",
  "id": "heartbeat",
  "step": { "kind": "callModel", "id": "check", "instructions": "Check system status" },
  "interval": 60000,
  "onError": "continue"
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
stepWorkflowNoderequiredThe node to execute each interval.
intervalnumberrequiredInterval in milliseconds.
onError'continue' | 'fail'undefinedWhether to keep running or abort on error.

subflow

Run another workflow document as a single step — inline, or by name resolved from the workflows registry.

{
  "kind": "subflow",
  "id": "verify",
  "ref": "verify-loop"
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
documentWorkflowDocumentXORInline sub-workflow. Supply exactly one of document / ref.
refstringXORNamed sub-workflow resolved from HydrationContext.workflows (or the workflows option on parseAndRunWorkflow).
inputstringundefinedLiteral input for the sub-workflow; defaults to the node's runtime input.

Resolution is lazy — the target document resolves and hydrates on first execution, so a live registry may gain entries after hydration. An unregistered name raises UNKNOWN_WORKFLOW_REFERENCE. Two subflow nodes referencing the same workflow are fine — the sub-tree's node ids are suffixed with the subflow node's id so traces stay distinct. A subflow document, inline or named, declares no connections: its nodes use the root document's. An inline one that does fails validation, and a named one fails with CONNECTION_INVALID when it resolves.

Recursion

Named workflows may call themselves, directly or through other named workflows. A subflow node without input passes its runtime input on, so the steps before a recursive call compute what it receives, and a routing node such as conditional picks the base case:

{
  "version": 1,
  "root": {
    "kind": "sequence",
    "id": "body",
    "steps": [
      { "kind": "runCode", "id": "step", "execute": "<count down; print done at zero>" },
      {
        "kind": "conditional",
        "id": "check",
        "routes": [{ "match": "done", "target": { "kind": "runCode", "id": "finish", "execute": "<report>" } }],
        "default": { "kind": "subflow", "id": "recurse", "ref": "countdown" }
      }
    ]
  }
}

Each recursion level hydrates lazily into its own tree, with ids suffixed by the calling subflow node's id (body-top, body-recurse-top, …), so every level traces and replays under distinct step ids. Depth is bounded at execution time: when a ref would resolve more than maxRecursionDepth levels deep on one path (default 100; Infinity for no limit), the call throws WORKFLOW_RECURSION_LIMIT.

Connections

A document that drives connections lists the ones it needs under connections, each with a version range, and acts on them with call, wait, and verify nodes:

{
  "version": 1,
  "connections": {
    "app":     { "profile": "browser@^1.2", "from": "browsers", "fresh": "path", "setup": { "url": "http://localhost:5173/login" } },
    "phone":   { "profile": "device@^1", "extensions": { "com.acme.ios-permissions": "^1.0" } },
    "deploys": { "events": ["deploy.failed"] }
  },
  "root": { "kind": "sequence", "id": "main", "steps": [
    { "kind": "call", "id": "email", "connection": "app", "op": "ui.fill",
      "args": { "element": "textbox \"Email\"", "text": "qa@example.com" } },
    { "kind": "call", "id": "password", "connection": "app", "op": "ui.fill",
      "args": { "element": "textbox \"Password\"", "text": { "$secret": "app.password" } } },
    { "kind": "call", "id": "sign-in", "connection": "app", "op": "ui.tap",
      "args": { "element": "button \"Sign in\"" } }
  ] }
}
Manifest fieldTypeDescription
profilestring<profile>@<range>, e.g. browser@^1.2. Requiring a profile requires its parents.
extensionsRecord<string, string>Extension id → semver range.
eventsstring[]For a source: the event types it must offer.
fromstringThe harness connection or pool to bind to; defaults to the entry's name.
fresh'run' | 'path'Get a fresh connection from a pool, one per run or per parallel path.
setupobjectPassed to the pool's provider; validated against the profile's setup schema. Fields the profile reserves to the host are refused with CONNECTION_NOT_ALLOWED.

Every operation and event a node uses must come from what its entry declares; anything else fails validation. The optional operations a document uses are worked out from its nodes, so they are never listed twice. Requirements are negotiated against the harness's connections before the root runs; a connection that does not offer what is required fails with CONNECTION_UNSATISFIED, and a name the harness does not have with CONNECTION_UNKNOWN.

Values

A connection node's args are JSON, plus four wrappers. Object keys starting with $ are reserved for them; wrap such data in $literal.

ValueMeaning
{ "$expr": "…" }An expression, evaluated against the node's input with $steps holding the outputs of earlier nodes by id. args may also be one $expr computing every argument.
{ "$literal": … }Passes a value through untouched.
{ "$secret": "name" }A secret, resolved through environment.secrets only when the call is sent. Allowed only in fields the operation marks sensitive.
{ "$ref": … } / { "$blob": … }A ref or blob as an earlier node returned it. Read a ref's id with $refId(x) in an expression.
{ "kind": "call", "id": "reply", "connection": "team", "op": "chat.reply",
  "args": { "thread": { "$expr": "$steps.post.thread" }, "text": "Deployed." } }

Operations written by name

Inside around, act, or any array of steps, an operation can be written by name: { "ui.tap": { "element": "button \"Pay\"" } } is a call on the enclosing connection — the verify subject, or the document's only connection.

Fan-out over connections

inParallel with over a list of connection names and "connection": { "$expr": "$item" } on its nodes runs the same steps on each, every name checked against the manifest:

{
  "kind": "inParallel", "id": "both-phones", "mode": "all",
  "over": ["ios", "android"],
  "each": { "kind": "call", "id": "open", "connection": { "$expr": "$item" }, "op": "device.openUrl",
            "args": { "url": "myapp://settings" } }
}

call

Runs one connection operation and returns its result. Operations that run long upstream are waited for.

FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
connectionstring | { "$expr" }enclosing connectionA manifest name, or an expression ($item in a fan-out).
opstringrequiredThe operation, e.g. ui.tap.
argsobject | { "$expr" }{}The operation's arguments (see Values).

wait

Waits for a connection event. With around, it starts listening, runs around, then waits, so the event an action causes cannot slip past. Its output is { event, around }.

{ "kind": "wait", "id": "approval", "connection": "team", "event": "chat.message.created",
  "filter": "$refId(event.payload.thread) = $refId(input.thread) and $contains($lowercase(event.payload.text), 'approve')",
  "around": { "chat.reply": { "thread": { "$expr": "thread" }, "text": "Approve?" } },
  "timeoutMs": 1800000 }
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
connectionstring | { "$expr" }enclosing connectionThe connection to listen on.
eventstringrequiredThe event type.
filterstringundefinedAn expression evaluated against { "event": …, "input": … }; the event matches when it gives true. Without one, every event of the type matches.
aroundnode, op, or arrayundefinedSteps run once listening has started.
timeoutMsnumberrequiredHow long to wait, from when listening starts.

verify

Checks claims about what a connection shows. See Verification.

{ "kind": "verify", "id": "checkout", "subject": "app",
  "expect": [ { "net": "POST /api/orders 201", "count": 1 }, { "ui": "heading \"Order confirmed\"" } ],
  "never":  [ { "net": "5xx" } ],
  "act": [ { "ui.tap": { "element": "button \"Pay\"" } } ] }
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
subjectstring | string[] | { "$expr" }requiredThe connection checked, or a list (one verify per connection, ids <id>.<name>, the worst outcome).
expectMatcher[]XORWhat must become true. A matcher names one sense (ui, net, log, route, state, signal, storage, time, visual, x-…) or event, plus optional count, atLeast, atMost, equals, tolerance. Supply exactly one of expect / claims.
neverMatcher[]undefinedWhat must not happen while the action runs.
claims / constraintsOVP documents—The full OVP form, instead of expect / never.
actnode, op, or arrayrequiredThe action. Steps run on the subject unless they name another connection.
witnessesstring[]undefinedConnections that confirm from outside the subject.
close{ until?, event?, quietMs?, timeoutMs? }quiet 500 ms, at most 15 sWhen the window closes. event is required with until: 'event', and only then.
intentstringundefinedWhy, kept in the run record.

Until Predicates

Loop termination conditions are expressed as named predicates. Each predicate is a JSON object with a kind discriminant. See Loop & Until for the programmatic equivalents.

Named predicates

KindFieldsDescription
maxStepsn: numberStop after n iterations.
maxCostusd: numberStop when cumulative cost exceeds usd dollars.
maxDurationduration: numberStop after duration milliseconds.
noToolCalls--Stop when the LLM produces no tool calls in a round.
outputContainsmarker: stringStop when output contains the marker substring.
outputEqualssentinel: stringStop when output exactly equals the sentinel.
convergedthreshold?: numberStop when output similarity between rounds exceeds threshold (0-1, defaults to the builder default).
never--Never stop; the loop ends on its maxIterations cap, an error, or an aborted context.
exprexpr: stringStop when a JSONata expression over the iteration's output gives true.
decidea decide nodeA model answers one boolean question about the iteration's output: yes stops, no or a refusal continues.

Decide as a predicate

A plain decide node can end a loop. It must ask exactly one question, and that question must be boolean; schema validation rejects anything else. Each iteration it runs on the loop's last output (and, like any decide node, sees the context view without writing to it): a yes stops the loop, a no or a refusal runs another iteration. With "maxIterations": "infinity", a model that keeps refusing keeps the loop running until the context is aborted, so put a backstop beside it when that matters:

{
  "kind": "any",
  "predicates": [
    { "kind": "maxSteps", "n": 20 },
    {
      "kind": "decide",
      "id": "good-enough",
      "questions": {
        "ship": { "type": "boolean", "instructions": "Is the draft ready to ship?" }
      }
    }
  ]
}

A decide node inside until is part of the workflow tree: walkWorkflow and workflowGraph list it as a child of its loop, and subflow and each id suffixes apply to it like any other node.

Expression as a predicate

An expr predicate evaluates a JSONata expression against the loop's last output. An output string that holds JSON is parsed first, so a body that returns a state object can be tested field by field. true stops the loop; false, or no value (a path that matches nothing), runs another iteration:

{
  "kind": "loop",
  "id": "drain",
  "body": { "kind": "compute", "id": "step", "expr": "{ \"n\": n - 1 }" },
  "until": { "kind": "expr", "expr": "n = 0" },
  "maxIterations": "infinity"
}

The expression must give a boolean. Any other value, or an expression that fails at run time, fails the loop with EXPRESSION_FAILED rather than stopping it, so a mistyped test never passes off the last output as the result. A syntax error fails the whole document with WORKFLOW_VALIDATION_FAILED before anything runs, wherever the predicate sits, any and all included.

Combinators

Combine multiple predicates with any (logical OR) or all (logical AND):

{
  "kind": "any",
  "predicates": [
    { "kind": "maxSteps", "n": 10 },
    { "kind": "outputContains", "marker": "DONE" }
  ]
}
{
  "kind": "all",
  "predicates": [
    { "kind": "maxSteps", "n": 3 },
    { "kind": "noToolCalls" }
  ]
}

dynamicWorkflow()

The dynamicWorkflow pattern hands the planning to an LLM: it generates a workflow document as JSON, validates it, hydrates it, and executes it -- all within a single harness run. If validation fails, the planner retries with error feedback up to maxRevisions times.

import { dynamicWorkflow, AgentHarness, type Tool } from '@noetic-tools/core';

declare const searchTool: Tool;
declare const calcTool: Tool;

const agent = dynamicWorkflow({
  model: 'openai/gpt-4o',
  tools: [searchTool, calcTool],
  instructions: 'Plan the most efficient workflow',
  maxDepth: 5,
});

const harness = new AgentHarness({
  name: 'planner',
  agentGraph: agent,
  params: {},
});

await harness.execute('Research and summarize quantum computing advances');
const response = await harness.getAgentResponse();

DynamicWorkflowOpts

FieldTypeDefaultDescription
modelstring'openai/gpt-4o'Model for the planner LLM.
instructionsstringundefinedAdditional instructions prepended to the planner prompt.
toolsTool[]requiredTools the generated workflow may reference by name.
maxDepthnumber5Maximum allowed tree depth for generated workflows.
maxRevisionsnumber3Retries with error feedback on validation failure.
connectionsConnectionAllowlistundefinedWhat a generated document may use of the harness's connections. Without it the document is granted none; the planner is shown only these. See What a document may use.
acpAgentsReadonlyMap<string, AcpAgent>undefinedACP agents a generated acp-agent node may run, keyed by agent id. The planner is shown these ids. See ACP agents.
acpAcpAllowlistundefinedPer agent id, the most a generated acp-agent node may ask in permissions, mode, model, cwd, clientCapabilities and session. An agent without an entry gets the narrowest defaults; the planner is shown each agent's limits. See What a document may ask of an agent.
mcpMcpAllowlistundefinedThe MCP servers a generated document may attach, with their secrets, policy and provider. Without it, every server is refused; the planner is shown only the granted servers. See MCP servers.

Throws NoeticConfigError with code WORKFLOW_VALIDATION_FAILED if the planner cannot produce a valid workflow within the revision limit, CONNECTION_UNKNOWN if the allowlist names a connection the harness does not have, and CONNECTION_NOT_ALLOWED if the last revision still asks for more than connections, acp or mcp grants.

parseAndRunWorkflow()

For pre-built JSON workflows -- parse, validate, hydrate, and execute in one call.

import { parseAndRunWorkflow, AgentHarness, type Tool } from '@noetic-tools/core';

declare const workflowDocument: unknown;
declare const searchTool: Tool;
declare const calcTool: Tool;

const harness = new AgentHarness({ name: 'runner', params: {} });
const ctx = harness.createContext();

const result = await parseAndRunWorkflow({
  json: workflowDocument,
  harness,
  ctx,
  tools: [searchTool, calcTool],
  maxDepth: 5,
});

ParseAndRunWorkflowOpts

FieldTypeDefaultDescription
jsonunknownrequiredRaw JSON (string or parsed object) representing a workflow document.
harnessAgentHarnessContractrequiredThe AgentHarness to execute the workflow with.
ctxContextrequiredExecution context.
toolsTool[]requiredAvailable tools the workflow may reference by name.
maxDepthnumber5Maximum allowed workflow tree depth.
layersReadonlyMap<string, ContextLayer>undefinedNamed context layers that withContext/spawn nodes may reference.
workflowsReadonlyMap<string, WorkflowDocument>undefinedNamed sub-workflows that subflow nodes may reference via ref. Any document you run can invoke any of them, and a named workflow runs as you wrote it — its own acp-agent settings, not held to connections, acp or mcp. Register only workflows you would let an untrusted document run.
maxRecursionDepthnumber100Deepest chain of named-ref resolutions on one path. A positive integer or Infinity.
expressionLimitsExpressionLimitsDEFAULT_EXPRESSION_LIMITSBounds on each expression evaluation (steps, depth, length, timeout). See Expressions.
connectionsConnectionAllowlistundefinedWhat the document may use of the harness's connections: operation and event globs, setup value patterns, and secrets bound to one op.field each. A manifest asking for more fails with CONNECTION_NOT_ALLOWED. Without it the document is granted none, as with acp and mcp; pass the grants a document you trust should have. See What a document may use.
acpAgentsReadonlyMap<string, AcpAgent>undefinedACP agents the document's acp-agent nodes may run, keyed by agent id.
acpAcpAllowlistundefinedPer agent id, the most the document's acp-agent nodes may ask in permissions, mode, model, cwd, clientCapabilities and session. An agent without an entry gets the narrowest defaults. See What a document may ask of an agent.
mcpMcpAllowlistundefinedThe MCP servers the document may attach, with their secrets, policy and provider. Absence refuses every server. See MCP servers.

Returns Promise<string> -- the string output of the executed workflow.

Throws NoeticConfigError with code WORKFLOW_VALIDATION_FAILED if the JSON does not match WorkflowDocumentSchema. Throws NoeticConfigError with code UNKNOWN_TOOL_REFERENCE if a tool name cannot be resolved. Throws CONNECTION_UNKNOWN if a node uses a connection the document does not declare (or the allowlist names one the harness does not have), CONNECTION_NOT_ALLOWED if the document asks for more than connections, acp or mcp grants, and CONNECTION_INVALID if an MCP server uses a $secret on a harness without environment.secrets.

What a document may ask of an agent

parseAndRunWorkflow and dynamicWorkflow run documents you did not write, so an acp-agent node gets only what you grant. Two allowlists cover it, and both are checked before anything runs, inline subflows included:

  • acp — per agent id, the most a node may ask in permissions, mode, model, cwd, clientCapabilities and session.
  • mcp — the MCP servers a node may attach as { "mcp" } tools entries (or the document as connections entries), and the secrets their headers and environment may carry (MCP servers).

acpAgents is the set of agents a node may name. Nothing else on a node is limited: prompt passes as written. Named subflow refs come from your own workflows registry and are neither checked nor held to the grants, so every workflow you register is one any document may invoke, with the settings you gave it.

import { parseAndRunWorkflow, AgentHarness } from '@noetic-tools/core';
import { claudeCode, createAcpAgentRegistry } from '@noetic-tools/acp';

declare const workflowDocument: unknown;

const harness = new AgentHarness({ name: 'runner', params: {} });

await parseAndRunWorkflow({
  json: workflowDocument,
  harness,
  ctx: harness.createContext(),
  tools: [],
  acpAgents: createAcpAgentRegistry(claudeCode()),
  acp: {
    'claude-code': {
      // Allow reads, and edits whose title contains "docs/"; nothing by default.
      permissions: { allow: [{ kind: 'read' }, { kind: 'edit', title: 'docs/' }] },
      // Any directory under /srv/checkouts.
      cwd: '/srv/checkouts',
      clientCapabilities: { readTextFile: true, writeTextFile: true },
      // The only session mode a node may set; any model goes.
      modes: ['default'],
      session: { keepAlive: 'run' },
    },
  },
});
Grant fieldA node mayWithout a grant
permissions.defaultset default: "allow" only when this is 'allow'deny or cancel only
permissions.allowwrite an allow rule only when a granted rule covers it: the same kind when the grant sets one, and a title containing the grant's title when the grant sets one. deny rules are always fine.no allow rules
permissions.persistset persistno
modesset mode to one of these. A mode such as bypassPermissions or acceptEdits lifts the agent's own prompts, so none is allowed by default.no mode
modelsset model to one of theseany model
cwdname a plain absolute directory that is this one or under it. A node without cwd runs here.the harness's working directory when the document was checked
clientCapabilitiesturn on only these. A capability the node leaves out takes the grant's value, not the agent client's default of on.{ readTextFile: true }
session.keepAlivekeep a session no longer than this: 'step' or 'run'. A document's sessions live in a namespace that ends with its run, so a 'harness' session would only leak its agent process.'run'
session.loadresume an agent session by idno

A document's sessions never join yours. Each run of a document moves its session.reuse keys into a namespace of its own, so two nodes of one document still share a session, but a node cannot take over a session your code or another document opened — along with the servers, cwd and capabilities it was opened with.

MCP servers

A document names an MCP server with a { "mcp" } entry, in a node's tools or in its own connections. Either way noetic is the MCP client, and parseAndRunWorkflow and dynamicWorkflow accept the server only when the mcp allowlist grants it, checking every server before anything runs:

import { parseAndRunWorkflow, AgentHarness } from '@noetic-tools/core';
import { mcp } from '@noetic-tools/mcp';

declare const workflowDocument: unknown;

const harness = new AgentHarness({
  name: 'runner',
  params: {},
  environment: {
    secrets: { resolve: async (name) => process.env[name.replace('.', '_').toUpperCase()] ?? '' },
  },
});

await parseAndRunWorkflow({
  json: workflowDocument,
  harness,
  ctx: harness.createContext(),
  tools: [],
  mcp: {
    // HTTP and SSE servers by URL pattern; this secret may fill only the Authorization header,
    // and the gates apply this policy to every matching server.
    urls: [{ url: 'https://mcp.example.com/*', secrets: { 'docs.token': 'Authorization' },
             policy: { deny: ['delete_*'] } }],
    // Stdio servers by exact command and args.
    commands: [{ command: 'npx', args: ['-y', '@acme/docs-mcp'] }],
    // Connects a granted server: ACP's shape, with its secrets resolved, as mcp() takes it.
    provider: mcp,
  },
});
  • A URL pattern is <scheme>://<host>[:<port>]<path>, matched part by part, so a * never crosses into another part: https://*.example.com/* does not match https://evil.com/.example.com/. The host matches case-insensitively and may start with *. for any subdomain — one or more non-empty labels, so https://*.example.com/ does not match https://.example.com/. After *. come at least two labels: *.com is invalid, as is * anywhere else in the host (CONNECTION_INVALID). The port is a number or *, and when left out is the scheme's default. In the path, which defaults to /, * matches any run of characters. The query is not matched.
  • A server's URL must be http: or https:, carry no user or password, and be written exactly as new URL(url).href reads it back — lowercase host, no default port, a path. The server is connected at the URL as written, so a URL that reads differently (a backslash, a tab, https://mcp.example.com without the /) is refused, with the form to write instead.
  • A stdio server must have the same command and the same args, in order, as a grant.
  • A server may name each header (case-insensitive) or environment variable once. Each value must be { "$secret": "<name>" }, and a grant matching the server must bind that name to that header (case-insensitive) or variable (exact). When several grants match, the first that binds every value allows the server, and its policy applies. Literal values are refused. The secret is resolved through environment.secrets when the server connects, and scrubbed from outputs, events and traces; a document using one on a harness without environment.secrets fails with CONNECTION_INVALID before anything runs.
  • The server's policy comes from its grant. A document cannot set one.
  • provider connects the server. Without it, a document that names a server fails with CONNECTION_INVALID.
  • Without mcp, every server is refused, on both functions.

Anything else fails with CONNECTION_NOT_ALLOWED before anything runs. dynamicWorkflow feeds the refusal back to the planner, and shows it only the granted servers.

Worked example: a model panel with an Opus judge

A complete, runnable example lives at packages/core/examples/dynamic-judge-workflow.ts. An Opus planner generates a workflow as JSON, the document is validated, and then it is executed: the question fans out to four different models in parallel (an inParallel node with mode: "settle"), and their concatenated answers are piped to an Opus judge that synthesises a single ideal response — the "mixture-of-agents" pattern, expressed entirely as data.

OPENROUTER_API_KEY=sk-... bun examples/dynamic-judge-workflow.ts "Your question here"

The canonical, hand-checked version of the generated document is committed at packages/core/examples/multi-model-judge.workflow.json:

{
  "$schema": "https://noetic.tools/schema/noetic-workflow.schema.json",
  "version": 1,
  "root": {
    "kind": "sequence",
    "id": "multi-model-judge",
    "steps": [
      {
        "kind": "inParallel",
        "id": "panel",
        "mode": "settle",
        "merge": "concat",
        "paths": [
          { "kind": "callModel", "id": "answer-gpt", "model": "openai/gpt-4.1", "instructions": "Answer the question. Prefix with '## Candidate (openai/gpt-4.1)'." },
          { "kind": "callModel", "id": "answer-gemini", "model": "google/gemini-2.5-pro", "instructions": "Answer the question. Prefix with '## Candidate (google/gemini-2.5-pro)'." },
          { "kind": "callModel", "id": "answer-llama", "model": "meta-llama/llama-3.3-70b-instruct", "instructions": "Answer the question. Prefix with '## Candidate (meta-llama/llama-3.3-70b-instruct)'." },
          { "kind": "callModel", "id": "answer-deepseek", "model": "deepseek/deepseek-chat", "instructions": "Answer the question. Prefix with '## Candidate (deepseek/deepseek-chat)'." }
        ]
      },
      {
        "kind": "callModel",
        "id": "judge",
        "model": "anthropic/claude-opus-4.5",
        "instructions": "Compare the candidate answers, correct any errors, and output one synthesised best answer."
      }
    ]
  }
}

mode: "settle" is deliberate: a single flaky provider cannot abort the whole panel, and the judge synthesises from whichever candidates responded.

Hydration API

For lower-level control, use hydrateWorkflow and hydrateNode to convert JSON workflow structures into live Step objects without immediately executing them.

import { hydrateWorkflow, hydrateNode } from '@noetic-tools/core';
import type { HydrationContext } from '@noetic-tools/core';

const hydrationCtx: HydrationContext = {
  tools: new Map(myTools.map((t) => [t.name, t])),
  executeStep: harness.run.bind(harness),
};

// Hydrate an entire document
const rootStep = hydrateWorkflow(workflowDoc, hydrationCtx);

// Or hydrate a single node
const singleStep = hydrateNode(someNode, hydrationCtx);

// Then execute manually
const result = await harness.run(rootStep, 'input text', ctx);

HydrationContext

FieldTypeDescription
toolsReadonlyMap<string, Tool>Tool registry mapping tool names to Tool instances.
executeStepExecuteStepFnStep executor function (typically harness.run.bind(harness)).
layersReadonlyMap<string, ContextLayer>Optional. Named context layers that withContext/spawn nodes reference; an unknown name raises UNKNOWN_LAYER_REFERENCE. Without a registry, those nodes run with the harness-default layers.
acpAgentsReadonlyMap<string, AcpAgent>Optional. ACP agent adapters keyed by agentId, resolving an acp-agent node's agent field.
uiLibrariesReadonlyMap<string, OutputCodec>Optional. Output codecs for callModel nodes' output codec references.
resolveSubprocess(ref: string) => SubprocessAdapter | undefinedOptional. Resolves a runCode node's named subprocess ref to an adapter. When a runCode node omits the ref, the harness default (ctx.subprocess) is used at execution time.
workflowsReadonlyMap<string, WorkflowDocument>Optional. Named sub-workflow documents that subflow nodes resolve via ref — lazily, so a live map may gain entries after hydration.
maxRecursionDepthnumberOptional. Deepest chain of named-ref resolutions on one path; default 100, Infinity for none.
expressionLimitsExpressionLimitsOptional. Bounds on each expression evaluation; unset fields use DEFAULT_EXPRESSION_LIMITS.
allowlistConnectionAllowlistOptional. Narrows { "connection" } tool entries to the operations it grants. Without it, a pick takes its ops as written.
mcpMcpAllowlistOptional. The MCP servers the document's { "mcp" } entries may name, in tools or connections. Without it, every server is refused with CONNECTION_NOT_ALLOWED.

hydrateWorkflow(doc, ctx) converts a validated WorkflowDocument into its root Step. hydrateNode(node, ctx) converts a single WorkflowNode. Both return Step<ContextData, string, string> -- indistinguishable from programmatically built steps.

Throws NoeticConfigError with code UNKNOWN_NODE_KIND if a node kind is unrecognised, UNKNOWN_TOOL_REFERENCE if a tool name cannot be resolved, UNKNOWN_UNTIL_PREDICATE if an until predicate kind is unrecognised, or INVALID_UNTIL_DECIDER if a decide predicate does not ask exactly one boolean question. At execution time a subflow ref can raise UNKNOWN_WORKFLOW_REFERENCE, WORKFLOW_RECURSION_LIMIT, or INVALID_MAX_RECURSION_DEPTH.

Limitations

  • runCode nodes need a subprocess adapter -- the JSON runCode node carries its body as a code string and dispatches it through a subprocess adapter (it is never eval'd in-process). A host that uses runCode nodes must supply an adapter capable of running the code and capturing its stdout.
  • Tools resolved by name -- every tool referenced in the workflow must be passed in the tools array. The hydrator looks them up by tool.name.
  • Subflow refs resolve at execution time -- a subflow node's ref is looked up on first execution, not during hydration, so an unregistered name surfaces as UNKNOWN_WORKFLOW_REFERENCE when the node runs. Depth limits do not see through refs; maxRecursionDepth bounds the depth of any one recursive path, but not total fan-out — hosts should keep it finite and cap registry size and breadth for untrusted documents.
  • Unbounded control flow is opt-in -- "maxIterations": "infinity" and maxRecursionDepth: Infinity remove the runtime's bounds. A host that runs untrusted documents and must bound every loop should reject "infinity" before hydrating.
  • Conditional routes test, they do not compute -- a conditional route is a substring match or a JSONata expr test. Routing that needs to run a step or call a function requires a programmatic conditional() step.

Plans as Workflows

The plan layer authors plans directly in this format: the reviewed plan tree is a WorkflowDocument, and the plan's named workflows feed the workflows registry. Composition also works from code — workflow runs a document (inline or by ref) as a single composable step:

import { workflow } from '@noetic-tools/core';

const verify = workflow({
  id: 'verify',
  ref: 'verify-loop',
  workflows: new Map([['verify-loop', verifyDoc]]),
  tools: [testRunnerTool],
  isolation: 'spawn', // optional: run in a fresh context boundary
});

workflow resolves the document lazily, memoizes the hydrated tree, and requires exactly one of document / ref (INVALID_WORKFLOW_SOURCE otherwise).

  • AgentHarness -- the execution engine that runs hydrated workflows.
  • Steps -- the step primitives that JSON nodes hydrate into.
  • Loop & Until -- programmatic equivalents of JSON until predicates.
  • In Parallel -- the parallel operator that inParallel nodes hydrate into.
  • Spawn -- isolated child execution for spawn nodes.
  • Connections -- the live things call, wait, and verify nodes act on.
  • Verification -- what a verify node answers.

On this page