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.
| 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, findings_profile). |
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. Fields in the autoflow block; behaviour on 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 the concerns block. |
messaging | bool or { tools: [...] } | no | on | The 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. |
loops | map<string, LoopDef> | no | {} | Named, bounded loops over a subgraph of existing steps. See Graph orchestration. |
max_concurrency | integer | no | unbounded | Cap 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
| 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). |
findings_profile | full | summary | The 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
| 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.
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:
| Value | Effect |
|---|---|
absent, or true | The default set above. true is the default written down. |
false | No 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.
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Marks the workflow as autonomously runnable. Everything else in the block is inert until this is true. |
entity | issue | pull_request | issue | The kind of item this autoflow owns. pull_request unlocks the PR-only selector fields. |
source | string | none | Free-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. |
priority | integer | 0 | When several autoflows select the same item, the highest priority wins the claim; ties go to the workflow name. |
selector | object | empty (matches every item) | Candidate filter. See the selector table below. |
wake_on | array<string> | [] | Event ids that mark a candidate dirty and due for reconciliation. Same vocabulary and * glob matching as triggers. |
reconcile_every | duration | unset | Maximum time between reconciliations of an already-claimed item. |
claim.key | issue | pr_head_sha | issue | What a claim is keyed on: one durable claim per issue, or one per pull request at a given head SHA. |
claim.ttl | duration | unset (no lease expiry) | How long the ownership lease is valid before another worker may take the item over. |
workspace.strategy | worktree | in_place | worktree | A durable per-item git worktree, or the repo's own checkout. With no workspace: block at all, [autoflow].checkout in config decides. |
workspace.branch | template string | derived from the entity | The branch the worktree sits on. The only template-rendered field in the block (rendered strictly). Not consulted for entity: pull_request. |
outcome.output | string | — (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
| Key | Type | Default | Meaning |
|---|---|---|---|
states | array of open | closed | [] (any state) | The item's state must be one of these. |
labels_all | array<string> | [] | Every listed label must be present. Sent to the connector as the server-side narrowing filter. |
labels_any | array<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_none | array<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. |
limit | integer | none | Candidate cap per reconciliation cycle. |
draft | include | exclude | only | unset (behaves like include) | Pull requests only — a parse error on entity: issue. |
base | string | unset (any base) | Pull requests only: restrict to PRs targeting this base branch. A parse error on entity: issue. |
authors | array<string> | [] | Explicit author-login allowlist. A listed login is always allowed. |
authors_from | collaborators | org_members | unset | Broader author scope, resolved by the SCM connector at tick time. |
on_skip | skip | label_needs_human | skip | What 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.
| 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 | One connector or findings tool call through the tool catalog. No model turn, 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. |
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:.
| 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. 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.
| 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; 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_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: — 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".
| Key | Type | Required | Meaning |
|---|---|---|---|
action | string | yes | A 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. |
with | mapping | no | Parameters 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.
action:must name a real tool in the catalog. An unknown name is a parse error.- The tool must be one an action step may call: not a core fs/shell tool, not one that starts another run, and needing only the services an action step provides (the SCM registry and the findings ledger). Anything else —
bash,dispatch,coverage.mark,assets.mark,board.post, … — is a parse error. with:must be a mapping. An unknown key, or a missing required key, is a parse error before the run ever starts.- Mutually exclusive with
agent/prompt,for_each:,parallel:,panel:, andbranch:. - An action step must not carry a non-empty
actions:list.actions:narrows an agent’s connector grant; an action step’s call is already explicit, so there is nothing to narrow. An empty or absentactions:is legal, if redundant. findings_profileis accepted only onaction: findings.record; see Findings profile.
Typed values. A with: value is coerced against the tool’s declared schema type:
- a plain literal against an
integer/number/booleanfield with stray quotes (number: "7") is rejected at parse time — almost always a typo; - a whole-leaf template (the entire string is one
{{ … }}expression) is rendered, then parsed against the declared type; a render that doesn’t fit is a runtime error; - a partial template mixed with other text (
"issue-{{ n }}") against a numeric field is a runtime error rather than a guess — use a whole-leaf template ("{{ n }}") for a typed parameter.
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.
| 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 | yes | yes | true is what makes the step pause. Every documented gate node sets it too. |
prompt | string | optional | optional | What the operator sees. Rendered with the same context as a step prompt; without it rupu generates a line naming the step. |
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; the notify hooks do not fire on auto-approve. |
on_timeout | approve|reject|fail | optional | rejected | What a timed-out gate resolves to. Absent means fail. Requires timeout_seconds. |
notify | array<action hook> | optional | rejected | action: hooks fired best-effort just before the gate parks. A failure is logged and never blocks the pause. |
on_reject | array<Step> | optional | rejected | A 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 }}."
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> | agent steps | Narrows 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. |
skills | array<string> | agent steps | Skills every agent this step launches may load, on top of its own. Same grammar as an agent’s skills:. See Step skills. |
preload_skills | array<string> | agent steps | Skills whose bodies go into every launched agent’s system prompt. Same rules as skills. |
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. |
findings_profile | full | summary | agent steps, action: findings.record | The 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. |
next | array<string> | all | Successor step ids. Using it makes the workflow a graph; see below. |
depends_on | array<string> | all | Predecessor 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.
| Rule | What it means |
|---|---|
| Empty means unchanged | actions: [], or no key, leaves the agent’s grant exactly as declared. Existing workflows keep working. |
| Connector tools only | Built-ins (bash, read_file, ast_grep, …) are never removed, whatever the list says. |
| No escalation | The 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 children | The 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 load | An 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:
| Field | True when |
|---|---|
restricted | The step declared a non-empty actions: list. |
declared | The step’s actions: names this tool. Always false on an unrestricted step, which is not a violation — check restricted. |
granted | The agent’s tools: covers this tool, before narrowing. Always true for an action: node. |
blocked | The 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
- They reach every agent the step launches: its own agent, each
for_eachunit, the members of aparallel:block, and a panel’s panelists and its gate’sfix_withfixer. A sub-step has noskills:of its own — it takes its parent step’s. - Allowed only on a step that runs an agent. A gate,
action:,run:,branch:,split:orjoin:step that names them fails to parse. - Parsing checks each entry’s syntax only. Whether the skill is installed is checked when the step launches, so a missing skill fails that step before its agent runs, the way a missing agent does. The run’s
skill_indexrecords step skills with reasonlaunch. - A placed step (
host:ordistribute:) ships its skills with the unit; see Remote placement. - A child the agent starts with
dispatchgets its own agent’s skills, not the step’s.
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.
| Mode | Workflow agent steps | Action steps |
|---|---|---|
bypass | all tools allowed | all catalog tools allowed |
ask | same as bypass — no prompt is possible | same as bypass |
readonly | write and external tools denied (bash, write_file, edit_file, scm.prs.create, …); sub-agents run readonly | external 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
parallel:sub-steps inherit their parent's profile and can't set their own.- An
action: findings.recordstep resolves its profile as the step’s ownfindings_profile→defaults.findings_profile→full(there is no agent frontmatter to consult), and checks itswith:keys against it at parse time:fullneedsscopeandreport;summaryneedsscope,summary,severityandrationale(optionalcode_excerpt/references; anevidenceobject, the agent tool’s form, works in place of the three). Only key presence is checked, so a value may be a template. A gate’snotify:hooks have no step of their own and usedefaults.findings_profile, elsefull. - Children started with
dispatchresolve only from their own agent file (a workflow child from its own defaults); the dispatching step’s value does not reach them. - Remote steps follow the same order: the coordinator launches each unit with
rupu run --findings-profilefrom the step’s value, elsedefaults.findings_profile; when neither is set the host resolves the agent’sfindingsProfilefrom its own copy of the agent file, thenfull. A host that can't honour the profile refuses the launch — the unit never runs under a different profile. - The built-in default is
full, so an olderaction: findings.recordstep that sendssummary/severity/rationalefails to parse until it setsfindings_profile: summary(or the workflow setsdefaults.findings_profile: summary).
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.
| Key | Applies to | Meaning & rule |
|---|---|---|
host | linear step only | Run 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:. |
distribute | for_each only | { hosts: [...] } — the items are spread round-robin across the listed hosts. hosts must be non-empty; rejected outright on a run: step. |
workspace | remote steps | sync 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:
- Permission mode —
rupu run --mode <mode>, which outranks the agent’spermissionModeand the host’spermission_modeconfig.readonlyandbypasstravel as they are;ask, the default, travels asbypass(a detachedrupu run --mode askwould refuse to start).--mode readonlyis how you keep a placed unit from writing. - Findings profile — as
--findings-profile; see Findings profile. Engagement profile ids travel as--engagement-profile; a host that doesn’t advertiseagent.engagement_profileis refused the launch, and the host resolves the ids against its own profiles. - Skills — the coordinator resolves the unit’s skills the way a local run does (the
skills:/preloadSkills:of its own copy of the agent plus the step’sskills:/preload_skills:;*means the coordinator’s global layer), packs each as a tar named by its content hash, asks the host which hashes it lacks and sends only those. The host verifies every archive before keeping it (no absolute or..paths, no symlink leaving the skill, the tree must hash to its name) and runs the unit with exactly those copies, so it needn’t have them installed; a shipped skill replaces the host’s installed one of the same layer and name. A skill tree is at most 32 MiB and a unit’s bundle 128 MiB — the coordinator refuses a unit over either limit before dispatch. A step skill not installed on the coordinator fails the unit before it is dispatched. A host that doesn’t advertiseagent.skill_bundleis sent the unit by name and runs it with its own installed copies, and the run gets a warning naming the host. Only the unit’s own skills ship: children it launches on the host resolve from the host’s installed layers. See Skills. - Messaging — the workflow’s
messaging:switch, as--no-messagingor one--messaging-toolper tool; a host that doesn’t advertiseagent.no_messaging/agent.messaging_toolsis refused the unit. See the messaging key. - Not
actions:— a non-emptyactions:on ahost:/distribute:step is rejected at parse time: the tool roster never reaches the remote dispatch payload, so a narrowed list there would be a silent no-op. An empty or absentactions:stays legal.
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]
| Construct | Meaning & 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_concurrency | Whole-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.
| 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.comments | read | project, number | Read 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.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. |
findings.record | record | scope (+ 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.verify | record | see the tool’s schema | Set 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.query | read | q | The 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.tag | record | finding_ids | Add 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::
- Parse-time validation. Keys are checked against the tool's JSON Schema when the workflow loads. An unknown key or a missing required key is a load error, not a runtime surprise. Typed values follow the rules under the action step.
- Permission mode. Action steps run under the same mode as the rest of the run (Permission modes). In
readonlymode anexternaltool — one that acts outside this machine, such asscm.prs.create,issues.commentorgithub.workflows_dispatch— is refused.askbehaves asbypass: nothing prompts per action step.
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>.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 }} · .converged | A 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. |
| fromjson | The 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('…') · | 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 and is action-eligible (a connector or findings tool — not a core tool, not one that starts a run);with:must be a mapping whose keys are all schema properties, with every required property present, and a quoted literal against a typed field is rejected. Gatenotifyentries are validated by the same rules.- A non-empty
actions:is rejected on anaction:step and on ahost:/distribute:step; anactions:entry must name a connector tool (an unknown name, orbash/findings.*, is a parse error).
Skills and messaging
skills:/preload_skills:are accepted only on a step that runs an agent; each entry must be well-formed (the agentskills:grammar). Whether a skill is installed is checked at launch, not at parse.- A sub-step of a
parallel:block accepts noskills:of its own. messaging: { tools: [...] }may name onlyboard.*/msg.*tools.
Graph and findings profile
- Every
next/depends_on/splittarget must name a real step, and a step can't point at itself. - The full edge set — control edges plus the data edges implied by template references — must be acyclic.
split:andjoin:nodes may not carryagent,action,for_each,parallel,panel,branchorapproval. Ajoin:needs an inbound edge;wait.countcan't exceed its inbound paths.loops.*.nodesmust name ≥ 2 existing steps, none in two loops;max_iterationsandmax_concurrencymust be ≥ 1.findings_profileis rejected on a step that runs no agent (branch, gate,run,split,join) and on any action other thanfindings.record, whosewith:keys must match the resolved profile.
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: [] 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"