Reference
Node reference
Six primitives compose every workflow. This page documents each one exactly as the engine executes it — every config key, its default, how the node writes state and selects edges, and the validation rules that block or warn before a run.
Anatomy of a node
Every node — regardless of type — has the same outer shape: a unique id, a type, a canvas position, and a type-specific config. The position is cosmetic; it positions the node on the builder canvas and never affects execution.
{ "id": "score", "type": "agent",
"position": {"x": 0, "y": 150},
"config": { /* type-specific keys documented below */ } }
Node ids must be unique per graph — a duplicate id is a validation error. Ids do double duty: they name the step in a run's timeline, and they are the default key a node writes its output to. Every node type also accepts three universal config fields.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name in the console and the step timeline. Falls back to the node id. |
| timeout_seconds | number | 120 | Hard wall-clock limit for one execution attempt (DEFAULT_TIMEOUT_S). Exceeding it raises, which then counts against retries. |
| retry_count | int | 1 | Automatic retries after the first attempt (DEFAULT_RETRIES). retry_count of 1 means up to 2 attempts total. |
Retries, timeouts, and backoff
Each attempt runs under an asyncio timeout of timeout_seconds. If an attempt raises (including by timing out), the engine sleeps and retries, up to retry_count extra times. Backoff is exponential and capped: 1s, 2s, 4s, … never exceeding 30s. Only when the last attempt still raises does the node — and the run — fail.
tool node pointing at a custom: registered tool, an unset timeout_seconds or retry_count falls back to the value stored on the tool registration before the engine defaults — so you can set sensible reliability limits once, on the tool, and reuse it across workflows.output_key and step records
Agent and tool nodes write their result into run state under output_key, which defaults to the node id. Every node execution also produces a step: its status, its input snapshot (public state at start; agent steps store null to avoid duplicating the whole prompt), its output, its cost, and its timing. How state flows between nodes via output_key and {templates} is covered in Core concepts.
Agentagent
One LLM call. The rendered user_message is sent to model with the rendered system_prompt; the model's reply text is written to state under output_key. Token usage is priced against the per-model table and recorded on the step.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name. |
| model | string | mock | claude-sonnet-4-6, claude-haiku-4-5, gpt-4o, gpt-4o-mini, or mock. Ballast is bring-your-own-key: real models run through a workspace model connection (Settings → Model connections) with your own provider key. The keyless mock model needs no setup; a real model with no connected key is blocked with a clear error — it never silently falls back to mock. |
| system_prompt | string | "" | Role and instructions. Supports {placeholders}. Empty defaults to a generic helpful-agent prompt at the provider. |
| user_message | string | state as JSON | The prompt body. Supports {placeholders}. When omitted, the full public state is serialized to JSON and truncated to 8,000 characters. |
| temperature | number | 0.7 | Sampling temperature passed through to the provider. |
| max_tokens | int | 1024 | Provider output-token ceiling. |
| output_key | string | node id | State key that receives the reply text (a string) — or the typed parsed object when response_schema is set. |
| response_schema | object | none | A JSON Schema. When set, the reply is parsed, validated, and auto-repaired against it, and downstream receives the typed parsed object. See Structured output below. |
| repair_attempts | int | 2 | How many times an invalid structured reply is re-prompted with its validation errors before the node fails. |
| fallback_models | string[] | [] | Ordered fallback model ids. On an approved primary failure the gateway fails over to the next allowed model. See Model fallback below. |
| fallback_error_classes | string[] | transient classes (auth excluded) | Which error classes trigger fallover — rate_limit, timeout, server_error, http_error, malformed, network, auth. auth is excluded by default. |
The step's output records {text, model, input_tokens, output_tokens} and the priced cost_usd. An agent node fires all of its outgoing edges when it completes.
{
"id": "score",
"type": "agent",
"position": {"x": 0, "y": 150},
"config": {
"label": "Score Lead",
"model": "claude-haiku-4-5",
"system_prompt": "Score this lead 0-100 for fit. Reply with just the number.",
"user_message": "Lead: {lead}",
"temperature": 0.1,
"max_tokens": 20,
"output_key": "score"
}
}
Unrecognized models are a warning, not an error
Amodel not in the pricing table still runs, but its cost is tracked as $0. Validation surfaces a warning so the mistake is visible without blocking the workflow. Pricing per model is in Core concepts.Structured output (JSON Schema + repair)
Set response_schemato a JSON Schema and the node stops returning free-form text: the model's reply is parsed, validated against the schema, and auto-repaired — on a validation failure the engine re-prompts the model with the exact errors, up to repair_attempts times (default 2). A reply that is still invalid after the last attempt fails the node with a clear error. On success, downstream nodes receive the typed parsed object under output_key — not the raw string — and the step output carries both text (the raw reply) and parsed, plus schema_valid and the number of repair_attempts it took.
{
"id": "score",
"type": "agent",
"config": {
"label": "Score Lead",
"model": "claude-haiku-4-5",
"user_message": "Lead: {lead}",
"response_schema": {
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 0, "maximum": 100},
"reason": {"type": "string"}
},
"required": ["score", "reason"]
},
"repair_attempts": 2,
"output_key": "score"
}
}
Model fallback
List ordered fallback_models and a primary model failure fails over to the next candidate — but the gateway is governance-checked, not blind. It first classifies the error (rate_limit, timeout, server_error, http_error, malformed, network, or auth) and only fails over for approved classes. By default auth errors do not fail over — a bad key or forbidden model is a config error, not a transient blip — which you can override with fallback_error_classes. Each fallback candidate is then vetted against the workspace model and provider allowlist before it is used; a forbidden fallback is refused and recorded as a model_fallback_blocked governance event. The step output records the model and provider actually used and fallback_used.
{
"id": "summarize",
"type": "agent",
"config": {
"model": "claude-sonnet-4-6",
"fallback_models": ["gpt-4o", "claude-haiku-4-5"],
"fallback_error_classes": ["rate_limit", "timeout", "server_error"],
"user_message": "Summarize: {raw_data}",
"output_key": "summary"
}
}
Tooltool
A typed side effect. String paramsare rendered against state before the call, and the tool's result object is written to output_key. Tool steps always cost $0. Built-in tools: http_request, slack_message, send_email, delay, and mcp_call — each documented with its params in the tool reference.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name. |
| tool_name | string | required | One of the five built-ins, or custom:<name> for a registered tool. An unknown name fails validation before the run starts. |
| params | object | {} | Tool arguments. String values support {placeholders}, applied recursively through nested objects and lists. |
| output_key | string | node id | State key that receives the tool's result object. |
{
"id": "fetch",
"type": "tool",
"position": {"x": 0, "y": 150},
"config": {
"label": "Fetch Data",
"tool_name": "http_request",
"params": {"method": "GET", "url": "https://api.github.com/zen"},
"output_key": "raw_data"
}
}
Referencing a custom:tool that was deleted after the graph was saved is caught two ways: validation flags it before the next run, and if it somehow reaches execution the node fails with a clear “re-register it or update the node” message rather than silently skipping the side effect. See the tool reference for registering HTTP and MCP tools.
blocked_arguments, which refuses a call that passes a blocked argument value), plus an idempotency_key to collapse duplicate side effects and a compensation hook to undo one. The tool reference owns the deep detail.Conditioncondition
A branch. It evaluates expression against public state and follows the outgoing edges whose condition_value (or label, as a fallback) matches the boolean result — "true" or "false", case-insensitive. The step output records {expression, result}. A condition writes nothing to state.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name. |
| expression | string | "" (true) | A safe expression over state variables. An empty expression is always true. Full grammar in the expression language reference. |
{
"id": "check",
"type": "condition",
"position": {"x": 560, "y": 150},
"config": {"label": "Anomaly?", "expression": "'unusual' in str(summary).lower()"}
}
// edges select on the boolean result:
{"id": "e3", "source": "check", "target": "alert", "condition_value": "true"}
{"id": "e4", "source": "check", "target": "log", "condition_value": "false"}
Expressions run in a sandboxed AST interpreter — no imports, no attribute access beyond a small method whitelist, capped length and complexity. The full list of allowed functions and operators is in the expression language reference.
A branch with no matching edge ends there
If the result isfalse and there is no false edge, that path simply stops — the run can still succeed on its other branches. Validation warns when a condition is missing its true or false edge, and an edge with no condition_value/label is treated as unconditional — it fires on both results.Looploop
A condition with memory and a hard cap. Each time the loop node is visited it increments an internal counter stored in state under __loop_<node_id>. The loop continues while expression is true and that counter is within max_iterations. The true edge re-enters the loop body; the false edge exits. The step output records {expression, iteration, continue}.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name. |
| expression | string | "" (true) | The continue condition, evaluated over public state before each iteration. |
| max_iterations | int | 5 | A hard ceiling that overrides the expression. Must be at least 1 (a validation error otherwise). |
{
"id": "refine",
"type": "loop",
"position": {"x": 560, "y": 150},
"config": {"label": "Refine Until Confident",
"expression": "int(confidence) < 80",
"max_iterations": 3}
}
// true → re-run the improvement branch; false → move on
{"id": "e1", "source": "refine", "target": "improve", "condition_value": "true"}
{"id": "e2", "source": "refine", "target": "publish", "condition_value": "false"}
When the loop continues, the engine re-arms the body: every node reachable from the true branch back up to the loop is removed from the completed set, so the subgraph runs again with the current state. When the loop exits, the counter is cleared. The counter itself is an internal __ key — invisible to templates and expressions.
Loops need a false branch
max_iterations guarantees termination, but without a falseedge the run simply ends the moment the loop stops continuing. Wire the exit path explicitly, and remember the counter is per-node — nested or sibling loops don't share it.Human gatehuman_gate
Pauses the run for a human decision. When reached, in-flight sibling branches finish, then the run parks: its status becomes paused, a pending_gate of {node_id, prompt}is set, and the gate's step sits in status waiting. Approving records the decision under approval_<node_id> (merging any reviewer input into state) and resumes; rejecting fails the run. The full lifecycle is in the human-in-the-loop guide.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name. |
| prompt | string | "Approval required" | Shown to reviewers in the console approval queue and echoed in the run's pending_gate. |
| stages | object[] | [] | Sequential approval stages, each { approvers, mode, min_approvals, deadline_seconds, when? }. A stage's when expression conditions it in or out per run. See Approval stages below. |
| reminders | object[] | [] | Escalation ladder of { after_seconds, action } where action is remind or escalate. Each rung fires exactly once as its elapsed-time threshold passes. |
| deadline_seconds | int | none | Terminal wall-clock deadline for the whole gate, measured from when it started waiting. |
| on_timeout | string | — | What happens at deadline_seconds: auto_approve, auto_reject, or escalate. |
{
"id": "approve",
"type": "human_gate",
"position": {"x": 560, "y": 150},
"config": {"label": "Review Reply",
"prompt": "Review the drafted support reply before it is sent."}
}
A gate's outgoing edges fire on approval, so whatever follows the gate — a Slack notification, a payout tool — runs only after a human says yes. Gates are ordinary nodes, so they can sit anywhere in the graph, including between parallel branches.
Mergemerge
A fan-in barrier. A merge node waits until every one of its incoming branches has completed, then continues. It takes no configuration beyond label, and its step output is simply {merged: true}. Use it to rejoin parallel work — for example, when a research branch and an outreach branch must both finish before a lead is handed to sales.
| Field | Type | Default | Description |
|---|---|---|---|
| label | string | node id | Display name. The only configurable field. |
{
"id": "join",
"type": "merge",
"position": {"x": 780, "y": 150},
"config": {"label": "Join Branches"}
}
// two branches feed in; the merge fires only once both complete
{"id": "e5", "source": "research", "target": "join"}
{"id": "e6", "source": "outreach", "target": "join"}
Edges and branch selection
Edges carry order, and for branch nodes they also carry a selector. The engine reads each edge's condition_valuefirst and falls back to its label. The rules:
- After an
agent,tool,human_gate, ormergecompletes, all outgoing edges fire — this is how a node fans out into parallel branches - After a
conditionorloop, only edges whose selector matches"true"or"false"(case-insensitive) fire - An edge out of a branch node with no selector is unconditional — it fires on both results, which is rarely what you want
- Edges that reference a missing
sourceortargetnode are validation errors and block the run
Validation: errors and warnings
POST /api/workflows/{id}/validate checks a graph structurally and by expression syntax without running it. Errors block a run; warnings surface likely mistakes but let the run proceed.
| Field | Type | Default | Description |
|---|---|---|---|
| no nodes | error | — | A workflow with an empty node list can't run. |
| duplicate / missing id | error | — | Node ids must exist and be unique within the graph. |
| unknown type | error | — | type must be one of the six known node types. |
| unknown tool_name | error | — | A tool node must reference a built-in or a registered custom: tool. |
| bad expression | error | — | A condition or loop expression that is too long or uses disallowed syntax. |
| max_iterations < 1 | error | — | A loop must allow at least one iteration; non-numeric values also error. |
| missing edge node | error | — | An edge whose source or target id isn't in the graph. |
| no start node | error | — | Every node has an incoming edge, so nothing can begin. |
| unrecognized model | warning | — | An agent model not in the pricing table — runs, but cost tracks as $0. |
| missing true/false branch | warning | — | A condition or loop without one of its branch edges — that path just ends. |
| merge < 2 inputs | warning | — | A merge with fewer than two incoming edges is probably unnecessary. |
A complete graph
This runnable JSON exercises four node types — the shape of the seeded Data Enrichment Pipeline. POST it as the graph of a new workflow, then trigger a run:
{
"nodes": [
{"id": "fetch", "type": "tool", "position": {"x": 0, "y": 150},
"config": {"label": "Fetch Data", "tool_name": "http_request",
"params": {"method": "GET", "url": "https://api.github.com/zen"},
"output_key": "raw_data"}},
{"id": "summarize", "type": "agent", "position": {"x": 280, "y": 150},
"config": {"label": "Summarize", "model": "mock",
"system_prompt": "Summarize in 2 sentences; note anything unusual.",
"user_message": "Data: {raw_data}", "output_key": "summary"}},
{"id": "check", "type": "condition", "position": {"x": 560, "y": 150},
"config": {"label": "Anomaly?", "expression": "'unusual' in str(summary).lower()"}},
{"id": "alert", "type": "tool", "position": {"x": 840, "y": 40},
"config": {"label": "Alert Team", "tool_name": "slack_message",
"params": {"text": "Anomaly: {summary}"}, "output_key": "alert"}},
{"id": "log", "type": "tool", "position": {"x": 840, "y": 260},
"config": {"label": "Log OK", "tool_name": "slack_message",
"params": {"text": "Pipeline OK: {summary}"}, "output_key": "log_result"}}
],
"edges": [
{"id": "e1", "source": "fetch", "target": "summarize"},
{"id": "e2", "source": "summarize", "target": "check"},
{"id": "e3", "source": "check", "target": "alert", "condition_value": "true"},
{"id": "e4", "source": "check", "target": "log", "condition_value": "false"}
]
}
POST /api/templates/{id}/instantiate and edit from there. Node-to-node data flow is explained in Core concepts.