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.

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.

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 }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
modelstring'openai/gpt-4o'Model identifier (OpenRouter format).
instructionsstringrequiredSystem/user prompt for the LLM.
tools{ type: string, parameters?: object }[]undefinedUniform 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.
paramsobjectundefinedModel 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"
}
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. 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" }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
toolNamestringrequiredName of a tool in the tool registry.
argsRecord<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" }
  ]
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
stepsWorkflowNode[]requiredOrdered 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"
}
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.
eachWorkflowNodeDynamic 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.
overstringundefinedWith 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.
concurrencynumberundefinedMaximum 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" }
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
routes{ match: string, target: WorkflowNode }[]requiredOrdered matching rules. First match wins (case-insensitive substring).
defaultWorkflowNodeundefinedFallback 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
}
FieldTypeDefaultDescription
idstringrequiredUnique step identifier.
bodyWorkflowNoderequiredThe node to repeat.
untilUntilPredicaterequiredTermination condition. See Until Predicates.
maxIterationsnumberundefinedHard 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
}
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.
{
  "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. 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

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

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.

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

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.

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

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.

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

  • 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; 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 -- conditional node routes use case-insensitive substring matching on the input string. Complex routing logic requires programmatic conditional() 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).

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

On this page