Workflows
A workflow composes several agents into ordered steps — one YAML file that orchestrates the run and passes data from each step to the next.
What is a workflow
Where an agent is a single AI worker, a workflow is several of them composed into an ordered process. It lives in one YAML file and describes which specialist owns each stage, where the review and approval boundaries are, and how data flows from one step to the next.
A workflow can:
- run linear steps in order;
- fan one agent out across many items with
for_each:; - fan many specialist agents out over the same subject with
parallel:; - run structured review panels with an optional fix loop using
panel:; - pause for a human gate with
approval:; - start manually, on cron, or from an event trigger.
Step prompts are rendered with minijinja templates against the workflow's inputs, prior step outputs,
and optional issue or event context — that is how data moves between steps. Workflow files resolve from
~/.rupu/workflows/<name>.yaml (global) and
<project>/.rupu/workflows/<name>.yaml (project-local), with
project-local files shadowing global ones by name:.
for_each: step fans out over a list, then later steps consume the aggregate.The YAML format
A workflow is a YAML document with a handful of top-level keys. Only name and
a non-empty steps array are required; everything else is optional. The
frontmatter is parsed strictly — extraneous fields inside blocks such as trigger:
are rejected at parse time.
| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Workflow identifier used by rupu workflow run <name>. |
description | string | no | Human-readable summary. |
trigger | object | no | How the workflow starts: manual (default), cron, or event. |
inputs | map | no | Typed runtime inputs (type, required, default, enum). |
defaults | object | no | Workflow-wide defaults — e.g. continue_on_error: true inherited by steps. |
contracts | object | no | Named structured outputs validated against a JSON Schema. |
autoflow | object | no | Autonomous-ownership metadata for rupu autoflow (see Autoflows). |
notifyIssue | bool | no | Auto-comment back only when the run target is an issue. |
steps | array<Step> | yes | The ordered step list. An empty array is invalid. |
Inputs are declared once and supplied at run time. Each input takes a type of
string, int, or bool; an
optional required flag; an optional default that must
match the type; and an optional enum of allowed stringified values.
inputs: phase: type: string required: true retries: type: int default: 3
Step kinds
Every step has a unique id and exactly one execution shape. A step is either a
linear step, a for_each: fan-out, a
parallel: multi-agent fan-out, or a panel: review.
| Shape | Defining fields | What it does |
|---|---|---|
linear | agent + prompt | One agent runs one prompt; its output feeds the next step. The basic shape. |
for_each | for_each (+ agent, prompt) | Renders a list and runs the same agent once per item, with max_parallel concurrency. |
parallel | parallel (list of sub-steps) | Different specialists run concurrently over the subject. Each sub-step has its own id / agent / prompt; the parent must not set agent / prompt. |
panel | panel (panelists + subject) | Several reviewer agents emit structured findings over one subject, with an optional gate review/fix loop. |
These fields apply to any step regardless of shape:
| Key | Applies to | Meaning |
|---|---|---|
id | all | Unique within the workflow. |
actions | all | Action-protocol allowlist — not a tool allowlist. Use [] unless you intentionally use the action protocol. |
when | all | Minijinja expression reduced to truthy / falsy; falsy values are empty string, false, 0, no, off. |
continue_on_error | all | Tolerate failure and keep going; inherits the workflow default. |
max_parallel | for_each, parallel, panel | Concurrency cap (at least 1). |
approval | all | Human pause before the step dispatches (checked after when:). |
Conditional steps with when:
when: is rendered as a template and then reduced to a boolean. If it is falsy
the step is skipped (and steps.<id>.skipped becomes true downstream).
when: "{{ steps.review.success }}" when: "{{ steps.panel.max_severity == 'critical' }}"
Human gates with approval:
When a step sets approval.required: true, the run pauses before that step
dispatches. Resume with rupu workflow approve <run-id> or reject with
rupu workflow reject <run-id> --reason "...".
approval: required: true prompt: | About to deploy {{ inputs.tag }}. Approve? timeout_seconds: 3600
The panel: gate loop
A panel: step runs its panelists over the rendered
subject; each panelist's final message must contain a parseable JSON object with
a findings array. An optional gate turns the panel into
a review/fix loop:
| Gate field | Required | Meaning |
|---|---|---|
until_no_findings_at_severity_or_above | yes | Severity threshold to clear: low, medium, high, or critical. |
fix_with | yes | Agent that addresses findings between passes; its output becomes the next pass's subject. |
max_iterations | yes | Cap on panel passes (at least 1). The loop stops when the gate clears or this is reached. |
Distribute a step across the fleet
A for_each step can spread its units across multiple hosts with a distribute: block — units are assigned round-robin to the named fleet hosts, run there, and their results aggregate back exactly like a local fan-out. Omit distribute: and the step runs locally, unchanged.
steps: - id: review_each agent: code-reviewer for_each: "{{ inputs.files }}" distribute: hosts: [gpu-box, build-box] # round-robin across fleet hosts prompt: "Review {{ item }}."
for_each steps, and hosts must be non-empty. Each unit's run is attributed to the host it ran on (visible in the control plane), and a remote unit failure is handled per the step's continue_on_error. Register the hosts first — see Multi-host.Template expressions
Prompts, when: conditions, and for_each: lists are
rendered with minijinja. Missing variables render as empty strings. The most common references are:
| Expression | Resolves to |
|---|---|
{{ inputs.<name> }} | A runtime input value. |
{{ steps.<id>.output }} | The final output string of an earlier step. |
{{ steps.<id>.success }} | Whether that step completed successfully. |
{{ steps.<id>.skipped }} | Whether that step was skipped by when:. |
{{ steps.<id>.results }} | Per-item (for_each) or per-sub-step (parallel) outputs. |
{{ steps.<id>.sub_results.<sub_id>.output }} | A named output from a parallel: sub-step (also .success). |
{{ steps.<id>.findings }} | Aggregated panel findings (also .max_severity, .iterations, .resolved). |
{{ item }} · {{ loop.index }} | The current item and 1-based index inside a for_each: (also loop.index0, loop.length, loop.first, loop.last). |
read_file('<path>') | The contents of a file a prior step wrote, resolved against the run's working directory. Fails loudly if missing. |
When the workflow is invoked against an issue target, an issue.* context is also
available (issue.number, issue.title,
issue.labels, and more); event-triggered workflows get the payload under
event.*.
for_each: JSON-parses any
rendered value that starts with [, have an upstream step write a clean JSON
array file and feed it with for_each: "{{ read_file('reports/items.json') }}".
That decouples control flow from how terse the agent was in chat.
A complete example
This is the investigate-then-fix workflow that ships as a rupu template — a
two-step linear bug fix where the second step consumes the first step's output via a template expression.
name: investigate-then-fix description: Two-step bug fix — investigate, then propose minimal edit. steps: - id: investigate agent: fix-bug actions: [] prompt: | Investigate the bug described by: {{ inputs.prompt }} Stop without making edits. Report the root cause as text. - id: propose agent: fix-bug actions: [] prompt: | Based on this investigation: {{ steps.investigate.output }} Propose and apply the minimal fix.
The fan-out shapes follow the same skeleton. A for_each: step adds a
for_each list and references {{ item }};
a later step then folds the results with a loop over
steps.review_each.results:
- id: review_each agent: code-reviewer actions: [] for_each: "{{ inputs.files }}" max_parallel: 4 prompt: | Review file {{ item }} ({{ loop.index }} of {{ loop.length }}). - id: summarize agent: writer actions: [] prompt: | Combine these per-file reviews into one summary. {% for r in steps.review_each.results %} {{ r }} {% endfor %}
Generate one from a description
You can also describe the pipeline in plain language and let rupu draft the YAML. create generates the workflow with a model — running a validate → repair loop against the real schema so the output parses — then opens it for you to refine. The same flow lives in the control plane’s authoring UI (and its visual editor).
# AI-draft a workflow from a one-line brief rupu workflow create nightly-audit \ --describe "Run a parallel security + performance review panel over the diff, then summarize" # or scaffold an empty workflow to edit yourself (omit --describe) rupu workflow create my-workflow
Running a workflow
List and inspect the workflows rupu can see, then run one:
# list every resolvable workflow (project + global scope) rupu workflow list # show one workflow's resolved definition rupu workflow show investigate-then-fix # run it, passing typed inputs rupu workflow run investigate-then-fix --input prompt="NPE on empty cart checkout"
Pass each declared input with a repeated --input key=value flag. If the
workflow also takes an issue target, supply it positionally before the inputs:
rupu workflow run <name> <issue-ref> --input .... When a step requires
approval:, the run pauses; resume it with
rupu workflow approve <run-id> or reject with
rupu workflow reject <run-id> --reason "...".