Authoring reference

The complete workflow schema — every top-level key, every step kind, every field, and the rules the parser enforces before a run ever starts.

This page is the reference. If you want the conceptual tour — what a workflow is, why you would fan out, how runs are launched — start on Workflows and come back here for the exact field names.

Everything below is validated at parse time. Workflow YAML is parsed strictly: unknown keys inside trigger:, inputs:, panel:, branch:, approval:, distribute:, a step, or the document root are rejected outright rather than silently ignored. A typo is a load error, not a surprise at 3 a.m.

Workflow anatomy

A workflow is one YAML document. Only name and a non-empty steps array are required; every other key is optional and has a documented default.

KeyTypeRequiredDefaultMeaning
namestringyesWorkflow identifier. This is what rupu workflow run <name> resolves, and what project-local files shadow global ones by.
descriptionstringnononeHuman-readable summary. Surfaced in listings; ignored by the runtime.
triggerobjectnoon: manualHow the workflow starts — manual, cron, or event. See Triggers.
inputsmap<string, InputDef>no{}Typed runtime inputs, validated before the first step dispatches.
defaultsobjectno{}Per-workflow defaults inherited by every step (continue_on_error, workspace).
stepsarray<Step>yesThe ordered step list. An empty array is a parse error.
contractsobjectno{}Named machine-readable workflow outputs under contracts.outputs.<name>, validated against a stored schema.
autoflowobjectnononeAutonomous-ownership metadata consumed by rupu autoflow. See Autoflows.
notifyIssueboolnofalseWhen true and the run target resolves to an issue, rupu auto-comments on the issue at run start and at terminal state. Note the camelCase spelling — it is the literal YAML key.
concernslistnononeCoverage catalog for the whole run. When present it overrides each step agent's own concerns: so every step shares one ledger. See Coverage.

The minimal skeleton, with every optional block shown in position:

name: my-workflow
description: What this pipeline does.

trigger:
  on: manual              # manual | cron | event

inputs:
  target:
    type: string
    required: true

defaults:
  continue_on_error: false

contracts:
  outputs:
    result:
      from_step: handoff
      format: json
      schema: autoflow_outcome_v1

notifyIssue: false

steps:
  - id: handoff
    agent: writer
    actions: []
    prompt: "Work on {{ inputs.target }}."

The trigger: block

KeyTypeRule
onmanual | cron | eventDefaults to manual.
cronstringRequired for on: cron, rejected otherwise. Must be a 5-field expression (min hour dom mon dow).
eventstringRequired for on: event, rejected otherwise. An event identifier such as github.issue.opened.
filterstringOnly allowed on on: event. A minijinja expression over the event payload.

The defaults: block

KeyTypeMeaning
continue_on_errorboolEvery step inherits this unless it sets its own continue_on_error.
workspacesync | noneDefault workspace mode for remote steps. A step's own workspace: wins. Absent means none (self-contained).

The contracts: block

KeyTypeRequiredMeaning
outputs.<name>.from_stepstringyesStep id whose output is the canonical value. Must name a step that exists.
outputs.<name>.formatjson | yamlyesSerialization expected from that step.
outputs.<name>.schemastringyesContract name resolved to a schema file under .rupu/contracts/.

A step may also carry its own contract: { emits, format } as authoring metadata. If a step's contract disagrees with the workflow-level declaration that points at it, the workflow is invalid — the workflow-level entry is the runtime authority.

Inputs

Inputs are declared once and supplied at run time with a repeated --input key=value flag. Each declaration is validated at parse time (the default must coerce to the declared type, and must be a member of the enum when one is given), and each supplied value is validated before the first step runs.

KeyTypeRequiredDefaultMeaning
typestring | int | boolnostringDeclared input type. Drives coercion and error messages.
requiredboolnofalseMust be supplied at run time unless a default exists.
defaultscalarnononeUsed when the input is not supplied. Must match type.
enumarray<string>no[]Allowed stringified values. Non-empty means anything else is rejected — including the declared default.
descriptionstringnononeFree-form help text surfaced by rupu workflow show. Ignored by the runtime.
inputs:
  diff:
    type: string
    required: true
    description: The unified diff under review.
  retries:
    type: int
    default: 3
  severity:
    type: string
    default: high
    enum: [low, medium, high, critical]
  strict:
    type: bool
    default: true
rupu workflow run code-review-panel \
  --input diff="$(git diff origin/main)" \
  --input severity=high

Step kinds

Every step has a unique id and exactly one execution shape. The shape is inferred from which block is present; the parser rejects any combination of two.

step one shape linear agent + prompt for_each one agent over N items parallel N sub-steps, one subject panel reviewers + optional gate loop branch condition → then / else action connector call, no agent run declared command, no agent approval gate a human decides here
Eight shapes. The shape is inferred from the block present on the step — combining two is a parse error.
ShapeDefining fieldsRuns an agent?What it does
linearagent + promptyesOne agent, one prompt. The basic shape.
for_eachfor_each + agent + promptyesThe same agent once per rendered list item.
parallelparallelyesDistinct specialists concurrently, each with its own prompt.
panelpanelyesReviewer agents emitting structured findings, with an optional fix loop.
branchbranchnoEvaluates a condition and skips the steps on the arm it did not take.
actionaction (+ with)noA deterministic connector call through the MCP tool catalog. No tokens spent.
runrun (cmd + args)noExecutes a declared command as an argv vector and binds its stdout. No tokens spent. Opt-in per workspace; composes with for_each:.
approval gateapproval alonenoPauses the run for a human decision, with notify and reject-cleanup hooks.

Linear step

One agent runs one rendered prompt; its final assistant text becomes steps.<id>.output for every later step. This is also the only shape that accepts host:.

KeyTypeRequiredMeaning
agentstringyesAgent name to dispatch. Resolved project-local first, then global.
promptstringyesMinijinja template rendered into the agent's user message.
hoststringnoRun this step on a fleet host instead of locally. Must be non-empty.
contractobjectno{ emits, format } — authoring metadata for the structured output the step should emit.

Rules. Both agent and prompt are required — a step with neither a block nor both of these fails to parse. host: may not be combined with distribute:.

  - id: investigate
    agent: fix-bug
    actions: []
    prompt: |
      Investigate the bug described by:
      {{ inputs.prompt }}

      Stop without making edits. Report the root cause as text.

for_each: — data fan-out

One agent, one prompt, run once per item of a rendered list. The list source can be a JSON/YAML array or a newline-delimited block; if the rendered value starts with [ it is parsed as an array, otherwise each non-empty line becomes one item.

KeyTypeRequiredMeaning
for_eachstringyesMinijinja template that renders to the item list.
agentstringyesDispatched once per item.
promptstringyesRendered per item, with item and loop.* bound.
max_parallelintegernoIn-flight concurrency cap. Absent means 1 (serial, declared order). Must be ≥ 1.
distributeobjectno{ hosts: [...] } — spread the items round-robin across fleet hosts. for_each-only; hosts must be non-empty.

Published outputs. steps.<id>.results is the list of per-item output strings; steps.<id>.output is the JSON array of those strings; steps.<id>.success is true only if every item succeeded.

  - id: review_each
    agent: code-reviewer
    actions: []
    for_each: "{{ read_file('reports/items.json') }}"
    max_parallel: 4
    prompt: |
      Review {{ item }} ({{ loop.index }} of {{ loop.length }}).

parallel: — agent fan-out

N distinct sub-steps over the same context, each with its own agent and prompt. The parent step carries no agent or prompt of its own.

KeyTypeRequiredMeaning
parallelarray<SubStep>yesAt least one sub-step.
parallel[].idstringyesUnique within this block. Becomes the sub_results key.
parallel[].agentstringyesAgent for this sub-step.
parallel[].promptstringyesTemplate for this sub-step.
max_parallelintegernoSet on the parent step. Absent means 1.

Rules. A sub-step accepts only id, agent, and promptactions: and continue_on_error: belong to the parent and cover the whole block. Duplicate sub-step ids are a parse error, and an empty parallel: list is a parse error.

Published outputs. steps.<id>.sub_results.<sub_id>.output and .success per sub-step, plus the list form steps.<id>.results in declared order.

  - id: review
    actions: []
    max_parallel: 2
    parallel:
      - id: security
        agent: security-reviewer
        prompt: "Review for security issues: {{ inputs.diff }}"
      - id: perf
        agent: performance-reviewer
        prompt: "Review for performance issues: {{ inputs.diff }}"

panel: — structured review

Several reviewer agents run in parallel over one rendered subject and each returns a JSON object containing a findings array. rupu extracts the first parseable object from each panelist's final message (surrounding prose is tolerated); a panelist that emits malformed JSON contributes zero findings and logs a warning.

KeyTypeRequiredMeaning
panel.panelistsarray<string>yesAgent names. Must contain at least one.
panel.subjectstringyesTemplate rendered once; the thing under review.
panel.promptstringnoOptional per-panelist prompt template. Omit it and the rendered subject is sent verbatim as the user message, letting each panelist's own system prompt carry the review instructions.
panel.max_parallelintegernoConcurrent panelist cap. Absent means 1.
panel.gateobjectnoTurns the panel into a review/fix loop.
panel.gate.until_no_findings_at_severity_or_abovelow|medium|high|criticalyes (in gate)Loop while the maximum finding severity is at or above this threshold.
panel.gate.fix_withstringyes (in gate)Agent dispatched between passes with the aggregated findings; its final text becomes the next pass's subject.
panel.gate.max_iterationsintegeryes (in gate)Safety cap, at least 1. No implicit default — authors must choose one. Exhausting it exits with resolved = false.

Rules. panel: is mutually exclusive with for_each:, parallel:, action:, and the top-level agent/prompt. Empty panelists is a parse error.

Published outputs. steps.<id>.findings (each entry carries source, severity, title, body), .max_severity, .iterations, .resolved, and .output as the JSON findings array.

  - id: panel_review
    actions: []
    panel:
      panelists:
        - security-reviewer
        - performance-reviewer
        - maintainability-reviewer
      subject: "{{ inputs.diff }}"
      max_parallel: 3
      gate:
        until_no_findings_at_severity_or_above: high
        fix_with: finding-fixer
        max_iterations: 3

The findings JSON each panelist must return:

{
  "findings": [
    {
      "severity": "low|medium|high|critical",
      "title": "Short title",
      "body": "One sentence of detail"
    }
  ]
}

branch: — conditional routing

A branch step dispatches no agent. It renders condition, records which arm it took, and marks every step id on the other arm as skipped for the rest of the run. Truthiness uses the same rule as when:.

KeyTypeRequiredMeaning
branch.conditionstringyesMinijinja template. Truthy takes then, falsy takes else. Must not be empty.
branch.thenarray<string>noStep ids that run when the condition is truthy. Defaults to [].
branch.elsearray<string>noStep ids that run when the condition is falsy. Defaults to [].

Rules.

Published output. steps.<id>.output is the literal string then or else. Steps skipped by a branch record skipped = true.

  - id: route
    branch:
      condition: "{{ steps.panel_review.max_severity in ['low', ''] }}"
      then: [open_pr]              # clean review — ship it
      else: [file_issue, escalate]  # complete set of ids on this arm

action: — deterministic connector call

An action step calls one SCM / issue-tracker / CI tool directly through the same in-process MCP tool catalog agents use. No agent runs, no tokens are spent, and the result is deterministic. Use it whenever the work is "make this one API call" rather than "reason about something".

KeyTypeRequiredMeaning
actionstringyesA tool name from the catalog, e.g. scm.prs.create. Must be non-empty and must exist.
withmappingnoTool parameters. Keys are validated against the tool's schema at parse time; values are minijinja templates rendered at execution time.

Rules.

Published output. The connector's JSON response, bound to steps.<id>.output.

  - id: open_pr
    action: scm.prs.create
    with:
      owner: Section9Labs
      repo: rupu
      title: "fix: {{ inputs.title }}"
      body: "{{ steps.implement.output }}"
      head: "rupu/issue-{{ issue.number }}"
      base: main
      draft: false

run: — deterministic command

A run: step executes a declared command and binds its output. Use it when a step must produce an exact value — scoring, validation, report rendering, a preflight check. No language model is involved, so the step is reproducible, instant, and costs no tokens.

KeyTypeDefaultMeaning
run.cmdstring— (required)Executable name or path, rendered as a template. Must be non-empty. Resolved against PATH when it is a bare name.
run.argsarray<string>[]The argument vector. Each element is rendered as a template independently and becomes exactly one argv element — a rendered value is never re-split on whitespace.
run.cwdstringthe run’s workspace rootWorking directory for the child process. Rendered as a template.
run.envmapping<string, string>{}Extra environment variables. Values are rendered as templates and merged over the inherited environment.
run.parseraw | json | linesrawHow stdout is interpreted when binding the step’s structured output. See the table below.
run.timeout_secondsintegernoneKill the child after this many seconds. Absent means no timeout.
run.allow_exit_codesarray<integer>[0]Exit codes treated as success; anything else fails the step. Must list at least one code — an empty list can never be satisfied and is a parse error.
  - id: score
    run:
      cmd: python3
      args: ["tools/score_fixture.py", "{{ item.fixture }}", "{{ item.candidate }}"]
      cwd: "{{ inputs.cybermark_root }}"
      env: { CYBERBENCH_RELEASE_KEY: "{{ inputs.release_key }}" }
      parse: json            # json | lines | raw   (default: raw)
      timeout_seconds: 300
      allow_exit_codes: [0]  # default [0]; anything else fails the step

There is no shell.

cmd and args are handed to the OS as an argv vector. There are no pipes, no redirection, no globbing, and no word splitting. Because each args entry becomes exactly one argument, a rendered value containing ;, &&, or $(...) arrives at the process as literal argument text rather than executing — the step kind is injection-safe by construction, not by escaping.

If you genuinely need shell features, invoke a shell explicitly — cmd: sh with args: ["-c", "..."]. That is your decision to make, visible in the workflow file, rather than something the engine does to every step behind your back.

Parse modes.

parse:What steps.<id>.json bindsNotes
raw (default)stdout as a JSON stringNo interpretation. steps.<id>.output is stdout verbatim.
jsonthe parsed JSON object or array — indexableTrailing whitespace is trimmed before parsing. stdout that is not valid JSON fails the step; it does not fall back to the raw string, because binding garbage as “the output” would let a broken tool produce a plausible-looking but meaningless downstream result.
linesan array of strings, one per non-empty linestdout is split on newlines and empty lines are dropped.

Published output.

BindingTypeContents
steps.<id>.outputstringstdout under parse: raw; the parsed value re-serialized as JSON under json/lines. It is a string in every mode — output stays a string for every step kind in the engine, so a step that starts emitting JSON never changes what {{ steps.<id>.output }} renders elsewhere.
steps.<id>.jsonvaluestdout interpreted per parse:indexable. Use this, not output, to reach into structured results: {{ steps.score.json.score }}.
steps.<id>.stdoutstringThe raw stdout stream, verbatim. Empty for every other step kind.
steps.<id>.stderrstringThe raw stderr stream, verbatim. Empty for every other step kind.
steps.<id>.exit_codeintegerThe process exit code.
steps.<id>.duration_msintegerWall-clock duration of the child process.
steps.<id>.successboolWhether the exit code was in allow_exit_codes.
steps.<id>.resultsarrayUnder for_each:, the per-unit results in declared order regardless of finish order.
  - id: report
    run:
      cmd: echo
      args: ["scored {{ steps.score.json.score }} / 100"]

Fan-out.

run: composes with for_each: and max_parallel: — that is how you score N items concurrently. With continue_on_error: true, a failing unit is recorded with success = false and the remaining units still dispatch, so one bad item never costs you the other 199. Without it, any failing unit fails the step.

  - id: score
    for_each: "{{ steps.plan.json.jobs }}"
    max_parallel: 8
    continue_on_error: true
    run:
      cmd: python3
      args: ["score_job.py", "--job-id", "{{ item.job_id }}"]
      parse: json

Rules.

Enabling it: config gate and allowlist.

run: executes commands, so it is opt-in per workspace and off by default.

[workflow]
run_step_enabled = true
# Optional. Empty (the default) permits any executable.
# Matched on basename, so "/bin/bash" and "bash" gate alike.
run_step_allowlist = ["python3", "make"]

A workflow containing a run: step fails when the toggle is off — it does not skip the step. A benchmark that quietly omitted its scoring step would report a plausible-looking but meaningless number.

Permission modeBehaviour
readonlyrun: steps are refused — a command that executes is write-class. Re-run with --mode ask or --mode bypass.
askAllowed — the CLI resolves ask to bypass for workflow run: steps, and announces it with the same warning agent steps print. See the note below.
bypassAllowed.
Config is checked before permission mode. bypass cannot override a workspace that has not opted in — bypass is about skipping per-call prompts, not escalating past workspace policy. And ask allows run: steps for the same reason it allows agent writes inside a workflow: there is no operator present mid-run to answer a prompt, so a genuinely-prompting ask would hang every scheduled run. That gap is announced by the same warning agent steps print; the workspace opt-in above is the real control.

Approval gate

A step whose only block is approval: — no agent, prompt, for_each, parallel, panel, branch, or action — is a gate node: a first-class "a human decides here" step with its own id, its own graph node, and its own recorded decision.

The older form — approval: sitting alongside an agent+prompt step — is still supported and unchanged. It is called the legacy inline option below, and it accepts only required, prompt, and timeout_seconds.

KeyTypeGate nodeInline optionMeaning
requiredboolignoredyesOn the inline option, true is what makes the step pause. A gate node always pauses, so the field is redundant there.
promptstringoptionaloptionalWhat the operator sees. Rendered with the same context as a step prompt. Falls back to a generated "Approve gate <id>?" line.
timeout_secondsintegeroptionaloptionalHow long the gate may sit unanswered before its timeout policy applies.
auto_approvestringoptionalrejectedMinijinja expression evaluated when the gate is reached. Truthy resolves the gate as approved (via: auto) without ever pausing.
on_timeoutapprove|reject|failoptionalrejectedWhat a timed-out gate resolves to. Absent means fail. Requires timeout_seconds.
notifyarray<NotifyAction>optionalrejectedConnector calls fired best-effort when the gate parks. Failures are logged and never block the pause.
on_rejectarray<Step>optionalrejectedInline cleanup steps that run after a reject decision, before the run ends Rejected.

Gate lifecycle. when: is evaluated first — a skipped step never asks for approval. Then the prompt renders, then auto_approve. If the gate does not auto-resolve, the notify hooks fire and the run parks as awaiting_approval. Resume with rupu workflow approve <run-id>, or reject with rupu workflow reject <run-id> --reason "...", which runs the on_reject chain and then ends the run.

Notify hooks take the same shape as an action step — an action: name plus with: params — and are validated against the tool catalog at parse time, exactly like a real action step.

on_reject entries are ordinary steps, restricted to plain agent steps and action steps: no nested gates, no for_each, no parallel, no panel, no branch. They also may not set host: or when: — cleanup runs inline and unconditionally. Each failure is logged and the chain continues; their results are recorded under their own ids.

Published output. A gate node writes a JSON decision record to steps.<id>.output{ "decision", "via", "reason", "decided_at" } — where decision is approved or rejected and via is human or auto. The convenience shortcut steps.<id>.decision exposes the decision string directly.

  - id: merge_gate
    approval:
      prompt: |
        {{ steps.panel_review.findings | length }} findings,
        max severity {{ steps.panel_review.max_severity }}. Approve to open the PR.
      auto_approve: "{{ steps.panel_review.max_severity in ['low', ''] }}"
      timeout_seconds: 86400
      on_timeout: reject          # approve | reject | fail (default: fail)
      notify:
        - action: issues.comment
          with:
            project: Section9Labs/rupu
            number: "{{ issue.number }}"
            body: "Approval needed at merge_gate."
      on_reject:
        - id: note_rejection
          action: issues.comment
          with:
            project: Section9Labs/rupu
            number: "{{ issue.number }}"
            body: "Rejected at merge gate; no PR opened."

The legacy inline option, unchanged, for pausing an agent step in place:

  - id: deploy
    agent: releaser
    approval:
      required: true
      prompt: "About to deploy {{ inputs.tag }}. Approve?"
      timeout_seconds: 3600
    prompt: "Run the release for {{ inputs.tag }}."
notify issues.comment review panel merge_gate approval gate approved rejected open_pr action · scm.prs.create note_rejection on_reject · issues.comment
A gate node routes on the human decision: approve continues into the next step, reject runs the inline on_reject chain and ends the run. notify fires best-effort as the gate parks.

Common step fields

These apply across shapes, subject to the rule in the last column.

KeyTypeApplies toMeaning & rule
idstringallRequired. Must be unique within the workflow. Referenced as steps.<id> in templates — so use identifier-safe characters (letters, digits, underscore); a hyphen truncates the reference.
whenstringall except branchMinijinja expression reduced to truthy/falsy; falsy skips the step and sets steps.<id>.skipped. Evaluated before approval. Rejected on a branch: step and on on_reject cleanup steps.
continue_on_errorboolallTolerate this step's failure and keep going. Inherits defaults.continue_on_error when unset. On fan-out shapes it applies per unit — a failed item is recorded with success = false and the rest still dispatch.
actionsarray<string>allThe action-protocol allowlist — not a tool allowlist. Tool access lives in the agent's own tools: list. Unless you deliberately use the action protocol, write actions: [].
max_parallelintegerfor_each, parallel, panelConcurrency cap; must be ≥ 1. Absent means serial in declared order. Ignored on non-fan-out shapes. On panel it is set inside the panel: block.
hoststringlinear onlyRun this step's agent on a named fleet host. Rejected on for_each/parallel/panel/branch/action/gate nodes, alongside distribute:, and when empty.
distributeobjectfor_each only{ hosts: [...] }, spread round-robin. Rejected without for_each:, rejected outright on a run: step (which executes on the coordinator), and hosts must be non-empty.
workspacesync | noneremote stepsShip the coordinator's workspace to the host and bring changed files back. sync is rejected on a purely local step — it requires host: or distribute:. Overrides defaults.workspace.
approvalobjectallAlone on a step it is a gate node; alongside agent+prompt it is the legacy inline pause. See above.
contractobjectagent steps{ emits: <schema-name>, format: json|yaml }. Authoring metadata; must agree with any workflow-level contracts.outputs entry pointing at this step.

The action catalog

These are the tools an action: step (or a gate's notify:/on_reject: entry) may name. It is the same catalog the embedded MCP server exposes to agents — one catalog, one permission model.

ToolClassRequired with: keysWhat it does
scm.repos.listreadList repositories the authenticated user can access on a platform.
scm.repos.getreadowner, repoFetch one repository: default branch, clone URLs, visibility, description.
scm.branches.listreadowner, repoList branches with name, sha, and protected flag.
scm.branches.createwriteowner, repo, name, from_shaCreate a branch from a given SHA.
scm.files.readreadowner, repo, pathRead a single file at an optional ref. Returns path, ref, content, encoding.
scm.prs.listreadowner, repoList pull/merge requests. Optional state, author, limit.
scm.prs.getreadowner, repo, numberFetch one pull/merge request: title, body, state, head/base, author, timestamps.
scm.prs.diffreadowner, repo, numberFetch the unified-diff patch plus per-file change counts.
scm.prs.commentwriteowner, repo, number, bodyPost a top-level comment on a pull/merge request.
scm.prs.createwriteowner, repo, title, body, head, baseOpen a pull/merge request. Optional draft.
issues.listreadprojectList issues. Optional state, labels, author, limit.
issues.getreadproject, numberFetch one issue: title, body, state, labels, author, timestamps.
issues.commentwriteproject, number, bodyPost a comment on an issue.
issues.createwriteproject, title, bodyOpen a new issue. Optional labels.
issues.update_statewriteproject, number, stateTransition an issue to open or closed.
github.workflows_dispatchwriteowner, repo, workflow, refTrigger a GitHub Actions run. The workflow file must declare on: workflow_dispatch:. Optional inputs.
gitlab.pipeline_triggerwriteowner, repo, refTrigger a GitLab CI pipeline against a branch or tag. Optional variables.

Three things to know about with::

Prefer an action step over a prompt. "Comment on the issue" through an agent costs a model round-trip and can be phrased wrong. As an action: step it is one typed API call, validated at load time, recorded in the audit trail, and free.

Template expressions

Prompts, when: conditions, branch.condition, for_each: lists, panel.subject, approval.prompt, auto_approve, and every value inside with: are rendered with minijinja. Missing variables render as empty strings (autoflow runs render strictly — a missing variable is an error there).

ExpressionResolves to
{{ inputs.<name> }}A runtime input value, after type coercion.
{{ steps.<id>.output }}An earlier step's final output string (JSON array for fan-out steps; the decision JSON for a gate; then/else for a branch).
{{ steps.<id>.success }}Whether that step completed successfully. For fan-out, true only if every unit succeeded.
{{ steps.<id>.skipped }}Whether that step was skipped by when: or by a branch arm.
{{ steps.<id>.results }}Per-item (for_each) or per-sub-step (parallel) output list, in declared order.
{{ steps.<id>.sub_results.<sub_id>.output }}A named parallel: sub-step output. Also .success.
{{ steps.<id>.findings }}Aggregated panel findings; each entry has source, severity, title, body.
{{ steps.<id>.max_severity }}Highest panel severity as a string, or empty when there were no findings.
{{ steps.<id>.iterations }}Number of panel passes executed.
{{ steps.<id>.resolved }}Whether the panel gate cleared before max_iterations.
{{ steps.<id>.decision }}An approval gate's decision string: approved or rejected.
{{ item }}The current item inside a for_each: step.
{{ loop.index }}1-based position. Also loop.index0, loop.length, loop.first, loop.last.
{{ issue.number }}Issue-target context. Also issue.title, issue.body, issue.labels, issue.author, issue.state, issue.r.project.
{{ event.* }}The payload of the event that triggered the run, for on: event workflows.
read_file('<path>')The contents of a file a prior step wrote, resolved against the run's working directory. Fails loudly when missing.
{% for x in … %} · {% if … %}Standard minijinja control blocks; useful for folding results or findings into one prompt.
| length · | join(', ') · | upper · | default('…') · | tojsonCommon minijinja filters. | length on findings and | tojson for embedding structured data are the two you will reach for most.

The truthiness rule. when:, branch.condition, and auto_approve all render to a string and then reduce to a boolean. Falsy, case-insensitively, is exactly: the empty string, false, 0, no, off. Everything else is truthy — including the string "None", so guard optional values explicitly.

Deterministic fan-out. Because for_each: parses any rendered value starting with [ as an array, have an upstream step write a clean JSON array to a file and feed it with for_each: "{{ read_file('reports/items.json') }}". Control flow then depends on a file, not on how terse the agent happened to be in chat.

Validation rules

All of these are checked when the workflow file loads — before any provider is contacted and before any token is spent.

Document level

Inputs and triggers

Step shape

Run steps

Branch

Approval gates and actions

Templates and contracts

A complete annotated example

One workflow using five shapes together: a linear investigation, a review panel with a fix loop, an approval gate with notify and reject-cleanup hooks, a branch that routes on the outcome, and two deterministic action steps on either arm.

name: review-and-ship
description: Investigate, panel-review, gate on a human, then open a PR or file an issue.

inputs:
  title:
    type: string
    required: true
    description: Short summary of the change under review.
  diff:
    type: string
    required: true

defaults:
  continue_on_error: false   # a failed step aborts the run unless it opts out

steps:
  # 1. Linear step — one agent, one prompt. Output lands in steps.investigate.output.
  - id: investigate
    agent: fix-bug
    actions: []
    prompt: |
      Summarize what this change does and what could break:
      {{ inputs.diff }}

  # 2. Panel — three reviewers in parallel, then a fix loop until nothing
  #    high-or-worse remains (or three passes, whichever comes first).
  - id: panel_review
    actions: []
    panel:
      panelists: [security-reviewer, performance-reviewer, maintainability-reviewer]
      subject: "{{ inputs.diff }}\n\nContext: {{ steps.investigate.output }}"
      max_parallel: 3
      gate:
        until_no_findings_at_severity_or_above: high
        fix_with: finding-fixer
        max_iterations: 3

  # 3. Approval GATE NODE — no agent, no prompt: just a human decision.
  #    A clean panel auto-approves; otherwise the run parks and pings the issue.
  - id: merge_gate
    approval:
      prompt: |
        {{ steps.panel_review.findings | length }} findings
        (max severity: {{ steps.panel_review.max_severity }}). Ship it?
      auto_approve: "{{ steps.panel_review.max_severity in ['low', ''] }}"
      timeout_seconds: 86400
      on_timeout: reject
      notify:
        - action: issues.comment
          with:
            project: Section9Labs/rupu
            number: "{{ issue.number }}"
            body: "Waiting on approval at merge_gate."
      on_reject:
        - id: note_rejection
          action: issues.comment
          with:
            project: Section9Labs/rupu
            number: "{{ issue.number }}"
            body: "Rejected at merge gate — nothing was opened."

  # 4. Branch — routes forward only. Each arm lists EVERY id on that arm.
  #    No `when:` allowed here; the condition is the gate.
  - id: route
    branch:
      condition: "{{ steps.panel_review.resolved }}"
      then: [open_pr]
      else: [file_followup]

  # 5a. Action step — a typed API call. No agent, no tokens, no ambiguity.
  - id: open_pr
    action: scm.prs.create
    with:
      owner: Section9Labs
      repo: rupu
      title: "fix: {{ inputs.title }}"
      body: "{{ steps.investigate.output }}"
      head: "rupu/{{ inputs.title }}"
      base: main

  # 5b. The other arm. continue_on_error keeps a tracker outage from
  #     failing an otherwise-successful run.
  - id: file_followup
    action: issues.create
    continue_on_error: true
    with:
      project: Section9Labs/rupu
      title: "Unresolved review findings: {{ inputs.title }}"
      body: |
        The panel did not clear after {{ steps.panel_review.iterations }} passes.
        {% for f in steps.panel_review.findings %}
        - [{{ f.severity }}] {{ f.title }} ({{ f.source }}): {{ f.body }}
        {% endfor %}
      labels: [review, needs-triage]

Validate it before you run it — rupu workflow show <name> resolves and parses the file, so every rule on this page fires there first:

rupu workflow list
rupu workflow show review-and-ship
rupu workflow run review-and-ship \
  --input title="empty-cart NPE" \
  --input diff="$(git diff origin/main)"