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.
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. |
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
Eleven node kinds are available, plus the acp-agent kind documented in ACP Agent Steps. Leaf nodes (callModel, invokeTool, runCode) perform work; structural nodes (sequence, inParallel, conditional, loop, spawn, withContext, schedule, subflow) compose them into trees.
callModel
Call an LLM with instructions and optional tools. Every tools entry is a uniform object { type, parameters? }. The type value decides the entry: 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).
{
"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 | { type: string, parameters?: object }[] | undefined | Uniform tool entries. 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. |
params | object | undefined | Model parameters: temperature, topP, maxTokens, stopSequences. |
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. A non-zero exit (or failed handle) surfaces as a thrown error.
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. |
sequence
Run steps sequentially, threading each output as the next step's input.
{
"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[] | required | Ordered list of child nodes (min 1). |
inParallel
Run multiple paths concurrently with configurable merge strategy. 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 | 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. |
merge | 'last' | 'first' | 'concat' | 'last' | How to combine results. Ignored in race mode. |
concurrency | number | undefined | Maximum concurrent paths. |
conditional
Route input to one of several targets based on substring matching.
{
"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, target: WorkflowNode }[] | required | Ordered matching rules. First match wins (case-insensitive substring). |
default | WorkflowNode | undefined | Fallback node when no route matches. |
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 | undefined | Hard cap on iterations (safety guard). |
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. |
{
"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. Ref chains are cycle-checked per execution path (WORKFLOW_CYCLE); 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.
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). |
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. |
Throws NoeticConfigError with code WORKFLOW_VALIDATION_FAILED if the planner cannot produce a valid workflow within the revision limit.
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. |
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.
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. |
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, or UNKNOWN_UNTIL_PREDICATE if an until predicate kind is unrecognised.
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; cycle detection prevents infinite recursion, but acyclic ref fan-out is not bounded — hosts should cap registry size and breadth for untrusted documents. - Conditional routing is substring-based --
conditionalnode routes use case-insensitive substring matching on the input string. Complex routing logic requires programmaticconditional()steps.
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.