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" }
}| Field | Type | Description |
|---|---|---|
version | 1 | Schema version. Currently only 1 is supported. |
root | WorkflowNode | The top-level node of the workflow tree. |
connections | Record<string, WorkflowConnectionRequirement> | Optional. The connections the document needs, by name. See Connections. |
customItems | Record<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):
| Field | Type | Effect |
|---|---|---|
durable | boolean | false on cheap orchestration nodes makes a resumed run re-run them instead of replaying their recorded output. |
replayKey | true | { 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:
| Form | Emits |
|---|---|
"emitItems": true | The 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
runCodenode emits its stdout. - A
decidenode emits its answers object, and itsonDonecan read it ("stop.value ? 'Done' : 'Trying again'"). When the node is a loop'suntil, it emits after every verdict. - A structural node (
sequence,loop,subflow, …) emits its own output once it finishes, after its children. - A
callModelnode's reply is already in the conversation, soemitItemson one adds a second copy. - Emitted items are normal conversation items: later
callModelnodes see them, andHarnessResponse.itemsincludes 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 }
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
model | string | 'openai/gpt-4o' | Model identifier (OpenRouter format). |
instructions | string | required | System/user prompt for the LLM. |
tools | object[] | undefined | Tool 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. |
params | object | undefined | Model 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 theopsglobs —connectionTools(name, { ops }). Under aconnectionsallowlist the name must be granted and each glob must lie inside a granted one; with noopsit takes exactly the granted operations. AcallModeloracp-agentnode a document check runs (parseAndRunWorkflow,dynamicWorkflow) gets exactly the tools it lists; with notoolsit is offered each granted connection with exactly its granted operations plus the MCP servers the document'sconnectionsattach, and its other tools as any step with notools. Withoutconnections, or with{}, it is offered no connection.{ "mcp": { "name", "type": "http" | "sse", "url", "headers"? } }attaches an MCP server to the step, liketools: [mcp({ name, type, url, headers })]: ACP'sMcpServershape, asmcp()takes it. A stdio server is{ "mcp": { "name", "command", "args"?, "env"? } }; on anacp-agentnode it starts where the node's agent runs. Header and environment values are{ "$secret" }s only, and there is nocwdorpolicy. The host'smcpallowlist 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"
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
execute | string | required | Source code dispatched to the subprocess. Receives the step input on stdin; its stdout becomes the step output. |
retry | object | undefined | Retry policy: { maxAttempts, backoff: 'fixed' | 'linear' | 'exponential', initialDelay, maxDelay? }. |
subprocess | string | undefined | Named subprocess adapter ref, resolved via HydrationContext.resolveSubprocess. When omitted, the harness default (ctx.subprocess) is used. |
Execution requires a subprocess adapter. The
runCodenode 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-nodedoes: it runs the body withnode -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" }
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
toolName | string | required | Name of a tool in the tool registry. |
args | Record<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 }"
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
expr | string | required | A 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" }
]
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
steps | (WorkflowNode | { "<op>": args })[] | required | Ordered 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"
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
mode | 'all' | 'race' | 'settle' | required | all waits for every path; race returns the first; settle waits for all but tolerates failures. |
paths | WorkflowNode[] | — | Static child nodes to execute concurrently. Mutually exclusive with each. |
each | WorkflowNode | — | 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. |
over | string | string[] | undefined | With 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. |
concurrency | number | undefined | Maximum 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" }
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
routes | ({ match: string } | { expr: string }) plus target: WorkflowNode, as an array | required | Ordered routes, each with exactly one test. The first route whose test passes wins. |
default | WorkflowNode | undefined | Fallback 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"
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
model | string | harness decisionModel | Decision or chat model id. |
instructions | string | undefined | Shared framing for every question. |
questions | Record<string, DecideQuestion> | required | boolean, 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. |
samples | number | 1 | Positive 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" }
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
decide | DecideWorkflowNode | required | The decision to route by. |
on | string | required | Id of the question to route by. |
cases | Record<string, WorkflowNode> | required | One node per outcome of on: each choice, "true" and "false" for a boolean, or each level label for a score. |
minConfidence | number | undefined | In [0, 1]. Below this confidence, take uncertain. |
uncertain | WorkflowNode | required | Taken 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
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
body | WorkflowNode | required | The node to repeat. |
until | UntilPredicate | required | Termination condition. See Until Predicates. |
maxIterations | number | "infinity" | 1000 | Hard 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
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
child | WorkflowNode | required | The node to execute in a child context. |
timeout | number | undefined | Timeout in milliseconds. |
layers | string[] | undefined | Context 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"]
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
child | WorkflowNode | required | The node to wrap. |
layers | string[] | required | Layer 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"
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
step | WorkflowNode | required | The node to execute each interval. |
interval | number | required | Interval in milliseconds. |
onError | 'continue' | 'fail' | undefined | Whether 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"
}| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
document | WorkflowDocument | XOR | Inline sub-workflow. Supply exactly one of document / ref. |
ref | string | XOR | Named sub-workflow resolved from HydrationContext.workflows (or the workflows option on parseAndRunWorkflow). |
input | string | undefined | Literal 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 field | Type | Description |
|---|---|---|
profile | string | <profile>@<range>, e.g. browser@^1.2. Requiring a profile requires its parents. |
extensions | Record<string, string> | Extension id → semver range. |
events | string[] | For a source: the event types it must offer. |
from | string | The 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. |
setup | object | Passed 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.
| Value | Meaning |
|---|---|
{ "$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.
| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
connection | string | { "$expr" } | enclosing connection | A manifest name, or an expression ($item in a fan-out). |
op | string | required | The operation, e.g. ui.tap. |
args | object | { "$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 }| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
connection | string | { "$expr" } | enclosing connection | The connection to listen on. |
event | string | required | The event type. |
filter | string | undefined | An expression evaluated against { "event": …, "input": … }; the event matches when it gives true. Without one, every event of the type matches. |
around | node, op, or array | undefined | Steps run once listening has started. |
timeoutMs | number | required | How 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\"" } } ] }| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique step identifier. |
subject | string | string[] | { "$expr" } | required | The connection checked, or a list (one verify per connection, ids <id>.<name>, the worst outcome). |
expect | Matcher[] | XOR | What 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. |
never | Matcher[] | undefined | What must not happen while the action runs. |
claims / constraints | OVP documents | — | The full OVP form, instead of expect / never. |
act | node, op, or array | required | The action. Steps run on the subject unless they name another connection. |
witnesses | string[] | undefined | Connections that confirm from outside the subject. |
close | { until?, event?, quietMs?, timeoutMs? } | quiet 500 ms, at most 15 s | When the window closes. event is required with until: 'event', and only then. |
intent | string | undefined | Why, 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
| Kind | Fields | Description |
|---|---|---|
maxSteps | n: number | Stop after n iterations. |
maxCost | usd: number | Stop when cumulative cost exceeds usd dollars. |
maxDuration | duration: number | Stop after duration milliseconds. |
noToolCalls | -- | Stop when the LLM produces no tool calls in a round. |
outputContains | marker: string | Stop when output contains the marker substring. |
outputEquals | sentinel: string | Stop when output exactly equals the sentinel. |
converged | threshold?: number | Stop 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. |
expr | expr: string | Stop when a JSONata expression over the iteration's output gives true. |
decide | a decide node | A 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
| Field | Type | Default | Description |
|---|---|---|---|
model | string | 'openai/gpt-4o' | Model for the planner LLM. |
instructions | string | undefined | Additional instructions prepended to the planner prompt. |
tools | Tool[] | required | Tools the generated workflow may reference by name. |
maxDepth | number | 5 | Maximum allowed tree depth for generated workflows. |
maxRevisions | number | 3 | Retries with error feedback on validation failure. |
connections | ConnectionAllowlist | undefined | What 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. |
acpAgents | ReadonlyMap<string, AcpAgent> | undefined | ACP agents a generated acp-agent node may run, keyed by agent id. The planner is shown these ids. See ACP agents. |
acp | AcpAllowlist | undefined | Per 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. |
mcp | McpAllowlist | undefined | The 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
| Field | Type | Default | Description |
|---|---|---|---|
json | unknown | required | Raw JSON (string or parsed object) representing a workflow document. |
harness | AgentHarnessContract | required | The AgentHarness to execute the workflow with. |
ctx | Context | required | Execution context. |
tools | Tool[] | required | Available tools the workflow may reference by name. |
maxDepth | number | 5 | Maximum allowed workflow tree depth. |
layers | ReadonlyMap<string, ContextLayer> | undefined | Named context layers that withContext/spawn nodes may reference. |
workflows | ReadonlyMap<string, WorkflowDocument> | undefined | Named 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. |
maxRecursionDepth | number | 100 | Deepest chain of named-ref resolutions on one path. A positive integer or Infinity. |
expressionLimits | ExpressionLimits | DEFAULT_EXPRESSION_LIMITS | Bounds on each expression evaluation (steps, depth, length, timeout). See Expressions. |
connections | ConnectionAllowlist | undefined | What 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. |
acpAgents | ReadonlyMap<string, AcpAgent> | undefined | ACP agents the document's acp-agent nodes may run, keyed by agent id. |
acp | AcpAllowlist | undefined | Per 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. |
mcp | McpAllowlist | undefined | The 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 inpermissions,mode,model,cwd,clientCapabilitiesandsession.mcp— the MCP servers a node may attach as{ "mcp" }tools entries (or the document asconnectionsentries), 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 field | A node may | Without a grant |
|---|---|---|
permissions.default | set default: "allow" only when this is 'allow' | deny or cancel only |
permissions.allow | write 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.persist | set persist | no |
modes | set 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 |
models | set model to one of these | any model |
cwd | name 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 |
clientCapabilities | turn 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.keepAlive | keep 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.load | resume an agent session by id | no |
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 matchhttps://evil.com/.example.com/. The host matches case-insensitively and may start with*.for any subdomain — one or more non-empty labels, sohttps://*.example.com/does not matchhttps://.example.com/. After*.come at least two labels:*.comis 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:orhttps:, carry no user or password, and be written exactly asnew URL(url).hrefreads 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.comwithout the/) is refused, with the form to write instead. - A stdio server must have the same
commandand the sameargs, 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 itspolicyapplies. Literal values are refused. The secret is resolved throughenvironment.secretswhen the server connects, and scrubbed from outputs, events and traces; a document using one on a harness withoutenvironment.secretsfails withCONNECTION_INVALIDbefore anything runs. - The server's
policycomes from its grant. A document cannot set one. providerconnects the server. Without it, a document that names a server fails withCONNECTION_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
| Field | Type | Description |
|---|---|---|
tools | ReadonlyMap<string, Tool> | Tool registry mapping tool names to Tool instances. |
executeStep | ExecuteStepFn | Step executor function (typically harness.run.bind(harness)). |
layers | ReadonlyMap<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. |
acpAgents | ReadonlyMap<string, AcpAgent> | Optional. ACP agent adapters keyed by agentId, resolving an acp-agent node's agent field. |
uiLibraries | ReadonlyMap<string, OutputCodec> | Optional. Output codecs for callModel nodes' output codec references. |
resolveSubprocess | (ref: string) => SubprocessAdapter | undefined | Optional. 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. |
workflows | ReadonlyMap<string, WorkflowDocument> | Optional. Named sub-workflow documents that subflow nodes resolve via ref — lazily, so a live map may gain entries after hydration. |
maxRecursionDepth | number | Optional. Deepest chain of named-ref resolutions on one path; default 100, Infinity for none. |
expressionLimits | ExpressionLimits | Optional. Bounds on each expression evaluation; unset fields use DEFAULT_EXPRESSION_LIMITS. |
allowlist | ConnectionAllowlist | Optional. Narrows { "connection" } tool entries to the operations it grants. Without it, a pick takes its ops as written. |
mcp | McpAllowlist | Optional. 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
runCodenodes need a subprocess adapter -- the JSONrunCodenode carries its body as a code string and dispatches it through a subprocess adapter (it is nevereval'd in-process). A host that usesrunCodenodes 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
toolsarray. The hydrator looks them up bytool.name. - Subflow refs resolve at execution time -- a
subflownode'srefis looked up on first execution, not during hydration, so an unregistered name surfaces asUNKNOWN_WORKFLOW_REFERENCEwhen the node runs. Depth limits do not see through refs;maxRecursionDepthbounds 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"andmaxRecursionDepth: Infinityremove 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
conditionalroute is a substringmatchor a JSONataexprtest. Routing that needs to run a step or call a function requires a programmaticconditional()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).
Related Pages
- 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
inParallelnodes hydrate into. - Spawn -- isolated child execution for
spawnnodes. - Connections -- the live things
call,wait, andverifynodes act on. - Verification -- what a
verifynode answers.