Skip to content

Steps

A workflow is built from 18 step kinds. In the Workflow Builder you add them from the Add node palette, which groups them by what they do. In code each step is an object in defineWorkflow’s nodes list with an id, a kind, and params.

Every step also takes:

Field What it is
id Unique within the workflow. Edges and run records name it.
label The name shown on the canvas and in run views.
position { x, y } on the canvas. The runner ignores it.

Parameters marked as mappings accept "={{ $json.<path> }}" to read the carried item. See the carried item.

Palette group Builder step Kind
Triggers Event, Schedule, Webhook, one entry per connector trigger
Flow Branch, Filter branch
Join join
Split, Aggregate split, aggregate
Human approval human
Logic Edit fields transform
Code code
Agents Agent agent
Models Model model
Actions one entry per connector action
Memory Memory (Remember, Recall, Relate) remember, recall, relate
Cubbies Cubby (Query, Exec) cubbyQuery, cubbyExec
Outputs Event publish
Result output

The Add node palette with its groups open

Triggers

Trigger

Kind: trigger. Where a run starts. A workflow needs at least one. The trigger’s output is the event’s payload, which becomes the first carried item.

Builder entry params.mode Starts a run when
Event none a workflow.start event targeted at the workflow arrives: the Run button, an app, another agent
Schedule "schedule" the vault’s clock reaches a cron boundary
Webhook "webhook" another system calls the workflow’s URL with a key
a connector (Slack, Telegram…) "connector" a message arrives through a vault connection
{ id: "ticket", kind: "trigger", label: "New ticket" }

Parameters, limits, and payloads for each mode are on Triggers.

Flow

Branch

Kind: branch. Takes exactly one way out: the first outgoing edge whose condition passes, in edge order. An edge without a condition always passes, so put it last as the fallthrough.

In the Builder, a Branch has two outputs. The first carries the run when the condition holds; the second is the fallthrough. In code, conditions sit on the edges:

edges: [
{ from: "route", to: "escalate", when: { field: "priority", op: "eq", value: "urgent" } },
{ from: "route", to: "queue" },
]

A condition (when) tests one field of the item the branch received:

op Builder label Passes when the field
eq is equals value
ne is not does not equal value
gt / gte is greater than / is at least is numerically greater / greater or equal
lt / lte is less than / is at most is numerically less / less or equal
contains contains is a string containing value
exists has any value is present and not empty

field is a dot path (classification.category). A missing field fails every test except ne. A condition tests one field; to combine two, chain a second branch.

If no edge passes, the run ends stalled.

Any step, not only a branch, can have conditions on its edges. A non-branch step takes every edge whose condition passes, so two unconditional edges out of one step run both paths at once.

Filter

A Builder step that carries on only when its condition holds. It compiles to a branch with one conditional edge, so a run the filter stops ends stalled.

Join

Kind: join. Waits until every inbound edge has delivered, then continues once with the branches merged. Use it after a fan-out, for example three agents reviewing the same ticket.

The merged item holds every branch’s fields, applied in inbound edge order (later edges win on a clash), plus each branch’s whole item under its step id.

Param Builder label Values
require When a branch fails "all" (default): a failed branch fails the run. "any": the join continues with the branches that answered, and a failed branch arrives as { failed: true, nodeId, error }.
{ id: "panel", kind: "join", params: { require: "all" } }

A step with two inbound edges and no join runs once per arrival. Use a join whenever branches must arrive together.

Split and Aggregate

Kinds: split, aggregate. Run the steps between them once per entry of a list, then collect the results. See Split and aggregate.

Human approval

Kind: human. Parks the run until people answer. Supports assignees, closing policies (any, all, { atLeast }), answer options, forms, and shared document editing. See People in workflows.

{ id: "approve", kind: "human", question: "=Refund {{ $json.amount }}?" }

Logic

Logic steps change the item deterministically: the same input always gives the same output, with no model call, no network, and no clock. fetch, Date, performance, require, process, and globalThis are shadowed, so using one fails the step. item, JSON, and Math are in scope.

Edit fields

Kind: transform. One JavaScript expression over item; its value becomes the new item. A non-object result is wrapped as { value: … }.

Builder mode What it does
Fields Set fields one by one from mappings, and choose under Other fields whether to keep the rest of the item or drop it.
Expression Write the expression yourself (expr).
Review Judge the step before and send it back with an objection. See Review a step.
{
id: "normalise",
kind: "transform",
params: { expr: "({ ...item, customer: item.customer.trim().toLowerCase(), words: item.text.split(/\\s+/).length })" },
}

Code

Kind: code. A function body with statements, locals, loops, and an explicit return of the new item. Same scope and limits as Edit fields; use it when one expression is not enough.

{
id: "dedupe",
kind: "code",
params: {
code: `
const seen = new Set();
const tickets = item.tickets.filter((t) => !seen.has(t.id) && seen.add(t.id));
console.log("kept", tickets.length);
return { ...item, tickets };
`,
},
}
  • The body runs synchronously. Returning a Promise fails the step; use a model or agent step for anything that waits.
  • Returning nothing fails the step.
  • console.log lines (up to 100) are recorded with the step’s run record.
  • A runaway loop holds the step until the Job’s idle timeout.

Agents

Agent

Kind: agent. Asks an agent of your Agent Service to do something, parks the run, and continues when the agent answers. The agent brings its own instructions, model, tools, and connections, can take several turns, and can ask a question back.

Param Builder label What it does
use Agent The agent’s alias in your Agent Service, or a full agent id. Required. In the Builder, Choose an agent… picks one, Change picks another, and Open opens an LLM agent’s definition.
prompt What to ask it The request, usually a mapping. Empty: the agent receives the item and works from its own instructions.
answer Answer it should give JSON example of the answer’s shape, such as {"category": "billing"}. The step fails if the answer lacks any of its keys.
Ask the agent for this shape Builder only, on by default: appends the shape to the request.
emit Event type sent to the agent. Default workflow.step.
{
id: "classify",
kind: "agent",
use: "ticket-classifier",
params: {
prompt: "=Classify this ticket:\n{{ $json.text }}",
answer: JSON.stringify({ category: "billing", priority: "normal" }),
},
}

The agent receives the carried item with the resolved params (including prompt) on top, plus runId and nodeId.

Which agents a step can call. Any agent of your Agent Service:

  • an LLM agent: one created in ROC with New agent, or an A2A agent you bring yourself;
  • a code agent: the handler for the step’s event type (workflow.step by default) returns the answer.

Every agent a workflow calls must be connected to the vault too. Deploying and running from ROC connects them with the workflow.

Reading the answer. The answer becomes fields:

  • An answer that is an object is used as is.
  • A text answer is kept as text and output, and the JSON object in it (fenced in ```json, or embedded in prose) is parsed and its fields added.

The fields are merged over the carried item; on a clash the answer wins. With answer declared, an answer missing any declared key fails the step, naming the missing keys and what the agent produced. That keeps a wrong answer from silently taking the wrong branch three steps later. Declare answer for every field a later step or branch depends on; see Its answer.

When the agent asks a question. An agent that ends its turn needing input (A2A input-required) does not move the run on. The run parks with the agent’s question; someone answers it from the run in ROC, and the answer goes back to the agent in the same A2A context, so it keeps what it had worked out. An agent needing authorization (auth-required) parks the same way until someone grants it and runs the step again. A step gives up after 12 exchanges without a final answer.

When the agent fails. A failed agent fails the run at that step, unless the step feeds a join set to carry on with the branches that answered. See Join.

Models

Model

Kind: model. Calls one model once and puts the answer on the item under into; the rest of the item is kept. No back-and-forth, no tools, no memory of its own. What models are, the catalogue, and the input fields are on Models.

Use a model step when Use an agent step when
The task is one call: transcribe, embed, classify, extract, summarise. The task needs judgement across several turns, tools, or connections.
You want each call visible and re-runnable as its own step. You want to reuse an agent other workflows and apps also use.
You can write the whole request from the item. The agent’s instructions are long or change independently of the workflow.
Param Builder label Default What it does
alias Model required The model alias. The workflow must declare it in models; cef build refuses a model step whose alias is not there. See Declare an alias.
input Request {} The request object, in the model’s own input schema. Values can be mappings, at any depth.
into Answer field output Where the answer lands.
each Call once per item false Call once per entry of a list.
over List field the item itself The list field to iterate when each is on.
{
id: "classify",
kind: "model",
label: "Classify the ticket",
params: {
alias: "llm",
input: {
messages: [
{ role: "system", content: "You triage support tickets." },
{ role: "user", content: "=Classify as billing, bug or question:\n\n{{ $json.text }}" },
],
max_tokens: 64,
},
into: "classification",
},
}

A language model answers with { text }, so the next step reads $json.classification.text. Set max_tokens: long answers are cut off at the model’s default budget.

input can also be JSON text of an object (what the Builder’s Request editor saves), or a single mapping such as "={{ $json.request }}" that resolves to an object an earlier step built. Anything else fails the step before the model is called.

Structured output. Ask a language model for JSON constrained to a schema with response_format:

input: {
messages: [{ role: "user", content: "=Classify this ticket:\n{{ $json.text }}" }],
max_tokens: 128,
response_format: {
type: "json_schema",
schema: {
type: "object",
properties: {
category: { type: "string", enum: ["billing", "bug", "question"] },
priority: { type: "string", enum: ["low", "normal", "urgent"] },
},
required: ["category", "priority"],
},
},
},
into: "classification",

The answer is still text. Parse it in the next step so later steps and branches can read fields:

{ id: "parse", kind: "transform", params: { expr: "({ ...item, ...JSON.parse(item.classification.text) })" } }

response_format: { "type": "json_object" } asks for any valid JSON without a schema. See Structured output.

Once per list entry. With each: true the step calls the model once per entry of over, one after another, and collects the answers as a list under into (default results). Each answer carries the itemIndex of the entry that produced it. Each entry’s fields are spread over the item for that call, so ={{ $json.url }} reads the current entry’s url; an entry that is not an object is $json.item.

{
id: "transcribe",
kind: "model",
params: { alias: "asr", each: true, over: "chunks", input: { audio: "={{ $json.url }}" }, into: "transcripts" },
}

If over is missing or not a list, the step fails naming the field. For several steps per entry, or waits per entry, use Split and aggregate.

Failures. A model call that fails in transport (a 5xx, a timeout, a dropped connection) is retried up to 3 attempts in total, after 0.4 s and 1.6 s. A 4xx (a bad request, an undeclared alias) fails the step at once.

A Model step: model, request, and answer settings

Actions

Connector action

Kind: action. Does one thing in an external system through a vault connection: post to Slack, send an email, message a Telegram chat. The vault holds the connection’s credentials and makes the call; the workflow only names the connection, the action, and the input. Adding the step in the Builder, and the access Deploy grants, are on Connectors.

Param What it does
connection The connection’s id. Required.
action The action name, such as send_message. Required.
input An object with one key per input field of the action. Values can be mappings; a value that resolves to nothing is left out.
{
id: "notify",
kind: "action",
label: "Tell the support channel",
params: {
connection: "<connectionId>",
action: "send_message",
input: {
channel: "C0123456",
text: "=New {{ $json.category }} ticket {{ $json.ticketId }}: {{ $json.summary }}",
},
},
}
Connector Actions Events (for connector triggers)
Slack send_message, send_dm, update_message, add_reaction message.received
Telegram send_message message.received
Email (SMTP) send_email none
MCP server discovered from the connected server none

The exact input fields of each action are shown in the step’s panel. The vault’s connector catalogue is public: GET /api/v1/connectors.

The step parks the run until the vault answers, like an agent step. On success, the action’s output fields are merged over the carried item. On failure, the run fails at the step with the vault’s error code and message:

Error code Meaning
connection_not_found No such connection in this vault.
connection_revoked The connection was revoked.
scope_not_allowed The connection is not usable in the workflow’s scope.
no_access The workflow has no grant for this action, or is no longer connected.
action_unknown The connector has no such action.
invalid_input The input does not match the action’s fields.
provider_error The external system refused or failed.
timeout The external system did not answer in time.

A person answering the run can send it back to a connector action step to try again, for example after the vault owner reconnects an account. See Send a run back.

Memory

Memory steps read and write the vault’s Memory Bank: typed, durable records that later runs and other workflows can read, subject to the connection’s grants. In the Builder they are one Memory step with an Operation of Remember, Recall, or Relate.

Writes are checked by reading back through the same grants. If the vault’s connection does not allow the scope or privacy level, the step fails and says so instead of silently writing nothing.

Remember

Kind: remember. Writes one record. Idempotent on id: writing the same id again updates the record instead of adding a second.

Param Builder label Default What it does
scope Scope required The grant scope the record lives in.
privacy Privacy public, internal, private, or restricted.
type Record type record The record’s type.
id One record per <runId>:<stepId> The record id; a mapping makes one record per value.
title Title the id A mapping or text.
body the whole item as JSON A mapping or text.
{
id: "file",
kind: "remember",
params: { scope: "default", privacy: "internal", type: "ticket-triage", id: "={{ $json.ticketId }}", title: "={{ $json.summary }}" },
}

Recall

Kind: recall. Searches the Memory Bank and adds the matching records to the item.

Param Builder label Default What it does
match Search for required Search text; usually a mapping.
limit Most rows 50 Most records returned.
into Field name recalled Where the records land; <into>Count holds their number.
bodies false Also read each record’s body.
maxBodyChars 2000 Longest body kept per record when bodies is on.

Relate

Kind: relate. Draws a typed, directed edge between two records.

Param Builder label Default What it does
from From record required Source record id; usually a mapping.
to To record required Target record id.
type Edge type related The relation’s type.
scope, privacy Scope, Privacy As for Remember.

Cubbies

Cubby query and Cubby exec

Kinds: cubbyQuery, cubbyExec. Read or write a SQL cubby your Agent Service declares. In the Builder both are one Cubby step; its Operation is Query — read rows back or Exec — write, or change the schema.

Kind Does Adds to the carried item
cubbyQuery Runs a SELECT and returns rows. <into> (the rows, a list) and <into>Count. into defaults to rows.
cubbyExec Runs an INSERT, UPDATE, DELETE, or other statement. cubbyChanged: the number of rows changed.
Param Builder label What it is
alias Cubby The cubby to use. Required.
sql SQL One SQL statement, with ? for every value. Required.
args Values One value per ?, in order (one per line in the Builder). Usually mappings such as "={{ $json.ticketId }}".
into Rows land on cubbyQuery only: the field the rows land on.
{
id: "save",
kind: "cubbyExec",
label: "Record the triage result",
params: {
alias: "triage",
sql: "INSERT INTO triage_tickets (run_id, ticket_id, category, ts) VALUES ('{{ $runId }}', ?, ?, ?)",
args: ["={{ $json.ticketId }}", "={{ $json.category }}", "={{ $json.receivedAt }}"],
},
},
{
id: "history",
kind: "cubbyQuery",
label: "Earlier tickets from this customer",
params: {
alias: "triage",
sql: "SELECT ticket_id, category FROM triage_tickets WHERE customer = ? ORDER BY ts DESC LIMIT 20",
args: ["={{ $json.customer }}"],
into: "previous",
},
},

After history, $json.previous holds the rows and $json.previousCount their number.

Values go in args, never in the SQL. A {{ $json.… }} inside sql fails the step: the run’s data comes from outside (a webhook body, a Slack message) and splicing it into SQL is an injection. Put ? in the statement and the value in args, where the platform binds it. An args value that resolves to an object is sent as its JSON text; a list of plain values is sent joined with "; ".

{{ $runId }} is the one template allowed inside sql: the run’s own id. Write it inside quotes, as in the example. Store it on every row a run writes, so a widget pinned to the workflow can select that run’s rows with WHERE run_id = ?.

Declaring a cubby and inspecting its data are on Cubbies; migration rules are on Cubby schema and migrations. A run’s view shows the cubby calls each step made; see Monitor runs.

Outputs

Event (publish)

Kind: publish. Publishes an event into the run’s scope, so another workflow, agent, or app subscribed to that type receives the result. This is how workflows compose: one publishes, the other is triggered on its own terms.

Param Builder label Default What it does
event Event type workflow.result The event type. Changing it breaks subscribers.
payload What the event carries the whole item A JSON example object as text. Only its keys are published, taken from the item.
{
id: "announce",
kind: "publish",
params: { event: "ticket.triaged", payload: JSON.stringify({ ticketId: "", category: "", priority: "" }) },
}

The published payload also carries runId. Declare payload so a publish never broadcasts the whole item.

Result

Kind: output. Ends a branch of the run and declares what the run returns: the webhook response, and what evaluations score.

Param Builder label What it does
result Where each field comes from { field: mapping }. The run’s output becomes exactly these fields.
resultTypes { field: "string" | "number" | "boolean" | "object" | "array" }. A resolved value of another type fails the run at this step.
outcome Outcome An optional label for how the run ended, such as "escalated"; can be a mapping.
{
id: "result",
kind: "output",
params: {
result: { category: "={{ $json.category }}", priority: "={{ $json.priority }}" },
resultTypes: { category: "string", priority: "string" },
outcome: "triaged",
},
}

Without result, the run’s output is the carried item as it reached the step. A step with no outgoing edges also ends the run with the item it produced.

Review a step

An Agent or Edit fields step can judge an earlier Agent step and send it back to try again. Set:

Param Builder label Default What it does
reviews Reviews the step / Judges which step The step to judge, or "." for the step drawn into this one.
feedback Objection field feedback The field of this step’s output holding the objection. Empty means accepted.
maxAttempts Attempts 3 Total tries for the judged step, the first included.
accept Optional condition ({ field, op, value }) that accepts the answer when it passes.

When the reviewer objects, the judged step is asked again with the objection as answer on its input. When attempts run out, the run does not fail and does not ship the rejected answer: it waits for a person, with the last objection as the question.

{
id: "check",
kind: "transform",
params: {
expr: "({ ...item, feedback: ['billing', 'bug', 'question'].includes(item.category) ? '' : 'category must be billing, bug or question' })",
reviews: "classify",
maxAttempts: 2,
},
}

Validation

The Builder’s Deploy, cef build, and the runner check a graph before it can run and refuse it with every problem listed: unknown kinds, duplicate ids, edges to unknown steps, a missing trigger, an agent step with no agent, a model step with no alias, a connector step with no connection or action, unmarked templates, cycles without a loop edge, invalid human-step params, and invalid regions. A step nothing leads to is reported but does not block.

Limits

What Limit
Input the run carries into an agent step 1 MiB (1,048,576 bytes). A larger item fails the step.
Exchanges in one agent step (agent asks, person answers) 12. The step then fails: “gave up after 12 exchanges without a final answer”.
Model call attempts 3, transport failures only, 400 ms then 1600 ms apart
Connector action: time per attempt 20 s. The attempt fails and is retried. Keep actions small.
Connector action: attempts 5, with backoff. The step then fails with connector.action.failed; handle the failure path in the graph.
SQL in a cubby step No {{ $json.… }} templates; only {{ $runId }}. Otherwise the step fails: “SQL may not interpolate the run’s data”.