Step types
A workflow agent is made of steps, such as “ask a model”, “use a tool” or “go one way or the other”. This page lists every kind of step, what each one needs to be filled in, and what it passes on to the next step. It is a reference: to learn how to draw a workflow, start with the visual builder.
In the builder, steps are dragged from the Steps palette and connected by lines (connections). Here a step is also called a node and a connection an edge, because that is what they are called in the saved agent. There are 14 kinds, each doing one job.
All fourteen are in the palette. Verify and Extract context are also produced by the strategy compiler; see Roles and strategies.

Quick reference
Every key below is read by the executor. A node’s config may carry anything else, and nothing will read it.
| Node Type | Config keys read | Output available as |
|---|---|---|
| Input | schema | {{input.*}} |
| Output | mapping, source | (terminal node) |
| Model call | userPromptTemplate (or userPrompt, required), systemPrompt, providerId, routing, roleKey, model, temperature, maxTokens, toolIds | {{nodes.<id>.output}} |
| Tool call | toolId (required), parameterMapping | {{nodes.<id>.output}} |
| Condition | expression (required) | (routes to true/false branches) |
| Transform | expression (required) | {{nodes.<id>.output}}, a string |
| Loop | iterableExpression (required), maxIterations | {{nodes.<id>.output}}, an array |
| Parallel | none | {{nodes.<id>.output}} |
| Merge | strategy, judgePrompt, consensusThreshold, judgeConfig, roleKey, model, routing | {{nodes.<id>.output}} |
| Sub-agent | agentId or target (required), inputMapping | {{nodes.<id>.output}} |
| Verify | checkers (required), target, spec, policy | {{nodes.<id>.output}}, a verdict object |
| Extract context | task, sources, instruction, roleKey, providerId, model, routing, temperature, maxTokens | {{nodes.<id>.output}}, a brief object |
| Decision | question (required), thresholds, state, roleKey, providerId, model, routing, temperature, maxTokens | (routes to the chosen answer’s edge) |
| Code | code (required), timeoutMs | {{nodes.<id>.output}}, what the script returns |
A Model call node needs a model from somewhere: a providerId, a routing
policy, a roleKey the agent defines, or the organization’s default routing
policy. With none of the four the node fails at run time.
Saving a pipeline enforces more than this table, such as a Condition node’s two outgoing edges, a Merge node’s two incoming ones. See pipeline validation.
Input
The entry point of every pipeline. Defines the JSON Schema that incoming data must conform to.
In the builder
- Click the Input node on the canvas
- In the config panel, define properties and their types
- Mark required fields
- The schema is validated against invocation payloads at runtime
Configuration
| Property | Type | Description |
|---|---|---|
schema | JSON Schema | Shape of accepted input |
{
"id": "input_1",
"type": "input",
"data": {
"schema": {
"type": "object",
"properties": {
"message": { "type": "string" },
"context": { "type": "string" }
},
"required": ["message"]
}
}
}Every pipeline must have exactly one Input node. Access its data in template
expressions via {{input.message}} or {{input.context}}.
Output
The exit point of the pipeline. Names the final result.
In the builder
- Click the Output node on the canvas
- In the config panel, pick an upstream node, or write the template yourself
Configuration
| Property | Type | Description |
|---|---|---|
mapping | Template string or object | Rendered against the context. A string result; an object is rendered value by value. |
source | Dot path, or a list of them | Read instead of mapping when mapping is absent. Resolved as a raw value, so objects and arrays keep their type. A list takes the first path a step that ran produced. |
{
"id": "output_1",
"type": "output",
"data": {
"mapping": "{{nodes.llm_1.output}}"
}
}mapping is what the builder writes, and it renders to text: a node whose
output is an object or an array arrives at the pipeline result as JSON text.
source instead takes a bare dot path with no {{...}} around it, for example
nodes.llm_1.output, and preserves the value’s type. It is reachable through
the API; the builder does not write it yet.
source may also be a list of dot paths, read in order: the first one a step that ran produced is the answer. A skipped or failed step has no output, so the next path is tried; when none produced anything the node fails, naming the paths. That is how a graph with a branch names its answer, and what a compiled strategy writes: a cascade reads ["nodes.escalate.output", "nodes.draft.output"], the escalation (which runs only when the check failed) and then the draft the check passed.
With neither set, the node returns every node’s output keyed by node id.
A pipeline needs at least one Output node. Two of them are allowed only when they cannot both run in the same execution (on opposite branches of a Condition), because otherwise which one became the run’s answer would come down to the order the nodes are stored in.
Model call
Sends a prompt to a configured LLM provider and returns the response.
In the builder
- Drag a Model call node onto the canvas and connect it
- In the config panel, select a Provider and Model from the dropdowns
- Write a System Prompt and User Prompt Template (supports
{{...}}expressions) - Optionally attach tools for function calling and adjust temperature/token limits
Configuration
| Property | Type | Default | Description |
|---|---|---|---|
providerId | string | — | ID of the LLM provider to use |
model | string | Provider default | Specific model (e.g., gpt-4o, claude-sonnet-5) |
systemPrompt | string | — | System prompt for the LLM |
userPromptTemplate | string | — | User prompt with template expressions |
temperature | number | 0.7 | Sampling temperature (0-2) |
maxTokens | number | 1024 | Maximum tokens in the response |
toolIds | string[] | [] | Tool IDs available for function calling |
{
"id": "llm_1",
"type": "llm_call",
"data": {
"providerId": "provider-uuid",
"model": "gpt-4o",
"systemPrompt": "You are a helpful assistant.",
"userPromptTemplate": "Answer this: {{input.message}}",
"temperature": 0.5,
"maxTokens": 2048,
"toolIds": ["tool-1", "tool-2"]
}
}When toolIds are provided, the LLM can invoke those tools during execution.
Tool call results are fed back to the LLM automatically.
Tool call
Directly invokes a tool with explicit parameter mapping, without involving an LLM.
In the builder
- Drag a Tool call node onto the canvas
- In the config panel, select a tool from the dropdown
- Map each parameter using template expressions or static values
Configuration
| Property | Type | Description |
|---|---|---|
toolId | string | ID of the tool to execute |
parameterMapping | array | Maps template expressions to tool parameters |
{
"id": "tool_1",
"type": "tool_call",
"data": {
"toolId": "tool-uuid",
"parameterMapping": [
{ "key": "query", "value": "{{input.message}}" },
{ "key": "limit", "value": "10" }
]
}
}The result is available downstream as {{nodes.tool_1.output}}.
When the call needs approval
If an approval rule holds the call (a refund over its amount, say), the run stops at the step and waits. The agent’s page says Waiting for your approval: Issue refund (amount is 820), with a link to Approvals. Approve it and the call is made once, exactly as the step asked, and the run carries on from there with what it returned. Reject it, or let it expire, and the call is not made: the step ends Rejected and the run stops there.
For developers: the run is waiting_approval with the plain line in error, and the node is recorded as status: "waiting_approval" with waitingForApproval: { approvalId, kind: "tool_call" }. The approval request is the gate’s held call (no run on it). Once it is approved, ToolApprovalGateService.runHeld makes the call, keeps its outcome on the request and emits approval.held_call_settled; WorkflowApprovalResumeService then carries on every run waiting on that request with that outcome (the gate files the same call held twice while pending as one request, so two such runs share one call). Rejected or expired, the node fails with errorCode: "APPROVAL_REJECTED" and the run ends cancelled.
Condition
Branches the pipeline based on a boolean expression. The node has two outgoing
edges: one for true and one for false.
In the builder
- Drag a Condition node onto the canvas
- In the config panel, write a JavaScript expression that evaluates to true or false
- Connect the true handle to one branch and the false handle to another
Configuration
| Property | Type | Description |
|---|---|---|
expression | string | JavaScript expression evaluating to true/false |
{
"id": "cond_1",
"type": "condition",
"data": {
"expression": "{{nodes.llm_1.output}}.includes('error')"
}
}Transform
Renders a template against the execution context and outputs the rendered text.
It is a template language, not JavaScript
The expression goes through the same resolver every other node’s {{...}}
fields use. That resolver reads input.*, nodes.<id>.output.* and
variables.* by dot path, own properties only, against a keyword blocklist,
and nothing else. There are no function calls, no arithmetic, no array
indexing and no return. {{Date.now()}} does not evaluate: it fails the
resolver’s path check and the node throws.
The output is the rendered string. A template shaped like a JSON object
therefore produces a JSON string rather than an object, and
{{nodes.transform_1.output.summary}} downstream resolves to an empty string.
To carry structure between nodes, leave the value in the node that produced it
and reference that node directly.
In the builder
- Drag a Transform node onto the canvas
- In the config panel, write the template
- Use
{{...}}expressions to pull in upstream data
Configuration
| Property | Type | Description |
|---|---|---|
expression | string | Template rendered against the execution context. Required. |
{
"id": "transform_1",
"type": "transform",
"data": {
"expression": "Summary: {{nodes.llm_1.output}} (source: {{input.url}})"
}
}Loop
Resolves an expression to an array and outputs that array, capped at
maxIterations.
It does not run downstream nodes once per item
The engine executes a pipeline in topological layers, and a Loop node is one
node in one layer: it resolves its iterable, caps it, and hands the whole list
downstream in a single pass. It does not re-run the nodes after it once per
item, and there is no loop context: {{loop.item}} and {{loop.index}} name
nothing in the execution context, so an expression using either resolves to an
empty string.
What the node is for is producing a list at a known point in the graph: feed
{{nodes.loop_1.output}} to a tool that takes an array, to a Model call prompt
that handles the whole list in one call, or to a Parallel node’s branches.
In the builder
- Drag a Loop node onto the canvas
- In the config panel, set the Iterable Expression to an array reference
- Optionally lower Max Iterations
Configuration
| Property | Type | Default | Description |
|---|---|---|---|
iterableExpression | string | — | Template expression resolving to an array. Required. |
maxIterations | number | 100 | How many items are kept. |
{
"id": "loop_1",
"type": "loop",
"data": {
"iterableExpression": "{{nodes.tool_1.output.items}}",
"maxIterations": 50
}
}An expression that is a single {{...}} reference keeps its type, so an array
stays an array. Anything else is rendered to a string, and any non-array value
is wrapped in a one-element array.
An iterable that resolves to nothing (a typo, a field the input did not carry,
a step that failed) fails the node with the iterable '...' resolved to nothing, rather than being treated as a one-item list. An empty array is an
empty list, and the run carries on.
Parallel
Marks a fan-out point. The engine executes a pipeline in topological layers, so every node wired to a Parallel node’s output already runs concurrently, in the same layer. The concurrency is the engine’s; the node itself is a marker.
In the builder
- Drag a Parallel node onto the canvas
- Connect its output handle to each branch you want to run at the same time
- Connect those branches into a Merge node to collect the results
Configuration
None. A Parallel node reads nothing from its config: there is no branches
list and no aggregation setting. Which nodes run together is decided by the
edges you draw, and how their results combine is decided by the Merge node
you draw them into.
{
"id": "parallel_1",
"type": "parallel",
"data": {}
}The node’s own output is a pass-through of its first incoming edge’s output,
falling back to the invocation input when it has none. The branch results are
read from the Merge node, or from each branch directly with
{{nodes.<branch id>.output}}.
Merge
Collects the outputs of every node with an edge into it and applies a merge strategy. Use it after parallel branches or condition branches to unify data before continuing.
In the builder
- Drag a Merge node onto the canvas
- Connect the branches you want to combine as inputs
- In the config panel, select a Strategy for combining the values
What it merges
The incoming edges, and only those. There is no sources list: the node reads
the output of every node whose edge targets it, in edge order.
Configuration
| Property | Type | Default | Description |
|---|---|---|---|
strategy | string | first_response | One of first_response, concatenate, best_of_n, consensus. Any other value behaves as first_response. |
judgePrompt | string | — | best_of_n only: the rubric sent to the judge. |
consensusThreshold | number | 0.5 | consensus only: the share of branches that must agree. |
judgeConfig | object | — | Pins the judging call: { providerId?, model?, routing?, temperature?, maxTokens? }. |
roleKey, model, routing | — | — | Read for the judging call when judgeConfig does not pin them. |
{
"id": "merge_1",
"type": "merge",
"data": {
"strategy": "best_of_n",
"judgePrompt": "Prefer the answer that cites a source."
}
}What each strategy outputs:
| Strategy | Output |
|---|---|
first_response | the first incoming output |
concatenate | an array of every incoming output |
best_of_n | the incoming output a judge model picked |
consensus | { answer, agreement, consensusReached, threshold, responses } |
best_of_n and consensus make a model call. It resolves the way a Model call
node’s does: a roleKey the agent defines, then judgeConfig.providerId,
then a routing policy, then the organization’s default routing policy. And
the node fails if none of them is set. With one incoming branch neither
strategy calls a judge; the single answer is returned as-is.
The merged result is available as {{nodes.merge_1.output}}.
A branch that failed, or was skipped by a Condition, contributes nothing. The
merge reads the branches that produced output, in edge order, so
concatenate lists only those, and the positions of the others close up, and
the merge itself fails only when no branch produced anything, naming each
upstream and whether it failed or was skipped.
Sub-agent
Invokes another agent as a step within this pipeline, enabling composable and modular architectures.
In the builder
- Drag a Sub-agent node onto the canvas
- In the config panel, select the target agent from the dropdown
- Map input fields from upstream node outputs to the sub-agent’s expected input schema
Configuration
| Property | Type | Description |
|---|---|---|
agentId | string | ID of the agent to invoke |
inputMapping | array | Maps expressions to the sub-agent’s input schema |
{
"id": "sub_1",
"type": "sub_agent",
"data": {
"agentId": "agent-uuid",
"inputMapping": [
{ "key": "query", "value": "{{nodes.llm_1.output}}" }
]
}
}The sub-agent’s output is available as {{nodes.sub_1.output}}.
inputMapping also accepts an object ({ "query": "{{nodes.llm_1.output}}" }),
and both shapes resolve the same way. With no mapping at all, the whole
invocation input is passed through to the sub-agent unchanged.
A child run that does not complete fails this node: a child that failed, timed
out or was cancelled is reported on the node as Sub-agent execution <status>: <the child's error>,
and the steps after it are skipped. Nesting is bounded by
the run’s recursion depth (see Run limits), so an agent that
reaches itself through a chain of sub-agents stops with Max nesting depth (N) exceeded rather than recursing.
Verify
Judges a value against a spec using a panel of refute-only checkers. Each
checker runs in its own bounded context, with its own provider and model, all in
parallel; their verdicts are then merged under a policy. Pointing checkers at
provider entities from different vendors is how you get a multi-vendor check,
and it is the reason a checker names a providerId of its own rather than
inheriting the pipeline’s.
A checker’s only job is to refute. It sees the target and the spec and answers with structured JSON.
Where it comes from
Drag it from the palette, or let the strategy compiler place it: the cascade
and best_of_n strategies both compile a verify step bound to the verifier
role slot. Ejecting a strategy to an editable graph (Execution tab, Eject to
an editable graph) leaves the compiled verify node in place.
It never throws on a failing verdict
A failing check is data, not an error: the node emits the failure list and
completes, so a downstream Condition node can branch on it and decide to
retry, escalate or halt. A checker that itself errors or returns unparseable
JSON becomes verdict error, and the policy decides whether that blocks.
Configuration
| Property | Type | Description |
|---|---|---|
checkers | array | At least one. Each is { name?, providerId, roleKey?, model?, instructions?, temperature?, maxTokens? }, with a providerId or a roleKey required. An empty list fails the node. |
target | any / string | The value to check. A string is template-resolved. Defaults to the incoming node output. |
spec | any / string | The rules to check against. A string is template-resolved; an object is serialized. |
policy | string | any_fail_blocks (default), all_pass or majority. |
{
"id": "check",
"type": "verify",
"data": {
"target": "{{nodes.draft.output}}",
"spec": "Must cite a source for every figure. Must not exceed 200 words.",
"policy": "any_fail_blocks",
"checkers": [
{ "name": "cites", "providerId": "provider-uuid-anthropic" },
{ "name": "length", "providerId": "provider-uuid-openai" }
]
}
}Output
{{nodes.check.output}} is a verdict object rather than a bare value:
| Field | Description |
|---|---|
verdict | pass or fail |
passed | the same thing as a boolean, for condition expressions |
policy | the policy the merge actually used |
failures | one entry per refutation: { rule, evidence, checker } |
passed_rules | the rules no checker could refute |
checkers | per-checker { checker, verdict, error? } |
Cost and tokens are summed across the panel and accounted to the node like any other call, so a check’s price shows up in the run.
Extract context
Compresses what the steps wired into it learned into a small structured brief, which later steps read instead of every transcript. It makes one model call, found the same way as a Model call node’s (role, provider, routing policy, or the organization default), and its cost shows on the run like any other step. The explore_extract_patch strategy compiles one between the explorers and the principal role.
In the side panel: What the brief is for (empty means the run’s input), What to boil down (empty means the steps wired into it) and Instruction (empty means the built-in one).
| Property | Type | Description |
|---|---|---|
task | string | What the brief is for. Template-resolved. Defaults to the run input. |
sources | string | What to compress. Template-resolved. Defaults to the incoming node outputs. |
instruction | string | Replaces the built-in extraction instruction. |
roleKey, providerId, model, routing, temperature, maxTokens | Which model answers, as for Model call. |
A brief that does not parse fails the node rather than passing the raw transcripts on. With nothing to compress (no sources and no upstream output), the node fails before calling a model.
Decision
Asks a model one question with a fixed set of answers and follows the edge of the answer it picks. The model gives every answer a weight; the weights are normalised into a distribution over the declared answers only, and the node leaves by the edge whose handle (or label) is the chosen answer.
In the side panel: the Question, the Answers (each with an optional meaning, and exactly one marked as cannot tell), the Kind of question (Pick one answer or Pick one level, in order), How sure it must be per answer, and Answers come back under (the question id).
| Property | Type | Description |
|---|---|---|
question | object | Required. The prompt, the kind (choice or score), and the options, one of them the abstain option. A yes/no question is asked as a choice with yes, no and abstain. |
thresholds | object | Optional minimum probability per answer. A winning answer below its bar leaves by the abstain edge instead. |
state | string | What to decide over. Template-resolved. Defaults to the upstream outputs, else the run input. |
roleKey, providerId, model, routing, temperature, maxTokens | Which model answers, as for Model call. |
The output carries selectedOption, argmax, abstained, probability, threshold and the whole distribution. The numbers order the answers; they are not calibrated probabilities.
Code
Runs a short script that calls the agent’s tools, for a job that needs several calls, a loop or filtering: “find every sold pet and archive each one” is one Code step instead of a loop of tool steps. The script runs in a locked box with no network, no files and no passwords; every call it makes is checked like a Tool call step, with the same permissions, approval rules and audit log. What the script returns is the step’s result.
In the side panel: the Script. It calls the agent’s tools as functions, one object per API (petstore.findPetsByStatus({ status: 'sold' })), reads the run’s input as context.input and earlier steps as context.steps["<step id>"], and ends with return. log(...) adds a line to the step’s log.

A change the agent’s settings send to a person (deletions, by default) does not run inside the script. The run stops at the step and waits: the agent’s page says Waiting for your approval: 3 changes, with a link to Approvals, where the changes wait as one list. Approve them and they are made, in order, and the run carries on from that step with what the script returned. Reject them and none is made: the step ends Rejected and the run stops there.
| Property | Type | Description |
|---|---|---|
code | string | Required. The script: the body of an async function, JavaScript or TypeScript. |
timeoutMs | number | How long it may run, within the server’s limits (30 seconds unless set, at most 2 minutes by default). |
The scope is the agent’s tools (toolIds and the active tools of its APIs). The calls count against the run’s tool-call budget. The write policy is the agent’s agentConfig.codeMode (scripts in autonomous agents). The trace is a code_executions row on the run.
A held change set puts the run in waiting_approval, with error set to the plain line (“Waiting for your approval: 3 changes.”), the node recorded as status: "waiting_approval" with waitingForApproval: { approvalId, changes } and its approvalId on the node’s input, and what the run needs to carry on in metadata.waitingForApproval. The approval request is marked with the run (payload.workflowExecutionId). When it is decided, WorkflowApprovalResumeService settles the change set and, once nothing the run waits on is pending, moves the run back to running and carries it on: the steps that ran before are replayed from the run’s record, not run again. Approved, the node’s output is what the script returned (a change that fails fails the node, naming it); rejected or expired, the node fails with errorCode: "APPROVAL_REJECTED" and the run ends cancelled. The run streams execution.waiting when it stops.