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:, defaults:, contracts:, autoflow: (and each of its sub-blocks), run:, join:, a loop definition, 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
namestringyes—Workflow 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, findings_profile).
stepsarray<Step>yes—The 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. Fields in the autoflow block; behaviour on 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 the concerns block.
messagingbool or { tools: [...] }noonThe messaging tools every agent step and unit gets. Absent or true: board.claim / release / post / read and msg.send. false: off for every step and unit and everything they launch. { tools: [...] }: only the board.* / msg.* tools named. See the messaging key.
loopsmap<string, LoopDef>no{}Named, bounded loops over a subgraph of existing steps. See Graph orchestration.
max_concurrencyintegernounboundedCap on how many nodes a graph workflow runs at once, across the whole graph. Must be ≥ 1. Ignored by a plain chain workflow.

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).
findings_profilefull | summaryThe contract findings are recorded under, for every step that runs an agent and every action: findings.record step. A step's own findings_profile wins. See Findings profile.

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.

The concerns: block

A workflow can declare one coverage catalog for the whole run, in the same shape as an agent’s concerns: frontmatter (catalog includes, with optional overrides, filters and render mode). When it does, every agent step runs with it, and each step agent’s own concerns: is ignored for this run. When it doesn’t, each step uses its agent’s block, if any.

name: payments-review
concerns:
  - include: owasp-top10-2021
  - include: secrets-in-source
steps:
  - id: map
    agent: surface-mapper        # its own concerns: (if any) are ignored here
    actions: []
    prompt: "Map the request handlers under services/payments."
  - id: review
    agent: security-reviewer
    actions: []
    prompt: "Review what map found: {{ steps.map.output }}"

All of a workflow’s steps report into one coverage target, keyed by the workflow’s name, so their file touches, verdicts and findings accumulate in one ledger whichever agent ran each step. The workflow-level block is what makes that ledger coherent: one catalog, one snapshot, one audit. (A workflow launched as an agentiflow unit reports into the agentiflow’s target instead.) See Coverage.

The messaging: key

Every workflow run has one message space, and every agent step and unit joins it as it starts. Messaging is on by default: each step and unit is offered board.claim, board.release, board.post, board.read and msg.send, recorded in its tool_grant as ambient:messaging_default. The top-level messaging: key changes that for the whole workflow:

ValueEffect
absent, or trueThe default set above. true is the default written down.
falseNo messaging tool for any agent step or unit, nor for anything they launch — a child inherits the off switch. The steps are still in the space, receive-only: the operator’s messages reach them.
{ tools: [...] }Only the tools named, recorded as ambient:workflow_messaging. Only board.* and msg.* tools are accepted; any other name is a parse error.
name: quiet-review
messaging: false         # no messaging tool for any step or unit, or anything they launch
steps:
  - id: review
    agent: reviewer
    prompt: Review crates/rupu-cp.

A placed unit (host: / distribute:) runs on another machine with a space of its own there, and the key reaches it: false is launched as rupu run --no-messaging, and true or { tools: [...] } as one rupu run --messaging-tool per tool. A host whose rupu doesn’t advertise the capability (agent.no_messaging / agent.messaging_tools) is refused the unit rather than run it with messaging on. An agent file’s own messaging: false turns it off for that agent wherever it runs, and off wins over everything else. An autoflow is a workflow with an autoflow: block, so the same key is its opt-out.

The autoflow: block

Optional metadata that lets rupu autoflow run the workflow on its own: which issues or pull requests it may claim, when it wakes, where it works, and which declared output it hands back. The steps: DSL is unchanged. Every key is optional and unknown keys are rejected at every level. This is the field list; the behaviour (claims, leases, worktrees, the outcome loop) is explained on Autoflows.

KeyTypeDefaultMeaning
enabledboolfalseMarks the workflow as autonomously runnable. Everything else in the block is inert until this is true.
entityissue | pull_requestissueThe kind of item this autoflow owns. pull_request unlocks the PR-only selector fields.
sourcestringnoneFree-form tracker-native source tag, e.g. linear:<team-id> or jira:<host>/<project>. Says which bound tracker or repo the autoflow belongs to when more than one is configured; informational, not a filter.
priorityinteger0When several autoflows select the same item, the highest priority wins the claim; ties go to the workflow name.
selectorobjectempty (matches every item)Candidate filter. See the selector table below.
wake_onarray<string>[]Event ids that mark a candidate dirty and due for reconciliation. Same vocabulary and * glob matching as triggers.
reconcile_everydurationunsetMaximum time between reconciliations of an already-claimed item.
claim.keyissue | pr_head_shaissueWhat a claim is keyed on: one durable claim per issue, or one per pull request at a given head SHA.
claim.ttldurationunset (no lease expiry)How long the ownership lease is valid before another worker may take the item over.
workspace.strategyworktree | in_placeworktreeA durable per-item git worktree, or the repo's own checkout. With no workspace: block at all, [autoflow].checkout in config decides.
workspace.branchtemplate stringderived from the entityThe branch the worktree sits on. The only template-rendered field in the block (rendered strictly). Not consulted for entity: pull_request.
outcome.outputstring— (required inside outcome:)Names a key under contracts.outputs whose parsed value decides the claim's next state. An undeclared name is a parse error.

Durations (reconcile_every, claim.ttl) are digits followed by exactly one of s, m, h, d — 45s, 15m, 6h, 2d. Compound forms and bare numbers are parse errors.

The autoflow.selector: block

KeyTypeDefaultMeaning
statesarray of open | closed[] (any state)The item's state must be one of these.
labels_allarray<string>[]Every listed label must be present. Sent to the connector as the server-side narrowing filter.
labels_anyarray<string>[] (no constraint)At least one listed label must be present. An empty list imposes nothing. Applied locally after the fetch, because the issue connectors only filter conjunctively.
labels_nonearray<string>[]None of the listed labels may be present — the usual way to make an item drop out of the loop once it has been handled. Applied locally.
limitintegernoneCandidate cap per reconciliation cycle.
draftinclude | exclude | onlyunset (behaves like include)Pull requests only — a parse error on entity: issue.
basestringunset (any base)Pull requests only: restrict to PRs targeting this base branch. A parse error on entity: issue.
authorsarray<string>[]Explicit author-login allowlist. A listed login is always allowed.
authors_fromcollaborators | org_membersunsetBroader author scope, resolved by the SCM connector at tick time.
on_skipskip | label_needs_humanskipWhat happens to an item excluded solely by the author gate.

The three label lists combine with AND: an item must carry every labels_all label, at least one labels_any label (when that list is non-empty), and no labels_none label. Labels compare as exact, case-sensitive strings.

autoflow:
  enabled: true
  entity: issue
  selector:
    states: [open]
    labels_all: [bot-ready]
    labels_any: [area:parser, area:cli]    # either area will do
    labels_none: [blocked, bot-done]       # drop out once handled
  wake_on: ["github.issue.*"]
  reconcile_every: "30m"
  claim: { key: issue, ttl: "2h" }
  workspace:
    strategy: worktree
    branch: "bot/issue-{{ issue.number }}"
  outcome: { output: result }       # must name a contracts.outputs key

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)noOne connector or findings tool call through the tool catalog. No model turn, 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.

Two more step forms do no work of their own: a split: node forks into named concurrent tracks and a join: node is a barrier that gathers them. They are pure routing and belong to Graph orchestration below.

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. If the rendered value starts with [ it is parsed as a JSON array — and if it doesn’t parse, the step fails with the parse error before any unit runs; otherwise each non-empty line becomes one item. To fan out over a list of objects from another step, render it with | tojson, e.g. for_each: "{{ (steps.tagged.output | fromjson).rows | tojson }}" after an action: findings.query step with all: true.

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 prompt — actions: 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; the current subject is injected into it as {{ inputs.subject }}. 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: — one tool call, no agent

An action step calls one catalog tool directly — the same tool an agent or the control plane’s /mcp endpoint would call — under the run’s permission mode. No agent runs, no model turn happens, no tokens are spent. Use it whenever the work is "make this one call" rather than "reason about something".

KeyTypeRequiredMeaning
actionstringyesA tool an action step may call: a connector tool (scm.*, issues.*, github.*, gitlab.*) or a findings tool (findings.record — the findings.report tool’s action name — findings.verify, findings.query, findings.tag). A legacy alias works too. GET /api/tools marks them action_eligible.
withmappingnoParameters passed to the tool call. Keys are validated against the tool’s JSON Schema at parse time; string values may be minijinja templates, rendered at execution time.

Rules, all enforced at parse time.

Typed values. A with: value is coerced against the tool’s declared schema type:

Published output. steps.<id>.output is the tool’s return value serialized as a JSON string, and steps.<id>.success whether the call succeeded. Because output is a string, pull fields out of it with the fromjson filter — {{ (steps.fetch.output | fromjson).title }}, not {{ steps.fetch.output.title }}. The call is recorded in the run’s transcript as an action_emitted line followed by its tool_audit.

  - id: fetch
    action: issues.get
    with: { project: "acme/widget", number: 41 }

  - id: triage
    agent: triager
    prompt: |
      Issue title: {{ (steps.fetch.output | fromjson).title }}
      Labels: {{ (steps.fetch.output | fromjson)['labels'] | join(', ') }}

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

The findings tools work the same way: findings.query with all: true returns every match unpaged, which a for_each takes as its rows (each item is a slim row with id, title, severity, scope, location, concern_id, tags, declared_at, run_id), and findings.tag adds or removes tags on one or many findings.

  - id: tagged
    action: findings.query
    with: { q: "tag:needs-poc severity>=high", all: true }
  - id: poc
    agent: poc-writer
    for_each: "{{ (steps.tagged.output | fromjson).rows | tojson }}"
    max_parallel: 4
    prompt: |
      Write a proof of concept for finding {{ item.id }}: {{ item.title }}
      ({{ item.location }}). When it works, tag it `has-poc` with findings.tag.
  - id: mark
    action: findings.tag
    with: { finding_ids: ["fnd_01J…"], add: ["needs-poc"] }

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
requiredboolyesyestrue is what makes the step pause. Every documented gate node sets it too.
promptstringoptionaloptionalWhat the operator sees. Rendered with the same context as a step prompt; without it rupu generates a line naming the step.
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; the notify hooks do not fire on auto-approve.
on_timeoutapprove|reject|failoptionalrejectedWhat a timed-out gate resolves to. Absent means fail. Requires timeout_seconds.
notifyarray<action hook>optionalrejectedaction: hooks fired best-effort just before the gate parks. A failure is logged and never blocks the pause.
on_rejectarray<Step>optionalrejectedA cleanup chain run when the gate is rejected — by an operator, or by on_timeout: reject — 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", "approver", "reason", "decided_at" } — where decision is approved or rejected and via is human, auto or timeout. steps.<id>.decision is the decision string, already parsed — no fromjson needed.

Several gates at once. In a graph workflow more than one gate can park in the same wave. Decisions are path-scoped: approving a gate releases only the path behind it; rejecting one prunes only what is reachable solely through it and runs its on_reject chain, while sibling paths keep going (a reject in a plain chain workflow still ends the run). Target a gate with rupu workflow approve|reject <run-id> --gate <step-id> — required once more than one is parked. The run stays awaiting_approval until every gate is resolved, and a still-parked sibling does not re-fire its notify: hooks. A decision is recorded under the run lock and applied by exactly one runner, wherever it was made.

Timeouts need rupu cp serve. timeout_seconds / on_timeout are acted on by the control plane's background gate sweep ([cp].gate_sweep_enabled, default on, every [cp].gate_sweep_interval_secs = 60). Without it, an on_timeout: approve or reject gate simply stays parked until rupu workflow approve or reject decides it (reject runs the on_reject chain), and an on_reject chain triggered from the control plane’s web UI or rupu workflow cancel is recorded as pending and executed by the sweep, so it too waits for cp serve. rupu workflow runs deliberately resolves no gate and runs no cleanup chain — a read command has no external side effects.

  - id: merge_gate
    approval:
      required: true
      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>agent stepsNarrows the agent’s connector tools (scm.*, issues.*, github.*, gitlab.*) for this step to the ones listed; every other tool is untouched. Entries use the agent tools: grammar — an exact connector tool name, a namespace (issues.*) or *. [] (or omitting it) leaves the agent’s grant as is. Must be empty on an action: step and on a host:/distribute: step. See Step actions.
skillsarray<string>agent stepsSkills every agent this step launches may load, on top of its own. Same grammar as an agent’s skills:. See Step skills.
preload_skillsarray<string>agent stepsSkills whose bodies go into every launched agent’s system prompt. Same rules as skills.
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.
findings_profilefull | summaryagent steps, action: findings.recordThe findings contract for this step. Overrides defaults.findings_profile. A parse error on a step that runs no agent, and on any action other than findings.record.
nextarray<string>allSuccessor step ids. Using it makes the workflow a graph; see below.
depends_onarray<string>allPredecessor step ids — the inverse of next. a.next: [b] and b.depends_on: [a] describe the same edge.

Step actions: narrowing connector tools

An agent’s tools: says what it may ever call. A step’s actions: narrows that, for this step only, to a subset of the connector tools — scm.*, issues.*, github.* and gitlab.* (the list is in the action catalog). Every other tool — bash, read_file, the findings and coverage tools, dispatch, … — is governed only by the agent’s own tools: and the run mode. Entries use the same grammar as tools:: an exact connector tool name, a namespace (issues.*) or *.

steps:
  - id: triage
    agent: issue-triager           # tools: ["*"]
    actions: [issues.get, issues.comment]
    prompt: "Triage {{ inputs.issue }} and leave one comment."

Here the agent keeps bash, read_file and every other built-in, can read and comment on issues, and can’t open a PR, dispatch a CI workflow or touch any other connector tool during this step.

RuleWhat it means
Empty means unchangedactions: [], or no key, leaves the agent’s grant exactly as declared. Existing workflows keep working.
Connector tools onlyBuilt-ins (bash, read_file, ast_grep, …) are never removed, whatever the list says.
No escalationThe step’s connector tools are the agent’s grant intersected with actions:. Listing a tool the agent isn’t granted doesn’t grant it — rupu logs a warning, since it’s probably an authoring mistake. What was narrowed away is recorded in the step transcript’s tool_grant event (narrowed).
Reaches childrenThe narrowing reaches every child the step’s agent starts with dispatch, in its process or its own: a child can’t call a connector tool the step couldn’t.
Checked at loadAn unknown entry is a parse error with a did-you-mean, and so is an entry that names no connector tool (bash, findings.*), since it would have no effect. A non-empty list is rejected on an action: step (its tool is already explicit) and on a host:/distribute: step, where the narrowing couldn’t reach the remote agent.

The tool_audit trail. Every connector call a step makes — by an agent, or by an action: node — writes one tool_audit event to the step’s transcript. Built-in calls don’t. Four booleans describe it:

FieldTrue when
restrictedThe step declared a non-empty actions: list.
declaredThe step’s actions: names this tool. Always false on an unrestricted step, which is not a violation — check restricted.
grantedThe agent’s tools: covers this tool, before narrowing. Always true for an action: node.
blockedThe call was denied: narrowed out of the step, or refused by the permission mode (a write in readonly).
{"type":"tool_audit","data":{"tool":"scm.prs.create","declared":false,"granted":true,"blocked":true,"restricted":true}}

That line reads: the agent is allowed to open PRs in general, this step didn’t list it, and the call was blocked. Current lines also carry reason (why the tool was in the grant) and decision (allowed, denied:readonly, denied:tool, not_granted, …). The live run view flags blocked calls, and the control plane’s transcript view badges each audited call. The full event schema is in Transcripts.

Launching children from a step

A step’s agent starts other runs with the launch tools dispatch, join and workflows.generate — the same catalog tools every agent run has (see Agents). dispatch takes a kind of subagent (a task in the step’s own process), agent (its own rupu run) or workflow (its own rupu workflow run --plain), returns at once unless wait: true, and join collects the results. The older names — dispatch_agent, dispatch_agents_parallel, run_workflow, generate_workflow — are aliases of these and keep their old output shapes; the model only ever sees the canonical names. What an agent may launch is its dispatchableAgents: / dispatchableWorkflows: lists, and a workflow child is checked before it starts: it parses, has no approval gate and no host: / distribute: step, and runs only agents the caller may dispatch.

Inside a workflow the step’s settings follow the child selectively: its actions: narrowing does (above), while findings_profile and the step’s skills do not — a child resolves both from its own agent file (a workflow child from its own defaults). A child shares the run’s message space, and a messaging: false reaches it.

Step skills

skills: and preload_skills: give skills to the agents a step launches, on top of each agent’s own. The entries use the same grammar as an agent’s skills:, and they only add — they never remove a skill the agent declares.

steps:
  - id: review
    agent: reviewer
    prompt: Review the PR.
    skills: [pdf, "project:*"]      # the model may load these
    preload_skills: [house-style]   # always in the system prompt

Permission modes in a workflow

rupu workflow run takes --mode ask | bypass | readonly. ask and bypass behave identically for workflow agent steps, and ask is the default when --mode is omitted — so a run without an explicit mode grants its steps full tool access. A genuinely prompting ask would block on stdin with no operator at the tool layer, so instead each step’s transcript records a permission_mode_degraded notice the first time ask lets a write through, and rupu workflow run prints a warning when no mode was given.

ModeWorkflow agent stepsAction steps
bypassall tools allowedall catalog tools allowed
asksame as bypass — no prompt is possiblesame as bypass
readonlywrite and external tools denied (bash, write_file, edit_file, scm.prs.create, …); sub-agents run readonlyexternal tools refused

If you want a workflow restricted, pass --mode readonly explicitly — ask will not do it. For human-in-the-loop control over what a workflow does, use a gate node rather than the permission mode: a gate pauses the run at a point you choose, where an operator can actually answer. A placed unit runs under the same mode (see Remote placement).

Findings profile

Steps that record findings do so under one of two contracts: full, a complete structured report (the built-in default), or summary, the lightweight summary / severity / rationale record. The profile resolves most-specific first: the step's findings_profile, then defaults.findings_profile, then the agent's findingsProfile frontmatter, then full.

defaults:
  findings_profile: full          # every agent step records complete reports...
steps:
  - id: sweep
    agent: quick-scanner
    prompt: "Skim {{ inputs.path }} for hard-coded secrets."
    findings_profile: summary     # ...except this cheap first pass

Remote placement

By default every step’s agent runs locally, in the same process as the rest of the workflow. host: and distribute: place a step’s work on a different host in your fleet instead. A placed unit runs as rupu run <agent> on its host, and its output feeds downstream steps exactly as a local step’s would.

KeyApplies toMeaning & rule
hostlinear step onlyRun this one step on the named host. Must be a non-empty string; rejected on for_each/parallel/panel/branch/action/gate nodes and alongside distribute:.
distributefor_each only{ hosts: [...] } — the items are spread round-robin across the listed hosts. hosts must be non-empty; rejected outright on a run: step.
workspaceremote stepssync ships the coordinator’s workspace to the host and brings file changes back. Omitted, or none, keeps the step self-contained: it sees only its rendered prompt and prior steps’ string outputs, no files. sync on a purely local step is a parse error; defaults.workspace is the fallback for every remote step without its own.
  - id: edit
    agent: editor
    for_each: "x\ny\nz"
    prompt: "edit {{ item }}.txt"
    max_parallel: 3
    workspace: sync
    distribute:
      hosts: [w1, w2]

What travels with the unit. The coordinator launches each unit (and a fan-out unit’s retry on its fallback host) with everything the step resolved:

A unit’s findings and coverage reach the coordinator through the run’s coverage stream; the workspace: sync delta still carries .rupu/coverage/, which the coordinator drops once the unit’s complete stream merged and applies otherwise. A stream that can’t be delivered or merged, or that may be incomplete, shows a StepWarning on the step; the unit is not failed. Every placed-unit error and warning is prefixed host <id>:. On rupu workflow resume a placed step or unit is restarted from its prompt, not continued from its transcript.

Graph orchestration

By default steps run in declaration order. A workflow becomes an explicit graph — scheduled by node readiness instead of list order — as soon as any step uses next:, depends_on:, split: or join:, or the workflow declares loops:. Edges from {{ steps.X.* }} template references count too, and the whole edge set must be acyclic.

max_concurrency: 4                  # whole-graph cap (optional)
steps:
  - id: recon
    split: [api, web, infra]        # three concurrent tracks
  - id: api
    agent: api-reviewer
    prompt: "Review the API surface."
    next: [gathered]
  - id: web
    agent: web-reviewer
    prompt: "Review the web client."
    next: [gathered]
  - id: infra
    agent: infra-reviewer
    prompt: "Review the deploy config."
    next: [gathered]
  - id: gathered
    join: { wait: { count: 2 } }    # all (default) | any | { count: N }
  - id: report
    agent: writer
    prompt: "Summarize {{ steps.gathered.results | length }} reviews."
    depends_on: [gathered]
ConstructMeaning & rule
split: [ids]Fans out into independent concurrent tracks, named by step id. Pure routing — no agent, action, fan-out, branch or approval of its own.
join: { wait }A barrier. wait is all (default), any, or { count: N }; the gathered outputs are steps.<id>.results. Needs at least one inbound edge, and count can't exceed the paths that feed it. Two tracks that both point at an ordinary step already wait for each other — reach for join: when you want any / count or a dedicated barrier.
loops.<name>nodes (≥ 2 existing steps, each in at most one loop), until (expression evaluated after each iteration), max_iterations (≥ 1), and on_max: fail (default) or proceed with the last iteration's outputs. Progress is loops.<name>.iteration / .converged.
max_concurrencyWhole-graph cap, distinct from a fan-out step's own max_parallel. Unset is unbounded. The machine's own concurrency ceiling still applies on top — see Multi-host.
loops:
  refine:
    nodes: [draft, critique]
    until: "{{ 'APPROVED' in steps.critique.output }}"
    max_iterations: 3
    on_max: proceed

The action catalog

These are the tools an action: step (or a gate's notify:/on_reject: entry) may name: the connector tools, which a step’s actions: can also narrow, and the findings tools, which it can’t. It is the same catalog the MCP endpoint and agents use — one catalog, one permission model. Every SCM tool takes an optional platform key and every issue tool an optional tracker key; omit them and rupu falls back to [scm.default] / [issues.default] from your config.

ToolClassRequired with: keysWhat it does
scm.repos.listread—List 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.commentsreadproject, numberRead an issue's comment thread, oldest first, with each commenter's role on the repo where the tracker exposes one. Optional limit (default 100).
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.
findings.recordrecordscope (+ per profile)Record a finding in the project's ledger (the findings.report tool’s action name). Under full send a report; under summary send summary, severity, rationale (or an evidence object). Takes an optional tags array. See Findings profile.
findings.verifyrecordsee the tool’s schemaSet an existing finding’s verification status (confirmed, disputed, inconclusive) — what a verification run records, never the reporting agent. Its parameters are in GET /api/tools.
findings.queryreadqThe project's findings matching a one-line query (e.g. severity>=high tag:needs-poc): one page of slim rows (limit defaults to 50, at most 500; cursor for the next page) plus next_cursor, total and tags_in_use. all: true returns every match unpaged — made for a for_each over the rows.
findings.tagrecordfinding_idsAdd or remove tags (add, remove) on one or many of the project's findings; returns each finding's tags before and after. Allowed in readonly mode: it annotates the ledger and never touches the workspace’s files.

Two 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>.error }}Why a tolerated step failed, as the step recorded it (for an agent step, the outcome's title and hint). Empty on success.
{{ 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 event that triggered the run, for on: event workflows. event.id, event.vendor and event.repo.full_name are always present; the vendor-native object is one level down under event.payload.* (event.payload.pull_request.merged). A top-level event.pull_request.* does not exist and renders empty — a when: built on it is silently, permanently falsy.
{{ loops.<name>.iteration }} · .convergedA loops: entry's progress: to its own members mid-iteration, to its until, and to every step downstream once it finishes.
read_file('<path>')The contents of a file a prior step wrote. A relative path resolves against the run's workspace — the directory its agents and run: steps work in (for a run target, the clone), not where rupu was started — so a resumed run reads the same file. Fails loudly when missing.
| fromjsonThe parsed JSON value — the inverse of | tojson, and what makes an action: step's string output indexable: (steps.fetch.output | fromjson).title. Values come back typed, so (steps.fetch.output | fromjson).comments > 5 works. Fails the run on invalid JSON; it never silently yields an empty value.
{% 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

Skills and messaging

Graph and findings profile

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: []
    skills: ["project:*"]       # the project's skills, on top of the agent's own
    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:
      required: true
      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)"

# or run a file that isn't in the catalog yet
rupu workflow run --file ./review-and-ship.yaml --input title="empty-cart NPE"