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.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
name | string | yes | — | Workflow identifier. This is what rupu workflow run <name> resolves, and what project-local files shadow global ones by. |
description | string | no | none | Human-readable summary. Surfaced in listings; ignored by the runtime. |
trigger | object | no | on: manual | How the workflow starts — manual, cron, or event. See Triggers. |
inputs | map<string, InputDef> | no | {} | Typed runtime inputs, validated before the first step dispatches. |
defaults | object | no | {} | Per-workflow defaults inherited by every step (continue_on_error, workspace). |
steps | array<Step> | yes | — | The ordered step list. An empty array is a parse error. |
contracts | object | no | {} | Named machine-readable workflow outputs under contracts.outputs.<name>, validated against a stored schema. |
autoflow | object | no | none | Autonomous-ownership metadata consumed by rupu autoflow. See Autoflows. |
notifyIssue | bool | no | false | When 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. |
concerns | list | no | none | Coverage 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
| Key | Type | Rule |
|---|---|---|
on | manual | cron | event | Defaults to manual. |
cron | string | Required for on: cron, rejected otherwise. Must be a 5-field expression (min hour dom mon dow). |
event | string | Required for on: event, rejected otherwise. An event identifier such as github.issue.opened. |
filter | string | Only allowed on on: event. A minijinja expression over the event payload. |
The defaults: block
| Key | Type | Meaning |
|---|---|---|
continue_on_error | bool | Every step inherits this unless it sets its own continue_on_error. |
workspace | sync | none | Default workspace mode for remote steps. A step's own workspace: wins. Absent means none (self-contained). |
The contracts: block
| Key | Type | Required | Meaning |
|---|---|---|---|
outputs.<name>.from_step | string | yes | Step id whose output is the canonical value. Must name a step that exists. |
outputs.<name>.format | json | yaml | yes | Serialization expected from that step. |
outputs.<name>.schema | string | yes | Contract 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.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
type | string | int | bool | no | string | Declared input type. Drives coercion and error messages. |
required | bool | no | false | Must be supplied at run time unless a default exists. |
default | scalar | no | none | Used when the input is not supplied. Must match type. |
enum | array<string> | no | [] | Allowed stringified values. Non-empty means anything else is rejected — including the declared default. |
description | string | no | none | Free-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.
| Shape | Defining fields | Runs an agent? | What it does |
|---|---|---|---|
linear | agent + prompt | yes | One agent, one prompt. The basic shape. |
for_each | for_each + agent + prompt | yes | The same agent once per rendered list item. |
parallel | parallel | yes | Distinct specialists concurrently, each with its own prompt. |
panel | panel | yes | Reviewer agents emitting structured findings, with an optional fix loop. |
branch | branch | no | Evaluates a condition and skips the steps on the arm it did not take. |
action | action (+ with) | no | A deterministic connector call through the MCP tool catalog. No tokens spent. |
run | run (cmd + args) | no | Executes a declared command as an argv vector and binds its stdout. No tokens spent. Opt-in per workspace; composes with for_each:. |
| approval gate | approval alone | no | Pauses 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:.
| Key | Type | Required | Meaning |
|---|---|---|---|
agent | string | yes | Agent name to dispatch. Resolved project-local first, then global. |
prompt | string | yes | Minijinja template rendered into the agent's user message. |
host | string | no | Run this step on a fleet host instead of locally. Must be non-empty. |
contract | object | no | { 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
for_each | string | yes | Minijinja template that renders to the item list. |
agent | string | yes | Dispatched once per item. |
prompt | string | yes | Rendered per item, with item and loop.* bound. |
max_parallel | integer | no | In-flight concurrency cap. Absent means 1 (serial, declared order). Must be ≥ 1. |
distribute | object | no | { 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
parallel | array<SubStep> | yes | At least one sub-step. |
parallel[].id | string | yes | Unique within this block. Becomes the sub_results key. |
parallel[].agent | string | yes | Agent for this sub-step. |
parallel[].prompt | string | yes | Template for this sub-step. |
max_parallel | integer | no | Set 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
panel.panelists | array<string> | yes | Agent names. Must contain at least one. |
panel.subject | string | yes | Template rendered once; the thing under review. |
panel.prompt | string | no | Optional 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_parallel | integer | no | Concurrent panelist cap. Absent means 1. |
panel.gate | object | no | Turns the panel into a review/fix loop. |
panel.gate.until_no_findings_at_severity_or_above | low|medium|high|critical | yes (in gate) | Loop while the maximum finding severity is at or above this threshold. |
panel.gate.fix_with | string | yes (in gate) | Agent dispatched between passes with the aggregated findings; its final text becomes the next pass's subject. |
panel.gate.max_iterations | integer | yes (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:.
| Key | Type | Required | Meaning |
|---|---|---|---|
branch.condition | string | yes | Minijinja template. Truthy takes then, falsy takes else. Must not be empty. |
branch.then | array<string> | no | Step ids that run when the condition is truthy. Defaults to []. |
branch.else | array<string> | no | Step ids that run when the condition is falsy. Defaults to []. |
Rules.
- Mutually exclusive with
agent/prompt,for_each:,parallel:,panel:, andaction:. when:is not allowed on a branch step. The runner evaluateswhen:before the branch block, so a falsywhen:would skip the branch without its condition ever running — and then both arms would execute. The parser rejects it outright.- Every target must name an existing step, and must appear strictly after the branch step in declaration order. Branches route forward only; they are not a loop construct.
- An id may not appear in both
thenandelse, and neither arm may target the branch step itself. - Each arm is the complete, transitive set of ids in that arm — including the arm steps of any branch nested inside it. A nested branch on a not-taken arm never gets to populate its own skip-set, so its steps must already be listed in the outer arm.
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".
| Key | Type | Required | Meaning |
|---|---|---|---|
action | string | yes | A tool name from the catalog, e.g. scm.prs.create. Must be non-empty and must exist. |
with | mapping | no | Tool parameters. Keys are validated against the tool's schema at parse time; values are minijinja templates rendered at execution time. |
Rules.
- Mutually exclusive with
agent/prompt,for_each:,parallel:,panel:, andapproval:. with:must be a mapping. An unknown parameter key, or a missing required parameter, is a parse error — validated against the static catalog, so linting a workflow needs no credentials.- Parameter values are not checked at parse time (they may be templates); they are re-validated after rendering, immediately before dispatch.
when:andcontinue_on_error:behave exactly as on any other step. A template-render failure inwith:always aborts, even undercontinue_on_error, because that is an authoring error rather than a runtime one. There is no built-in retry.- The executed call is recorded in the run's audit trail as an action envelope, unified with the agent action protocol.
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.
| Key | Type | Default | Meaning |
|---|---|---|---|
run.cmd | string | — (required) | Executable name or path, rendered as a template. Must be non-empty. Resolved against PATH when it is a bare name. |
run.args | array<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.cwd | string | the run’s workspace root | Working directory for the child process. Rendered as a template. |
run.env | mapping<string, string> | {} | Extra environment variables. Values are rendered as templates and merged over the inherited environment. |
run.parse | raw | json | lines | raw | How stdout is interpreted when binding the step’s structured output. See the table below. |
run.timeout_seconds | integer | none | Kill the child after this many seconds. Absent means no timeout. |
run.allow_exit_codes | array<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 binds | Notes |
|---|---|---|
raw (default) | stdout as a JSON string | No interpretation. steps.<id>.output is stdout verbatim. |
json | the parsed JSON object or array — indexable | Trailing 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. |
lines | an array of strings, one per non-empty line | stdout is split on newlines and empty lines are dropped. |
Published output.
| Binding | Type | Contents |
|---|---|---|
steps.<id>.output | string | stdout 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>.json | value | stdout interpreted per parse: — indexable. Use this, not output, to reach into structured results: {{ steps.score.json.score }}. |
steps.<id>.stdout | string | The raw stdout stream, verbatim. Empty for every other step kind. |
steps.<id>.stderr | string | The raw stderr stream, verbatim. Empty for every other step kind. |
steps.<id>.exit_code | integer | The process exit code. |
steps.<id>.duration_ms | integer | Wall-clock duration of the child process. |
steps.<id>.success | bool | Whether the exit code was in allow_exit_codes. |
steps.<id>.results | array | Under 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.
- Mutually exclusive with
agent/prompt,parallel:,panel:,branch:,action:,split:, andjoin:. It is compatible withfor_each:,when:, andcontinue_on_error:. run.cmdmust not be empty, andrun.allow_exit_codesmust list at least one code.distribute:is not supported on arun:step and is rejected at parse time rather than ignored. Arun:step executes on the coordinator, and silently dropping adistribute:would let you believe work was spread across a fleet when it never left the local host. Remove it, or use an agent step.- Each
run:step writes a one-record JSONL transcript (argv, exit code, duration, stdout, stderr) so the control plane and the app have something to render where an agent step would have a conversation.
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 mode | Behaviour |
|---|---|
readonly | run: steps are refused — a command that executes is write-class. Re-run with --mode ask or --mode bypass. |
ask | Allowed — the CLI resolves ask to bypass for workflow run: steps, and announces it with the same warning agent steps print. See the note below. |
bypass | Allowed. |
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.
| Key | Type | Gate node | Inline option | Meaning |
|---|---|---|---|---|
required | bool | ignored | yes | On the inline option, true is what makes the step pause. A gate node always pauses, so the field is redundant there. |
prompt | string | optional | optional | What the operator sees. Rendered with the same context as a step prompt. Falls back to a generated "Approve gate <id>?" line. |
timeout_seconds | integer | optional | optional | How long the gate may sit unanswered before its timeout policy applies. |
auto_approve | string | optional | rejected | Minijinja expression evaluated when the gate is reached. Truthy resolves the gate as approved (via: auto) without ever pausing. |
on_timeout | approve|reject|fail | optional | rejected | What a timed-out gate resolves to. Absent means fail. Requires timeout_seconds. |
notify | array<NotifyAction> | optional | rejected | Connector calls fired best-effort when the gate parks. Failures are logged and never block the pause. |
on_reject | array<Step> | optional | rejected | Inline 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 }}."
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.
| Key | Type | Applies to | Meaning & rule |
|---|---|---|---|
id | string | all | Required. 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. |
when | string | all except branch | Minijinja 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_error | bool | all | Tolerate 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. |
actions | array<string> | all | The 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_parallel | integer | for_each, parallel, panel | Concurrency 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. |
host | string | linear only | Run this step's agent on a named fleet host. Rejected on for_each/parallel/panel/branch/action/gate nodes, alongside distribute:, and when empty. |
distribute | object | for_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. |
workspace | sync | none | remote steps | Ship 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. |
approval | object | all | Alone on a step it is a gate node; alongside agent+prompt it is the legacy inline pause. See above. |
contract | object | agent 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.
| Tool | Class | Required with: keys | What it does |
|---|---|---|---|
scm.repos.list | read | — | List repositories the authenticated user can access on a platform. |
scm.repos.get | read | owner, repo | Fetch one repository: default branch, clone URLs, visibility, description. |
scm.branches.list | read | owner, repo | List branches with name, sha, and protected flag. |
scm.branches.create | write | owner, repo, name, from_sha | Create a branch from a given SHA. |
scm.files.read | read | owner, repo, path | Read a single file at an optional ref. Returns path, ref, content, encoding. |
scm.prs.list | read | owner, repo | List pull/merge requests. Optional state, author, limit. |
scm.prs.get | read | owner, repo, number | Fetch one pull/merge request: title, body, state, head/base, author, timestamps. |
scm.prs.diff | read | owner, repo, number | Fetch the unified-diff patch plus per-file change counts. |
scm.prs.comment | write | owner, repo, number, body | Post a top-level comment on a pull/merge request. |
scm.prs.create | write | owner, repo, title, body, head, base | Open a pull/merge request. Optional draft. |
issues.list | read | project | List issues. Optional state, labels, author, limit. |
issues.get | read | project, number | Fetch one issue: title, body, state, labels, author, timestamps. |
issues.comment | write | project, number, body | Post a comment on an issue. |
issues.create | write | project, title, body | Open a new issue. Optional labels. |
issues.update_state | write | project, number, state | Transition an issue to open or closed. |
github.workflows_dispatch | write | owner, repo, workflow, ref | Trigger a GitHub Actions run. The workflow file must declare on: workflow_dispatch:. Optional inputs. |
gitlab.pipeline_trigger | write | owner, repo, ref | Trigger a GitLab CI pipeline against a branch or tag. Optional variables. |
Three things to know about with::
- Parse-time validation. Keys are checked against the tool's schema when the workflow loads. An unknown key or a missing required key is a load error, not a runtime surprise — and the check needs no credentials.
- Platform defaults. Every SCM tool takes an optional
platformkey and every issue tool an optionaltrackerkey. Omit them and rupu falls back to[scm.default]/[issues.default]from your config, exactly as the MCP tools do. - Permission mode. Action steps run under the same mode as the rest of the run. In
readonlymode a write-class action fails with an explicit permission error before the connector is ever called.askmode does not prompt per action step — the workflow author declared the call deliberately.
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).
| Expression | Resolves 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('…') · | tojson | Common 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.
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
steps:must be present and non-empty.- Step ids must be unique across the workflow.
- Unknown keys anywhere in the document are rejected — typos fail loudly instead of being ignored.
Inputs and triggers
- An input's
defaultmust coerce to its declaredtype, and must be a member of itsenumwhen one is declared. on: cronrequires a non-empty, well-formed 5-fieldcron:;on: eventrequires a non-emptyevent:.- Cross-field leftovers are rejected: no
cron:/event:/filter:on a manual trigger, noevent:/filter:on a cron trigger, nocron:on an event trigger.
Step shape
- The shape blocks are mutually exclusive.
branch:,panel:,parallel:,action:, andrun:each reject the top-levelagent/promptand each other. - A step that is none of those must supply both
agent:andprompt:. max_parallelmust be ≥ 1;panel.gate.max_iterationsmust be ≥ 1.panel.panelistsmust be non-empty; aparallel:block must be non-empty and free of duplicate sub-step ids.host:is valid only on a linear step, must be non-empty, and may not be combined withdistribute:.distribute:is valid only on afor_each:step, and itshostslist must be non-empty.distribute:is rejected on arun:step — arun:step executes locally on the coordinator, so adistribute:is refused at parse time rather than silently ignored. Remove it or use an agent step.workspace: syncis valid only on a remote step (one withhost:ordistribute:).
Run steps
run:is mutually exclusive withagent/prompt,parallel:,panel:,branch:,action:,split:, andjoin:. It is compatible withfor_each:,when:, andcontinue_on_error:.run.cmdmust not be empty.run.allow_exit_codesmust list at least one exit code — an empty list can never be satisfied.- Execution is additionally gated at run time by
[workflow] run_step_enabledandrun_step_allowlist, and refused entirely under--mode readonly. A workflow with arun:step fails when the toggle is off; it is never skipped.
Branch
conditionmust be non-empty;when:is rejected on a branch step.- Every
then/elsetarget must name an existing step, and must run strictly after the branch step. - An id may not appear in both arms, and neither arm may name the branch step itself.
Approval gates and actions
on_timeoutrequirestimeout_seconds.auto_approve,on_timeout,notify, andon_rejectare rejected on the legacy inline approval option — drop theagent/promptto make it a gate node.on_rejectentries must be plain agent or action steps — no nested gate, fan-out, panel, or branch — and may not sethost:orwhen:.action:must name a tool that exists in the catalog;with:must be a mapping whose keys are all schema properties, with every required property present. Gatenotifyentries are validated by the same rules.
Templates and contracts
- A
steps.<id>reference to a step that does not exist is an error. - A reference to a step that runs later is a forward reference and is rejected — its value is not bound yet.
- A
steps.<id>.<field>reference must use a known field:output,success,skipped,results,sub_results,findings,max_severity,iterations,resolved,decision, and therun:step fieldsjson,stdout,stderr,exit_code,duration_ms. contracts.outputs.*.from_stepmust name an existing step, and must agree with that step's owncontract:on bothschemaandformat.autoflow.outcome.outputmust name a declared workflow output; autoflow durations must look like<int><s|m|h|d>.
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)"