The complete workflow JSON schema, the type system, condition operators, the
runtime container a run returns, the xdog-flow CLI, and every check that runs
at load time. A workflow is one JSON object executed directly by the runtime or
compiled to Python — both agree field-for-field.
JSON schema
Workflow (top level)
A workflow is one JSON object. Nothing is strictly required, though a useful
workflow declares nodes and edges. entry is optional — when omitted, the
run starts from every node that depends only on $in (multi-entry, run in
parallel).
| Field | Required | Meaning |
|---|---|---|
name |
default "" |
Human-readable workflow name; shown in diagrams and the runtime container. |
provider |
default "" |
LLM provider id used to build the default agent stream (e.g. copilot). |
entry |
optional | Id of the first node to run; when unset, derived from the $in frontier (multi-entry). |
defaults.model |
default "" |
Fallback model for any agent node that does not set its own model. |
state |
default {} |
Object of initial values, exposed as the output ports of the $in source. |
nodes |
default [] |
The list of node objects (see below). |
edges |
default [] |
The list of edge objects wiring node ports together. |
tools |
default {} |
Custom-tool manifest: {tool_name: "module.path:callable"}. |
in_schema |
inferred | Explicit JSON Schema properties for $in; otherwise inferred from typed consumers. |
max_concurrency |
default 0 |
Maximum static frontier nodes running concurrently; 0 is unlimited. |
fan_max_concurrency |
default 0 |
Per-fan-group dynamic instance cap; independent from the outer frontier cap. |
schedule |
optional | Timer or hook declaration consumed by xdog-flow scheduling install; ignored by the one-shot engine. |
Node
A node is either an agent (calls an LLM) or a script (runs Python). type
defaults to agent. An agent node must not set code or run; a script node
must set exactly one of them.
| Field | Required | Meaning |
|---|---|---|
id |
required | Unique node id. $in and $output are reserved and rejected. |
type |
default "agent" |
"agent", "script", "human", or "subflow". |
model |
optional | Agent only: overrides defaults.model for this node. |
system_prompt |
default "" |
Agent only: system prompt; {{ $.port }} reads this node's inputs. |
prompt |
default "" |
Agent only: user prompt; {{ $.port }} is a JSONPath into this node's inputs. |
tools |
default [] |
Agent only: names of built-in or manifest tools to expose. |
web_search |
default false |
Agent only: enable the built-in web_search tool. |
web_search_model |
optional | Agent only: a distinct browsing model for web_search. |
code |
optional | Script only: inline source defining exactly one ctx-first function. |
run |
optional | Script only: a "module.path:callable" reference imported at run time. |
subflow |
optional | Subflow only: the child workflow — an inline object or a "./child.json" path string. Ports are derived from the child's signature; do not declare inputs/outputs. |
retry |
optional | {max, backoff} retry policy; max counts retries after the first attempt. |
on_error |
default "fail" |
"fail" aborts the run; "isolate" records the branch failure and skips its descendants. |
deterministic |
default false |
Memoize output by node id and input hash for safe retry/resume reuse. |
backend |
optional | Agent only: "claude-cli" or "codex-cli"; absent uses the in-process SDK. |
allowed_tools |
default [] |
CLI agent only: narrows that CLI's own tool set. |
mcp_servers |
default {} |
CLI agent only: opaque MCP server specs with ${ENV_VAR} interpolation. |
inputs |
default [] |
Input ports: a bare required string name or {name, schema, required}. |
outputs |
default [] |
Output ports in the same canonical forms. For an agent, >1 port (or one non-string port) makes it a structured submit_result node — the schema is derived from the ports. |
Port
A port is a bare string (a required string port) or an object. A structured
output port lets an agent fan its submit_result result across several ports and
lets an edge map a sub-field with a type check.
| Field | Required | Meaning |
|---|---|---|
name |
required | Port name; referenced by edge maps and {{ $.name }} interpolation. |
schema |
required in object form | A JSON Schema fragment: scalar or nested {type, properties, items, required}. A bare string port implies {"type":"string"}. |
required |
default true |
Input only: false exempts it from the must-be-fed rule for loop-carried or conditionally supplied values. |
Edge
An edge moves data from a source node's output ports to a destination node's
input ports. An empty map is a pure control edge (ordering only). $in is
source-only; $output is sink-only.
| Field | Required | Meaning |
|---|---|---|
from |
required | Source node id, or the reserved $in source. |
to |
required | Destination node id, or the reserved $output sink. |
map |
default {} |
{source_output_port: destination_input_port} pairs. A source key may be a JSONPath into a structured port, e.g. "$.verdict.within_budget". |
when |
optional | A condition; the edge only fires (or feeds) when it holds. |
loop.max |
optional | Marks a bounded back-edge; exhausted plain loops stop normally. |
while |
optional | Strict bounded-loop sugar carrying cond and optional max; a still-true exhausted condition raises non-convergence. |
fan_out |
optional | Names the source array port whose elements run one worker instance each. |
fan_in |
optional | "list" preserves one value per instance; "concat" flattens array-valued instance outputs one level. |
Type system
Every port carries a JSON Schema (a bare name implies a required string port). The
wire format is type-native: a port value is the live Python value — a script
node reads its inputs as that value and returns the value directly, with no
stringify/parse round-trip. An edge is type-checked at load time: the source and
destination port types must match (a JSONPath sub-field is checked against the
source schema when it can be resolved). $in seed values are untyped, so edges
out of $in are exempt.
| Type | Python value | Empty → |
|---|---|---|
string |
str |
"" |
integer |
int |
0 |
number |
float |
0.0 |
boolean |
bool (true/1/yes/on → true) |
false |
array |
list |
[] |
object |
dict |
{} |
Interpolation & conditions (JSONPath)
A prompt or a condition operand may embed {{ <jsonpath> }} placeholders,
resolved against the node's inputs (or, for an edge when, the source node's
output ports): {{ $.topic }} for a whole port, {{ $.plan.tasks[0] }} for a
nested field. An unresolved path yields the empty string. The same jsonpath-ng
evaluator runs in both the interpreter and the generated module.
Condition operators
An edge's when is a condition tree evaluated against the source node's output
ports. value and text support {{ $.port }} JSONPath interpolation.
| Op | JSON | Holds when |
|---|---|---|
equals |
{"equals": {"value": V, "text": T}} |
interpolate(value) == interpolate(text) |
contains |
{"contains": {"value": V, "text": T}} |
interpolate(text) in interpolate(value) |
gt / gte / lt / lte |
{"gte": {"value": V, "text": T}} |
numeric compare: float(value) ≥ float(text) (an empty operand is lenient-false; a non-numeric one errors) |
not |
{"not": <cond>} |
negation of one child condition |
and |
{"and": [<cond>, ...]} |
all children hold |
or |
{"or": [<cond>, ...]} |
any child holds |
Every {{ $.key }} operand root is checked at load time against the source node's
output ports (strict interpolation), so a typo fails validation.
Runtime container and process result
The Python execute() API returns an internal runtime container. CLI and generated
Python process boundaries print a stable envelope:
{
"success": true,
"message": "Workflow completed",
"output": {},
"context": {
"workflow": "demo",
"runId": null,
"startTime": "...",
"endTime": "...",
"durationMs": 42,
"tokensUsed": 0,
"lastNode": "finish"
}
}
Failures use the same shape with success:false, an error message, empty
output, and a non-zero process exit code. The internal execute() container is:
| Key | Contents |
|---|---|
ctx |
The last node to run: {step, node_id, workflow_name}. This is also what a script node receives. |
stack |
A per-node delta trace: one {step, node, in, out} frame per run; a looped node appears once per pass. |
state |
Real-node outputs only: {node_id: {port: value}} — excludes $in and $output. |
in |
The $in seed: the workflow's state, with any run-time input overrides applied. |
out |
The $output map: the key/value pairs collected from edges targeting $output. |
failed |
Isolated failures: {node_id: error} for branches captured by on_error:isolate. |
memo |
The determinism ledger: memo_key(node, input hash) → output ports, for deterministic reuse. |
tokens_used |
Cumulative agent tokens spent this run; enforced against execute(max_tokens=…). |
CLI — xdog-flow
Every subcommand accepts a .json workflow or a .svg with the JSON embedded.
xdog-flow validate <config>— Load and validate a workflow; prints OK or the first error.xdog-flow run <config>— Execute a workflow and print the structuredsuccess/message/output/contextresult envelope.--dry-run— Inject a stub LLM; agent nodes echoDRYRUN:<model>so you can test wiring offline.--input K=V— Seed or override a$invalue (repeatable; split on the first=).--provider X— Override the AI provider.--timeout N— Per-node wall-clock timeout in seconds (default 120).-v / --verbose— Show flow's DEBUG logs — node execution and loop firing.xdog-flow generate <config>— Compile the workflow to a standalone Python module.-o / --output FILE— Write to a file instead of stdout.--portable -o DIR— Emit a self-contained bundle;ai/agentare vendored only when an SDK agent node needs them (a pure-CLI/script bundle drops them), and--offlinealso downloads wheels for a no-network install.xdog-flow graph <config>— Render the workflow graph.--mermaid— Emit a Mermaid flowchart.--svg— Emit an SVG document with the workflow JSON embedded.xdog-flow build <config>— Open the interactive TUI builder (created if the file is missing).xdog-flow scheduling install <config>— Build a portable bundle and install a timer/hook scheduler; supports--nameand--dry-run.xdog-flow scheduling list— List locally installed scheduled workflows.xdog-flow scheduling uninstall <name>— Remove units, bundle, and registry entry; supports--dry-run.xdog-flow test <target>— Run a workflow's companion*.test.jsonsuite. Accepts a workflow (finds the sibling suite), a suite file, or a directory to sweep. Exits 1 on failure.--case NAME— Run a single case.--allow-script-stub— Permitscriptsstubs (script nodes run for real by default).-v / --verbose— Show the node trace for passing cases too.
Workflow tests — xdog-flow test
A workflow is a program, so it needs tests. Flow uses a companion suite rather than embedding tests in the production artifact:
release_readiness.json
release_readiness.test.json
xdog-flow test release_readiness.json
xdog-flow test examples/
xdog-flow test release_readiness.json --case "critical finding blocks the release"
Only the boundaries a test cannot reason about are stubbed. Agent turns (SDK and
CLI backends alike), human signals, and whole subflow nodes; script nodes are opt-in
behind --allow-script-stub, since stubbing deterministic logic hides the thing
under test. Edges, conditions, loops, fan-out, fan-in aggregation, mappings, type
coercion, retry and $output collection all run for real — a case cannot degrade
into "I mocked the chain and asserted my own mocks".
Stubs are injected at the provider call, not at the node output. Prompt
interpolation has already run, and the stubbed value still passes through the node's
required-field check and to_state coercion. So a stub is validated by the same code
that validates a real model response, and a broken {{$.path}} is still broken.
Because the stub runner answers every agent node, no provider or CLI is ever
constructed — a test cannot reach the network by accident.
{
"cases": [
{
"name": "critical finding blocks the release",
"inputs": {"repo": "/fixture/repo", "base_ref": "main"},
"agents": {
"plan_checks": {"checks": [{"name": "security", "focus": "auth"}], "rationale": "..."},
"audit": [
{"when": {"check": {"name": "security"}}, "then": {"finding": {"severity": "critical"}}},
{"then": {"finding": {"severity": "low"}}}
]
},
"subflows": {"report": {"final_report": "# Blocked", "quality_score": 9, "report_feedback": "ok"}},
"expect": {
"output": {"risk": {"status": "blocked", "release_allowed": false}},
"calls": {"audit": 3, "revise": 0}
}
}
]
}
A stub is authored as the node's output ports — either a constant, or an ordered rule list where the first match wins. Three selectors, each stable under concurrency:
| Selector | Means | Why it is deterministic |
|---|---|---|
when |
deep-subset match on the activation's inputs | a value match, so fan-out completion order is irrelevant |
index |
the instance's position in the fanned array | the source array, not arrival order |
round |
which activation of the node this is, 1-based | fan instances share a round; loops are sequential |
Use when for fan-out and round for loops — that is how a case pins down loop
termination: score below the gate on round 1, above it on round 2.
expect takes exactly one outcome — success (the default), error: "<substring>",
or paused: "<human node id>" — plus two partial assertions. output is a deep
subset of $output; calls maps node id to invocation count and collapses four
questions into one number:
| Question | Written as |
|---|---|
| did this node run? | {"report": 1} |
| was this branch skipped? | {"revise": 0} |
| how many fan instances? | {"audit": 3} |
| how many loop iterations? | {"critique": 2} |
Objects match as subsets, arrays must be the same length, and bool only matches
bool. On failure only the deepest differing path is printed, with a trace marking
each node stub or ran. Two further failures are about the suite rather than the
workflow: no rule matched a call (the activation's inputs are printed), and a
selector that never fired (a stale round: 3 against a two-iteration loop).
Authoring mistakes are caught at load time — a stub aimed at a missing node, at the wrong node type, or setting an undeclared output port never reaches execution.
Nodes inside a subflow are deliberately not stubbable: a child workflow carries its
own .test.json, so the parent asserts the composition and the child asserts its own
internals. xdog-flow test runs the interpreter only — interpret == compile is
flow's own invariant, guarded by flow's own suite, and binding your cases to codegen
output would couple them to something they should not know about.
CLI agent nodes
An agent node may set "backend": "claude-cli" or "codex-cli" to run its turn by
shelling out to a coding-agent CLI instead of the in-process SDK:
- No provider — the CLI owns auth; a workflow whose agent nodes are all
CLI-backed omits
provider(it is required only for an SDK agent node). allowed_tools— a list that NARROWS the CLI's own tools (built-ins like"Read", or MCP tools"mcp__server__tool");[]= no tools, tightest sandbox.mcp_servers— an opaque per-server spec flow converts into the CLI's MCP config, with${ENV_VAR}secret interpolation (the JSON carries the reference, never the token).- The CLI binary is found on
PATH;FLOW_CLI_BIN(orFLOW_CLI_BIN_CLAUDE_CLI) overrides it. Both engines shell the same command, sointerpret == compileholds.
Scheduling
A top-level schedule block makes a workflow fire on its own (config for
xdog-flow scheduling install; the engine ignores it):
{"mode": "timer", "every": "15m"}or{"mode": "timer", "cron": "0 9 * * 1-5"}— a systemd user timer (or crontab fallback) runs the bundle on schedule.{"mode": "hook", "signal": "s", "listen": {"type": "http", "path": "/hooks/x", "port": 8787}}— an external event deliverssignalto a fresh run (routed by one shared, systemd-supervised listener). Pair it with ahumannode on the same signal.
Every firing is an independent python <bundle> run — the engine is unchanged.
Generated-module run-time overrides
A generated module reads a few environment variables at run time, so a compiled
workflow (or a --portable bundle run with python .) stays overridable without
regenerating — parity with run's flags:
FLOW_INPUTS='{"days": 2}'— a JSON object merged per-key into$in(mirrors--input).FLOW_PROVIDER=openai— override the provider (mirrors--provider).FLOW_MAX_TOKENS=100000— abort once cumulative agent tokens pass the ceiling.FLOW_RUN_ID+FLOW_CHECKPOINT_DIR— enable checkpoint/resume.FLOW_SIGNALS=go,ready— deliver human-node signals (how a hook run passes its gate).FLOW_CLI_BIN(orFLOW_CLI_BIN_CLAUDE_CLI) — override the CLI binary a CLI agent node shells out to.
Validated before it runs
Loading a workflow runs every check below; any failure raises before a single node executes. This is what lets diagrams and codegen trust the graph.
| Rule | Triggered by |
|---|---|
| Node id must be non-empty | A node object has an empty id. |
Node id $in / $output is reserved |
A real node tries to claim a reserved id. |
| Duplicate node ids | Two nodes share the same id. |
| Entry node not found in nodes | entry names a node that does not exist. |
Agent node must not set run / code |
An agent node carries script-only fields. |
Script must set exactly one of code or run |
A script node sets both or neither. |
Script run must match module.path:callable |
A malformed run reference. |
| Script inline code invalid / must define one function | Inline code fails to parse or has ≠1 function. |
Script function's first parameter must be ctx |
The inline function signature does not start with ctx. |
| Script params != declared inputs | The function parameters and the declared input ports disagree. |
| References unknown tool | A node names a tool that is neither a built-in nor in the manifest. |
Tool manifest ref must match module.path:callable |
A malformed custom-tool reference. |
Edge src $output / dst $in not allowed |
Using the sink as a source, or the source as a sink. |
| Edge src / dst not found in nodes | An edge names an endpoint that does not exist. |
| Source/destination has no such port | An edge map names a port a node doesn't declare. |
Back-edge must have loop.max >= 1 |
A back-edge (dst not strictly after src) is not a bounded loop. |
| Input port is fed by N unconditional edges | Two always-on producers target one input (ambiguous). |
| Input port is not fed by any edge mapping | A required input port has no feeder. |