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:

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:.

investigate linear plan linear item 1 for_each item 2 for_each item 3 for_each report
Steps run left to right; a 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.

KeyTypeRequiredMeaning
namestringyesWorkflow identifier used by rupu workflow run <name>.
descriptionstringnoHuman-readable summary.
triggerobjectnoHow the workflow starts: manual (default), cron, or event.
inputsmapnoTyped runtime inputs (type, required, default, enum).
defaultsobjectnoWorkflow-wide defaults — e.g. continue_on_error: true inherited by steps.
contractsobjectnoNamed structured outputs validated against a JSON Schema.
autoflowobjectnoAutonomous-ownership metadata for rupu autoflow (see Autoflows).
notifyIssueboolnoAuto-comment back only when the run target is an issue.
stepsarray<Step>yesThe 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.

ShapeDefining fieldsWhat it does
linearagent + promptOne agent runs one prompt; its output feeds the next step. The basic shape.
for_eachfor_each (+ agent, prompt)Renders a list and runs the same agent once per item, with max_parallel concurrency.
parallelparallel (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.
panelpanel (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:

KeyApplies toMeaning
idallUnique within the workflow.
actionsallAction-protocol allowlist — not a tool allowlist. Use [] unless you intentionally use the action protocol.
whenallMinijinja expression reduced to truthy / falsy; falsy values are empty string, false, 0, no, off.
continue_on_errorallTolerate failure and keep going; inherits the workflow default.
max_parallelfor_each, parallel, panelConcurrency cap (at least 1).
approvalallHuman 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 fieldRequiredMeaning
until_no_findings_at_severity_or_aboveyesSeverity threshold to clear: low, medium, high, or critical.
fix_withyesAgent that addresses findings between passes; its output becomes the next pass's subject.
max_iterationsyesCap 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 }}."
Valid only on 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:

ExpressionResolves 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.*.

Deterministic fan-out. Because 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 "...".

Build it visually. Workflows can also be authored in the rupu control plane, where a drag-and-drop graph and the underlying YAML stay in sync — edit either side and the other follows.