Workflow steps
This content is not available in your language yet.
A workflow can contain steps as well as agents. A step is one fixed piece of work, such as classifying a message, searching a collection or writing a reply, that runs the same way every time. Steps let you script the parts of a conversation that should not be left to an agent’s judgement, and keep agents for the parts that should.
This page is the reference. For what a workflow is and how agents hand over to each other, see Workflows. A workflow with no steps works exactly as described there.
A worked example
Section titled “A worked example”This workflow answers policy questions from a collection and sends
everything else to a triage agent. A router step reads the message and
chooses a branch. On the policy branch a retrieve step searches the
handbook collection and an llm step writes the cited answer. On the
other branch the conversation goes to triage, which can transfer to
specialist.
description: Answer policy questions from the handbook; route the rest to triageagents: - triage - specialiststeps: classify: kind: router title: Classify the question branches: - name: policy description: The user asks what the company policy says - name: other description: Anything else search: kind: retrieve collections: [handbook] query: "{{ input.message }}" top_n: 5 retry: {max: 1, backoff_ms: 500} answer: kind: llm title: Policy answer reply: true system: Answer only from the context. Cite sources as [ref]. prompt: | Question: {{ input.message }}
Context: {{ search.context }}edges: - {from: start, to: classify} - {from: classify, to: search, branch: policy} - {from: classify, to: triage, branch: other} - {from: search, to: answer} - {from: triage, to: specialist, kind: transfer, when: The user needs a specialist}Agents are written by name here, as the console shows them. start is
the box every run begins at; an edge from it replaces entry. The
console’s Scripted RAG chat template creates a workflow of this shape
for you: you choose one collection you can read, and it creates the
starter agents, named after the workflow by default
(<workflow>-triage and <workflow>-specialist; you can rename them in
the dialog), along with the steps and edges.
File format
Section titled “File format”On top of the keys in Workflows, a workflow with steps may use:
steps— the steps, by name. At most 100.vars— variables that live for the whole conversation, see Variables. At most 50.guardrails— the same settings an agent has, applied to the whole workflow, see Guardrails.route_every_turn—truemakes every message start at the step the Start box leads to, even while an agent holds the conversation. See Routing every message.limits.max_steps— a ceiling on step and agent runs in one run.
Step and variable names start with a lowercase letter and then use
lowercase letters, digits and underscores, up to 64 characters in all.
start and end are reserved. Steps and agents share one namespace, so
a step may not have the same name as an agent. A step may carry a title
of up to 80 characters.
An entry in agents is either a name, or {agent: name, input: "..."}
for a pipeline agent that is given a task.
Step kinds
Section titled “Step kinds”Every step has a kind. Three settings are common to all of them:
title— a label for people. Replies written by a step carry the step’s name in the chat; a title is shown as the label when the step has one. Without a title the label is the step’s name; the chat shows the title the step had when that run happened.retry—{max, backoff_ms}, see Errors and retries.on_error—fail,continueorbranch, see the same section.
The step kinds below are the ones you can use today, with eight more for unattended runs and the approval and ask steps.
Calls a chat model with a prompt.
| field | |
|---|---|
prompt |
Required. The message sent to the model. A template. |
system |
System instructions. A template. |
model |
A chat model from the model registry, by alias. Left out, the workspace’s utility model is used. |
output_fields |
A list of fields the model must return, see below. |
reply |
true shows the result in the chat. Default false. |
history |
How many earlier conversation messages to send before the current one, 0 to 20. Default 0. Agent messages are sent as text only. |
With reply: true the text streams into the chat as the model writes it,
under the step’s name. Without it the result is kept for later steps and
the user does not see it.
output_fields asks the model for structured output. Each field has a
name, a type (string, number, integer, boolean or array)
and optionally description, required, options (the allowed values,
on a string only) and items (the element type, on an array only). There
are at most 30 fields. A reply that does not fit the schema is a step
error. You cannot combine output_fields with reply: true.
Output: text, or one entry per declared field.
condition
Section titled “condition”Chooses a branch by testing values, with no model call.
branches is an ordered list of {name, when}, 1 to 20 of them, with
unique names. The first whose rule is true wins; if none is, the run
takes the else branch. See Conditions for the rule
syntax.
Output: branch, the name of the branch taken.
router
Section titled “router”Asks a model to choose a branch. It is the step to use when the answer depends on what the user meant, not on a value you can test.
| field | |
|---|---|
branches |
Required. 2 to 10 of {name, description}. The descriptions are what the model decides by. |
input |
What to classify. A template; default {{ input.message }}. |
model |
A chat model or a decision model, by alias. Left out, the workspace’s decision model answers when one is set, otherwise the workspace’s utility model. |
min_confidence |
0 to 1. When a decision model answers less sure than this, a chat model decides instead. Default 0.3. |
A decision model answers in a fraction of a second and gives a confidence with its choice. It writes no reason. The model’s choice is never shown in the chat.
Output: branch, reason (empty when a decision model answered),
confidence and probabilities (empty when a chat model answered),
engine (decision or llm) and fallback_reason (why a chat model
decided instead of the decision model, or empty).
classify
Section titled “classify”Asks a model questions about the input: pick one of a set, yes or no, or a score on a scale. Each answer comes with how sure the model was, so a later condition can branch on the answer or on its confidence. Classify has no branches of its own and never writes to the chat.
| field | |
|---|---|
questions |
Required. 1 to 10 questions, see below. |
input |
What to classify. A template; default {{ input.message }}. |
model |
A chat model or a decision model, by alias, as for router. |
min_confidence |
0 to 1, as for router. Only the questions answered less sure than this go to a chat model. Default 0.3. |
Each question has:
| field | |
|---|---|
name |
Required. Lower-case letters, digits and _; not engine or fallback_reason. |
type |
Required. choice, yes_no or score. |
instructions |
Required. The question, in plain text. |
options |
For choice: 2 to 20 of {name, description}. |
levels |
For score: 2 to 10 of {name, description}, lowest first. |
steps: triage: kind: classify questions: - name: urgency type: choice instructions: How urgent is this request? options: - {name: high, description: Service down or a deadline today} - {name: low, description: Anything that can wait} - name: is_complaint type: yes_no instructions: Is the customer complaining? escalate: kind: condition branches: - name: urgent when: all: - {left: "{{ steps.triage.urgency.answer }}", op: equals, right: high} - {left: "{{ steps.triage.urgency.confidence }}", op: gte, right: 0.8}Output: one entry per question, holding answer, confidence and
probabilities, and position for a score (a number from 0 for the lowest
level up to one less than the number of levels). A yes-or-no answer is
true or false. When a chat model answered a question, its confidence,
probabilities and position are empty.
Also engine (decision, llm or mixed) and fallback_reason.
retrieve
Section titled “retrieve”Searches one or more collections.
| field | |
|---|---|
collections |
Required. 1 to 5 collection names, written out in the step. |
query |
A template; default {{ input.message }}. |
top_n |
How many results to return, 1 to 50. Left out, the collection’s own result count is used (8 unless the collection says otherwise). |
filters |
Metadata filters, as for the agent search tool. See Metadata & filters. |
Collection names are always literal. A template cannot choose what is searched, so nothing a user types or a model writes can move a search to a different collection. The search runs as the person using the workflow, so a collection they cannot read returns nothing. When you save, you must be able to read every collection you name.
Output: results (the rows search returns), context (the same rows
as text, each as [ref] content, ready for a prompt) and count.
Citations from retrieve steps are attached to the replies that cite
them, as they are for an agent, see Citations.
Writes variables.
values maps a variable name to a template or a literal. Only declared
variables can be written. A literal is checked against the variable’s
type when you save; a rendered value is checked when the step runs.
Output: the values written, by variable name.
Adds a fixed message to the chat.
text is required, and is a template. It appears whole, not streamed.
Output: text.
approval
Section titled “approval”Pauses the run until a person decides, then continues on one of three
branches: approved, rejected or timed_out.
approve: kind: approval approvers: {roles: [admin], members: [ana@example.com]} message: "Send this reply to {{ input.body.requester }}?" edit: "{{ steps['ui/desk-drafter'].text }}" expires_in: 3dmessageis required, and is a template shown to the approver.approversis optional.roleslists workspace roles (admin,editor,viewer) andmemberslists email addresses, at most 50. Either or both. They are written out literally, never as templates, so a webhook payload cannot choose who approves. Each address must belong to a verified workspace member.editis an optional template. The approver sees it in a text box and may change it. Later steps read the result as{{ approve.text }}, and{{ approve.edited }}says whether it was changed.expires_inis how long the request waits, from1hto30d, and defaults to3d. It applies only withapprovers.allow_selflets the person who started the run approve it. It defaults to false, and applies only withapprovers.
All three branches must be connected. Output: branch, approver (the
decider’s email, empty after a timeout), reason, decided_at, and,
with edit, text and edited. An approval step has no retry or
on_error, and it cannot be inside a for_each or loop.
Without approvers, the person in the chat decides, exactly as for an
approval on an edge. There is no request, no mail and no expiry. Such a
step cannot be in a workflow that has a trigger, because nobody would see
the request.
With approvers, the approvers are mailed a link to the
Approvals page. A decision is accepted from a
current member whose role is named, or whose verified email is named. The
person who started the run cannot decide it, unless allow_self is on;
for a triggered run that person is the trigger’s owner. An expired request
takes timed_out, and a decision on an expired request is refused. A run
continues only on a decision the platform recorded.
Named approvers are not available in a chat started from an embed or in a run started with an API key: start such a workflow from a trigger or the console. A workflow with named approvers cannot have an embed, and the check runs both when the step is saved and when the embed is created.
A chat that begins a new turn which reaches the same step asks again; each turn makes its own request.
The Approvals page
Section titled “The Approvals page”The Approvals page lists requests that name you, with a count in the navigation. Waiting shows requests you can decide now, oldest first. Decided shows the last 30 days. Open a request to read the message, change the text when the step offers one, add a reason, and approve or reject. If someone decided first, the page tells you who and when.
Workspace admins see every request, read-only, and decide only those that name them. The person who started the run sees theirs with no buttons.
Pauses the run on a question for the person in the chat, then continues with the answer.
level: kind: ask title: Choose a level question: "I found material on {{ topic.answer }}. Which level?" ask: choice options: [1 – easy, 2 – medium, 3 – challenging]questionis required, and is a template.prefaceis optional, a template of up to 2,000 characters shown above the question: feedback on the previous answer, a score. Empty means none.askisconfirm,choice,multi,scaleortext.options(choice and multi) is a list of 2–10 labels, each a template, or one template that renders to a list.progressis{current, total};scaleis{min, max, min_label, max_label}(default 1–5).
The person can tap an answer or type one; a typed answer goes through the workflow’s input guardrails. An answer that does not fit (an option that does not exist, a number out of range) asks again with the reason.
Outputs: answer (the answer as text, always set), selected (the chosen
labels), value (the number) and confirmed (true or false); the ones that
do not apply are null. Branch on them with a condition step.
An ask step cannot run inside a for_each or loop, takes no retry or
on_error, and cannot be in a workflow that has a trigger.
A list of questions
Section titled “A list of questions”One ask step can ask a whole list, such as a practice round, and grade it.
quiz: kind: ask title: Practice ask: choice items: "{{ draw.questions_json | fromjson }}" preface: "Let us start."itemsreplacesquestion,options,progressandscale, and works withask: choiceormulti. It is a template that renders to a list of 1–20 questions, either a list value or text holding a JSON list. A malformed list fails the step and names the item at fault.- Each item is
{question, options, correct, explanation, id}.questionandoptions(2–10 labels) are required.correctis optional: the correct option’s label, its number counted from 1, or a list of either formulti.explanation(up to 1,000 characters) is shown after the answer.idis passed through to the outputs. - The questions are asked one at a time, each showing its place (
2 of 5).prefaceis shown above the first question only. Above each later question the person sees feedback on the previous answer, ✓ Correct! or the correct answer, followed by the explanation, when that item has acorrect. A typed answer is graded by matching its text, ignoring case, to an option label or number. - When the last answer is in, the chat shows a review: the score, and for each question the answer given and, when it was wrong, the correct one.
Outputs: answers (a list of {id, question, selected, answer, correct},
correct being true, false or null when the item has no correct), score
(the number correct, null when no item has a correct) and total. The
single-question outputs are null. A later step can use them, for example
{{ quiz.score }} of {{ quiz.total }}.
Steps for unattended runs
Section titled “Steps for unattended runs”Eight more kinds are for workflows that run on a schedule or from a webhook. They are described in Triggers and automation.
- for_each runs a nested flow once for each item of a list. An
optional
progress_labelnames each item while the run is watched. - loop repeats a nested flow until a condition holds, up to a limit.
- http calls a web address and reads JSON, text or an RSS/Atom feed.
- email sends a plain-text email to workspace members or verified domains.
- ingest adds documents to a collection, from web addresses or from text, through the collection’s import queue.
- documents lists, reads and sets metadata on a collection’s documents.
A read returns up to 100,000 characters, which is what still fits in the
agent input a template builds;
limitandmax_charsmay come from a template. - code runs a short piece of Python in an isolated sandbox.
- tool calls a tool from a registered integration.
Agents in a flow
Section titled “Agents in a flow”An agent in agents can be a step in the flow too. An agent that has no
outgoing then edge and is reachable from start is a conversation
holder: it answers the user and keeps the conversation, see
the turn rule. An agent with a then edge is a
pipeline agent. It runs in the background, gets the conversation (or the
task you give it in input, a template rendered as one user message),
and its final text goes to steps["name"].text for later steps. It does
not talk to the user. A pipeline agent cannot have transfer edges.
Output: text.
A flow edge has a from, a to and a kind.
| kind | meaning |
|---|---|
then |
Continue to the next step. At most one per step or agent. |
branch |
Take this edge when a condition or router chooses the label named in branch:. |
error |
Take this edge when the step fails and has on_error: branch. |
transfer and delegate |
Between agents, as described in Workflows. |
On an edge that touches a step you can leave kind out: it is then,
or branch when the edge has a branch: label. Between two agents it
is required.
The rules the editor checks when you save:
- Every edge from
startnames where a run begins. A file with one may leave outentry; if it keepsentry, it must name the same target. - Every label a condition declares, plus
else, and every label a router declares, has exactly onebranchedge, and no edge names a label that does not exist. - A condition or router has no
thenedge. - A step with
on_error: branchhas exactly oneerroredge; any other step has none. - Any edge may point to
end, which finishes the run. Nothing leavesend. then,branchanderroredges cannot form a cycle. A flow only moves forward.- Every step must be reachable from
start. A step with no edge out ends the run. - A
transfermay only leave and arrive at an agent that can hold the conversation.
Expressions and references
Section titled “Expressions and references”Wherever a field says “a template”, you can put values into it with
{{ ... }}.
| name | what it holds |
|---|---|
input.message |
The user’s latest message. |
vars.<name> |
A variable. |
run.id, run.thread_id, run.started_at, run.source |
Facts about this run. |
<step>.<field> |
A field of an earlier step’s output, such as search.context. |
steps["<agent>"].text |
An agent’s output, where the name is not a plain identifier, such as steps["hr/triage"].text. |
A reference to a step is accepted only when that step comes before the current one in the flow, and only for a field that step declares. The editor checks this when you save. A step that did not run reads as empty.
When a field’s whole value is one reference, such as
query: "{{ input.message }}", it keeps the value’s type: a list stays a
list. In any other template the result is text, with lists and objects
written as JSON.
Templates use a small, deliberately limited expression language:
- Allowed: attribute and index reads, slices, comparisons,
and,or,not,+,-,~(join as text),x if cond else y, short list and object literals,istests and{% if %}blocks. - Not allowed: function and method calls, loops,
{% set %}, macros, includes, and every arithmetic operator other than+and-. The editor names the problem when you save. - Filters:
abs,capitalize,default,first,float,int,items,join,last,length,list,lower,map(asmap(attribute="name")),max,min,replace,reverse,round,selectattr,string,title,tojson,fromjson,trim,truncate,upperandwordcount.
Example: {{ search.results | map(attribute="title") | join(", ") }}.
fromjson turns JSON text into data, so a flow can read a request sent as
JSON. It takes text of at most 64 KiB, and bad JSON is a step error. With a
declared variable folder:
request: kind: set values: folder: "{{ (input.message | fromjson).folder }}"Limits: a template may use at most 100 filters, and may write at most 20 items in list and object literals in total. A result larger than 256 KB is a step error.
Only the text you write is treated as a template. What a user types, or what a model returns, is a value inside it and is never run.
In the console, every template field has an Insert reference picker
that lists input.message, the variables, run, and each earlier step
with the fields it produces, and inserts the {{ ... }} at the cursor.
Conditions
Section titled “Conditions”A rule is {left, op, right}. left and right are templates or plain
values. Rules can be combined with {all: [...]} and {any: [...]},
nested up to three deep. A condition step holds at most 50 rules in all.
| operator | true when |
|---|---|
equals, not_equals |
The two sides are equal, or not. |
contains, not_contains |
left contains right. |
starts_with, ends_with |
left starts or ends with right. |
regex |
left matches the pattern in right. |
empty, not_empty |
left is empty, or not. Takes no right. |
gt, gte, lt, lte |
A numeric comparison. If either side is not a number the rule is false. |
in, not_in |
left is in the list right. right must be a list. |
A regular expression is written out in the rule, not built from a
template. It can be at most 500 characters and is checked when you save.
It runs against the first 10,000 characters of left and is stopped
after 100 milliseconds, which is a step error. \p{...} classes and
fuzzy-matching syntax are not supported, so use character ranges instead.
In the console, a condition’s branches are edited as a rule builder: rows of left, operator and right, grouped by all or any.
Variables
Section titled “Variables”vars declares named values that last for the whole conversation, not
just one run, so a later turn can read what an earlier one stored.
vars: customer_tier: type: string default: standardThe type is string, number, boolean, list or object; default
is optional. A set step writes them and any template reads them as
vars.customer_tier. In the console they are edited under the
workflow’s Settings.
Variables are not an access control. Do not use one to decide who may see something.
Guardrails
Section titled “Guardrails”A workflow can carry its own guardrails, in the same shape as an
agent’s. The input check runs once when a run starts, on the user’s
message; if it refuses, the refusal is the reply and the run ends. The
output check runs on the text of every reply step and every llm step with
reply: true, before it is shown. A step whose result only feeds later
steps is not checked; the final reply is.
An agent in the flow still applies its own guardrails as well. See
Trust tiers for how a workflow’s agents are
limited. In the console, guardrails are edited under Settings.
The turn rule
Section titled “The turn rule”A conversation continues across several messages. After an agent in the
flow answers the user, it holds the conversation: the user’s next message
goes straight to that agent, and the steps before it do not run again.
If that agent transfers to another, the new agent holds it instead. If no
agent has answered yet, a message starts at start.
In the example above, a first message that classifies as other goes to
triage. From then on triage answers, so a follow-up policy question
goes to triage, not back through the router. That is why triage should
also be able to answer policy questions from the collection, when the
conversation might wander into them.
A flow that ends without any reply tells the user so, in one fixed message, and records a warning. A step that only writes variables or classifies, and then ends, therefore never leaves the chat silent.
Errors and retries
Section titled “Errors and retries”Any step can set retry and on_error, except that retry is refused on
for_each and loop (retry the steps inside them instead).
retry is {max, backoff_ms}. max is the number of extra attempts, 0
to 5. backoff_ms is the wait before the first retry, 0 to 60,000, and
the wait doubles on each later attempt. Approvals and transfers are never
retried (an approval step takes neither setting), and neither are a run exceeding a limit, a missing model, or a
trial workspace calling a model with no price.
When a step still fails after its retries, on_error decides:
| value | what happens |
|---|---|
fail |
The default. The run ends with an error. For a step, the error names the step; for a limit, it names the limit. |
continue |
The step’s output is empty and the flow follows its then edge. A condition or router that fails this way has no branch to take, so the run ends. Exceeding a limit always fails the run, whatever on_error says. |
branch |
The output is empty and the flow follows the step’s error edge. |
A failed step is recorded on the run either way. Only an error message that the platform wrote itself, such as an invalid template or a limit, is kept. Provider and driver errors keep their type but not their text, because the text can reveal internal addresses.
Limits
Section titled “Limits”| limit | default | maximum |
|---|---|---|
max_steps |
200 | 1,000 |
max_handoffs_per_turn |
5 | 20 |
max_model_calls |
50 | 200 |
max_steps counts every step and agent run in one run. A run that goes
past it ends with an error that names the limit. Other bounds: at most
100 steps, 50 variables and 20 agents in a workflow, 20,000 characters in
a prompt or template field, and 256 KB for one step’s output. In the
console, the three limits are edited under Settings.
The workflow page has a Runs tab, which lists the workflow’s past runs, newest first, from every source: the console chat, the API, an embed. Each row shows when it started, its source, who ran it (you, or the kind of caller: another user, an API key, an embed), its status, how long it took and what it cost.
Cost is the model calls made by the workflow’s agents and steps. Search planning, embeddings, reranking and guardrail checks are not included.
Opening a run shows the workflow with each step’s status, timing and cost on its box. A box that did not run is dimmed once the run has ended. Open a step to see what it received and what it returned, and for a failed step its error.
Who sees what:
- Anyone who can read the workflow sees the list of runs.
- A step’s input and output show on your own runs. Editors of the workflow and tenant admins also see them on every run.
- Anyone else sees a run’s status, timings, cost and the error type, but not its message.
- An agent in the workflow that you cannot read appears as “An agent”, with no content. Text it produced can still appear in a later step’s input or output.
Watching a run
Section titled “Watching a run”A workflow that runs for minutes shows where it is while it runs.
- Chat. Under your last message the chat shows one line: the step
that is running, such as “Find the documents…”, or for a
for_eachorloopits title, how far it has got and what is in flight, such as “Each document — 3 of 10 · Статини.pdf · 1 failed”. Aloopreads “iteration 3 of at most 10”. Screen readers hear the line at most every ten seconds. - Canvas. While a run goes, every box that is working is lit and a band
of light circles its border; a
for_eachorloopshows its count (“3/10”) and its body’s busy steps are lit too. With reduced motion turned on in your system, boxes are lit but do not animate. - API. A caller that does not stream can poll
GET /api/manage/workflows/{workflow_id}/runs/{run_id}/progresswith the same Bearer key. See Threads and runs.
Name the items with progress_label on a for_each or loop: a template
that can read the same names as the container’s body (item and index in
a for_each; iteration and previous in a loop), rendered for each
item and cut to 80 characters.
each: kind: for_each title: Each document items: "{{ find.documents }}" progress_label: "{{ item.filename }}"A label that fails to render leaves that item unnamed and never fails the run. At most five labels are shown at once. Labels are text from your own data, so they follow the same rule as a step’s input and output: you see them on your own runs, and editors of the workflow and workspace admins on every run. Anyone else sees the counts only.
Trace and cost
Section titled “Trace and cost”Each run is one trace, with a span for every step and, at debug level,
every pipeline agent. A step’s model calls count against max_model_calls
and appear as “Steps” in the Monitor tab’s cost-by-agent table. See Read traces and evals.
