Autoflows
An autoflow is a workflow that runs itself: an event wakes it, it claims a unit of work — an issue or a pull request — runs the workflow in a durable worktree, and opens a PR or posts a review. No human kicks it off.
What is an autoflow
A workflow answers two questions: how does one run execute and when may it start. An autoflow answers the questions a workflow can't: which issue is already claimed, who owns the repo when you're not standing in it, how do we revisit an issue after a PR merge or a backoff timer, and how do we keep one durable branch per issue across many runs.
The key design choice: there is one workflow YAML language. An autoflow is not a new file type and not a second
step DSL — it is an ordinary workflow file plus an optional top-level autoflow: block. The same file runs
manually through rupu workflow run or autonomously through the rupu autoflow … command family.
workflow stays the unit of execution; autoflow is the persistent, lifecycle-oriented runner for it.
Triggers
Any workflow can declare a top-level trigger: block that decides when a one-shot run may fire.
The trigger.on: field takes one of three values.
| Trigger | When it fires | Mechanism |
|---|---|---|
manual |
You run rupu workflow run <name> |
The default. Every install. No scheduler needed. |
cron |
The schedule in trigger.cron: (5-field UTC) matches |
System cron / launchd invokes rupu cron tick. There is no rupu daemon — the OS scheduler drives it. |
event |
A matching SCM event appears | Polled tier via rupu cron tick, and/or the live tier via rupu webhook serve (HMAC-validated). |
Cron
You install one crontab / launchd entry that runs rupu cron tick every minute. The tick walks every
cron-triggered workflow and fires those whose schedule matched between the persisted last_fired timestamp and now —
idempotent at one-minute granularity. rupu cron list is read-only and prints every cron workflow plus its next
firing time; --dry-run verifies a crontab line without firing anything.
# one line in your crontab drives every cron + event trigger
* * * * * /usr/local/bin/rupu cron tick
Events — two tiers, no double-firing
rupu reacts to SCM events two ways, and you can run both at once:
-
Polled (CLI-native). The same
rupu cron tickcalls each configured connector for new events between ticks. Configure sources in[triggers].poll_sources(empty by default — rupu polls nothing until you ask). Shipped connectors: GitHub repo feeds, GitLab repo feeds, Linear team feeds (linear:<team-id>), and Jira project feeds (jira:<site>/<project>). Latency is one tick interval. -
Webhook (live). Run
rupu webhook serveas a long-lived process under your own supervisor for sub-second latency and broader event coverage. The receiver validates each delivery's signature (X-Hub-Signature-256for GitHub,X-Gitlab-Tokenfor GitLab,Linear-Signaturefor Linear,X-Hub-Signaturefor Jira Cloud), maps the raw delivery onto the rupu event id, and fires matching workflows.
Webhook secrets are read from environment variables only — never config files, never the keychain. Bind to 127.0.0.1
and front with a TLS-terminating reverse proxy in production; rupu does not terminate TLS itself.
# split the tiers across different cadences if you like * * * * * rupu cron tick --skip-events # cron only, every minute */5 * * * * rupu cron tick --only-events # events every 5 minutes # or run the live receiver under your supervisor RUPU_GITHUB_WEBHOOK_SECRET=<secret> rupu webhook serve --addr 0.0.0.0:8080
Writing an autoflow
An autoflow file is a workflow file. You write the same name:, the same
steps:, and add one top-level autoflow: block that marks the file as autonomously
runnable. The runtime owns only the autonomous concerns — candidate discovery, claims, repo-to-path resolution, worktree lifecycle,
scheduling/retries, and structured outcome handling. Repo-specific reasoning stays in the workflow steps:.
The smallest valid autoflow is a name:, autoflow: { enabled: true }, and one step.
Everything else below narrows what it claims, when it wakes, where it works, and what it hands back.
Where autoflow files live
Autoflow files are ordinary workflow files, so they live in the usual two places: ~/.rupu/workflows/
(global) and <repo>/.rupu/workflows/ (project). Discovery for an autonomous tick is not
cwd-rooted — it walks the global workflow directory and every preferred repo checkout recorded in the
global repo registry, so reconciliation keeps working when you're not standing in the repo.
rupu autoflow create <name> scaffolds a file with an autoflow: block, opens it in
your editor, and re-parses it on save.
rupu autoflow create phase-ready --scope project # writes .rupu/workflows/phase-ready.yaml rupu autoflow list # everything with autoflow.enabled = true rupu autoflow show phase-ready # the file + its resolved metadata
The autoflow: block, field by field
Every field is optional except enabled. The block rejects unknown keys — a typo like
labels_alll: is a parse error, not a silently ignored line.
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false |
Marks this workflow as autonomously runnable. Anything else in the block is inert until this is true; the CLI refuses the file with “workflow … is not autoflow-enabled”. |
entity | issue | pull_request | issue |
The entity type this autoflow owns. issue claims issues from an issue tracker; pull_request claims PRs and unlocks the PR-only selector fields. |
source | string | inferred from the repo binding | Explicit event/discovery source when the work doesn't live in the repo itself — linear:<team-id>, jira:<site>/<project>, github:owner/repo, gitlab:group/project. Omitted, discovery and wake matching run against the repo this workflow file is bound to. |
priority | int | 0 |
Match precedence when several autoflows select the same item; the highest wins the claim, ties break on the workflow name that sorts first. Negative values are legal. |
selector | block | empty (matches everything of that entity) | Candidate filter — states, labels, authors, and PR-only draft/base narrowing. Fields below. |
wake_on | list of strings | [] |
Event ids that mark a candidate dirty and make it due for reconciliation. Same vocabulary and same * glob matching as triggers. Empty means no autoflow polls events for this file at all — it only becomes due on first sight, on reconcile_every, or on a retry backoff. |
reconcile_every | duration string | unset (never due on a timer) | Maximum time between reconciliations of an already-claimed item, measured from the claim's updated_at. |
claim | block | unset (key: issue, no lease TTL) |
The claim/lease policy that stops two workers from grabbing the same item. Fields below. |
workspace | block | unset (falls back to [autoflow].checkout, itself worktree) |
Where the run's checkout comes from and what branch it sits on. Fields below. |
outcome | block | unset (no structured outcome consumed) | Names the declared workflow output the runtime should parse to decide what happens next. Fields below. |
reconcile_every and claim.ttl take a compact relative duration: digits
followed by exactly one unit of s, m, h, or d — 30s, 10m,
3h, 7d. Compound forms like 1h30m and bare numbers are rejected at parse time.
A complete autoflow, commented
One file, top to bottom: what it claims, when it wakes, where it works, what it produces. Every value here is a real field — copy it and change the labels.
name: phase-ready-autoflow description: Implement the ready phase for a labelled issue, then open a draft PR. autoflow: enabled: true # required — without it nothing autonomous happens entity: issue # issue | pull_request # source: linear:<team-id> # omit to bind to this file's repo priority: 200 # highest priority wins the claim on a contested item selector: # which issues this autoflow may claim states: ["open"] labels_all: ["autoflow", "phase:phase-1"] limit: 100 # cap per discovery query wake_on: # event ids that mark a candidate dirty - github.issue.labeled - github.pull_request.closed - github.pull_request.reopened reconcile_every: "10m" # ...and revisit at least this often anyway claim: key: issue # issue | pr_head_sha ttl: "3h" # ownership lease, not just retry timing workspace: strategy: worktree # durable per-issue worktree, never a temp clone branch: "rupu/issue-{{ issue.number }}" # the one templated autoflow field outcome: output: result # must name a key under contracts.outputs contracts: # the machine-readable handback outputs: result: from_step: result format: json schema: autoflow_outcome_v1 steps: - id: implement agent: repo-implementer actions: [] prompt: | Implement the ready phase for issue #{{ issue.number }} ({{ issue.ref }}). Title: {{ issue.title }} Labels: {{ issue.labels | join(", ") }} {{ issue.body }} Keep the change scoped to that one phase. End with branch name, changed files, and a validation summary. - id: open_pr agent: pr-author actions: [] prompt: | Open a draft PR for issue #{{ issue.number }}. {{ steps.implement.output }} - id: result # the step contracts.outputs.result reads agent: writer actions: [] contract: emits: autoflow_outcome_v1 format: json prompt: | Return only valid JSON for `autoflow_outcome_v1`. Use status "await_human" when the PR is ready for human review, "blocked" when it needs manual intervention.
An optional sibling contracts: block declares the workflow's machine-readable output — the
from_step it comes from, its format, and the JSON Schema it must validate against (for example
autoflow_outcome_v1). The runtime parses that structured outcome to decide what happens next; it never parses prose.
rupu workflow run and the steps: execute normally while the
autoflow: metadata is ignored except for contract validation. Run through rupu autoflow … and the runtime
activates claim, worktree, outcome, and retry behavior.
Sub-block: selector:
The selector decides which items this autoflow is allowed to claim. An empty selector matches every item of the
declared entity, which is almost never what you want — start from states plus a
label convention. Part of the selector is pushed to the connector as a server-side query
(states when it names exactly one state, labels_all, limit);
the rest is applied locally after the fetch, because the issue connectors only support a conjunctive label filter.
| Field | Type | Default | Meaning |
|---|---|---|---|
states | list of open / closed | [] — any state | Item state must be one of these. A single-element list also narrows the connector query itself. |
labels_all | list of strings | [] | Every one of these labels must be present. This is the set sent to the connector as the server-side narrowing filter. |
labels_any | list of strings | [] | At least one of these labels must be present. Applied locally after the fetch. |
labels_none | list of strings | [] | None of these labels may be present — the standard way to make an item drop out of the loop once it's been handled. Applied locally. |
limit | int | unset — connector default | Cap on how many candidates one discovery query returns. |
draft | include | exclude | only | unset — both | PR only. exclude skips drafts, only matches nothing but drafts. Setting it on entity: issue is a parse error. |
base | string | unset — any base | PR only. Restrict to pull requests targeting this base branch, e.g. main. Setting it on entity: issue is a parse error. |
authors | list of logins | [] | Explicit author allowlist. A login in this list is always allowed, whatever authors_from says. |
authors_from | collaborators | org_members | unset | Broader author scope — repo collaborator, or member of the owning org. The SCM connector resolves the membership at tick time (and caches it per tick). |
on_skip | skip | label_needs_human | skip | What to do when an otherwise-eligible item is excluded solely by the author gate. |
The author gate resolves in one pass: an explicit authors match wins outright; otherwise, if
authors_from is set, the item is allowed only if the connector confirms the scope; if both fields are unset
there is no author restriction at all; and a non-empty authors list with no match and no
authors_from denies. That last rule is what makes an autoflow safe to point at a public repo — a drive-by
issue from a stranger never reaches an agent.
on_skip: label_needs_human is PR-only today. On a pull request rupu applies a
needs-human label before skipping. On an issue there is no label-add API on the issue connector, so rupu logs a warning
and falls back to a plain skip. Don't rely on the label appearing on issues.
Sub-block: claim:
A claim is rupu's answer to “who owns this issue right now.” Before touching an item the runtime writes one claim file per owned
entity under ~/.rupu/autoflows/claims/ recording the workflow, the repo, the branch, the worktree path, the
last run id, the claim status, an owner id (<user>:pid-<n>), and a lease expiry.
| Field | Type | Default | Meaning |
|---|---|---|---|
key | issue | pr_head_sha | issue | What the claim is keyed on. issue gives one durable claim per issue that survives many runs. pr_head_sha scopes the claim to a pull request at one head SHA, so a new push is genuinely new work rather than a re-entry into the old claim. |
ttl | duration string | unset — no lease expiry written | How long the ownership lease is valid. This is a lease, not a retry timer: it is what lets another worker eventually take over an item abandoned by a crashed process. |
Duplicate claiming is prevented by lease + lock together, not by either alone:
- Each claim carries an owner id and a lease expiry derived from
claim.ttl. - An active cycle holds an exclusive lock file for the claim and renews the lease while it runs.
- A second process may steal a claim only when the lease has expired and no active lock is present.
- Claims parked in
await_humanorawait_externalkeep the lease — they stay owned — but they release the active-cycle lock, so the item isn't held hostage by a stuck process. - Release is explicit: a terminal outcome, or an operator running
rupu autoflow release <ref>.
Sub-block: workspace:
Autoflows deliberately do not use the throwaway temp clone that a one-shot workflow run against a
remote target uses. Autonomous work needs a checkout that survives between runs so an issue can be resumed, retried, and pushed from
the same branch a week later.
| Field | Type | Default | Meaning |
|---|---|---|---|
strategy | worktree | in_place | worktree |
worktree creates (or reuses) a durable git worktree per entity under ~/.rupu/autoflows/worktrees/<repo-slug>/<entity>/, branched from the preferred checkout's HEAD. in_place runs directly in the repo's preferred checkout — no worktree, no isolation. |
branch | templated string | derived from the entity | The branch the worktree sits on, e.g. rupu/issue-42. This is the only template-rendered field in the whole autoflow: block. |
Prefer worktree: it never mutates your main working tree, gives one issue exactly one durable branch,
makes cleanup explicit, and lets several issues be owned concurrently in the same repo. Reach for
in_place only when a checkout is dedicated to the autoflow and you actively want the changes in the tree you
look at. If you omit the workspace: block entirely, the strategy falls back to
[autoflow].checkout in config (worktree by default), and the worktree location to
[autoflow].worktree_root.
entity: pull_request the
branch is always derived as rupu/pr-<number>-<short-head-sha> so a re-review after a push can never collide with
the previous review's worktree — workspace.branch is not consulted on that path.
Sub-block: outcome:
| Field | Type | Default | Meaning |
|---|---|---|---|
output | string (required inside the block) | — | Names a key under contracts.outputs. The runtime parses that step's declared output and uses it to decide the claim's next state. Naming an output that isn't declared in contracts: is a parse error, so a typo fails the file rather than silently disabling the loop. |
The canonical schema is autoflow_outcome_v1. The runtime reads these keys from it:
status (one of continue, await_human,
await_external, retry, blocked,
complete), summary, pr_url,
retry_after, and an optional dispatch object naming a child workflow.
// what the final step must emit — JSON, never prose { "status": "await_human", "summary": "Draft PR opened and panel findings addressed", "pr_url": "https://github.com/org/repo/pull/123", "retry_after": "30m", "dispatch": { "workflow": "phase-delivery-cycle", "target": "github:org/repo/issues/42", "inputs": { "phase": "phase-2" } } }
A requested dispatch is not executed inline. It is persisted onto the claim and picked up on the
next reconciliation cycle — that's what keeps dispatch idempotent and crash recovery boring.
What your prompts can see
An autoflow binds the claimed entity into the step context, so prompts can address it directly. For
entity: issue you get {{ issue.* }}:
number, ref, title, body,
state, state_name, labels,
author, project, tracker,
url, created_at, updated_at. For
entity: pull_request you get the event shape instead —
{{ event.pull_request.number | title | base | head | head_sha | author | url | diff }} and
{{ event.repository.full_name }}. The diff is truncated to a byte cap
with a trailing note, so a huge PR degrades instead of blowing the context.
prompt: |
Triage issue #{{ issue.number }} ({{ issue.ref }}).
Title: {{ issue.title }}
Author: {{ issue.author }}
Labels: {{ issue.labels | join(", ") }}
{{ issue.body }}
Waking an autoflow: trigger: vs wake_on:
An autoflow does not need a trigger: block, and the two do different jobs. A
trigger: means dispatch this workflow now — a one-shot run. A wake_on: entry
means mark this item dirty and reconcile it on the next cycle. Same event vocabulary, same glob matching, same ingestion
path; different semantics on the other end. See Triggers for the full event catalogue.
| What fires | Who feeds it | What it does to an autoflow |
|---|---|---|
trigger.on: cron |
rupu cron tick from your OS scheduler |
Fires a normal one-shot run of the same file. It does not drive the autoflow loop — rupu autoflow tick does. |
trigger.on: event |
Polled connectors, or rupu webhook serve |
Fires a one-shot run per matching delivery, with filter: narrowing it. |
autoflow.wake_on |
The same shared event ingress — no second poller, no second cursor store | Queues a wake for the matching item. On the next autoflow cycle that item is due, and the claim advances. |
autoflow.reconcile_every |
The autoflow cycle itself | Makes a claimed item due once this long has passed since the claim was last updated — the safety net for events you never receive. |
In practice: cron drives ingestion and cadence, wake_on: decides which items got interesting,
and reconcile_every: guarantees forward progress even when nothing at all arrives. An autoflow with an empty
wake_on: is legal — it simply never polls events and relies on first sight, the reconcile interval, and retry
backoff.
Lifecycle of one claim
One reconciliation cycle, start to finish. Everything here is idempotent — two cycles racing each other must not duplicate ownership or dispatch.
- Wake. A cycle starts —
rupu autoflow tickfrom launchd / a systemd timer / Task Scheduler, or a long-livedrupu autoflow serveworker. Enabled autoflows are discovered and each one's repo binding is resolved from the repo registry. - Select. Candidates are fetched from the tracker or SCM using the server-side part of
selector:, then narrowed locally by the rest — labels, draft/base, and the author gate. - Resolve precedence. Every autoflow whose
entityandselectormatch is evaluated; highestprioritywins, ties break on the workflow name that sorts first. Only the winner may hold the claim. - Claim. Acquire the claim lock, then write or renew the claim record with an owner id and a lease expiry from
claim.ttl. - Check due. An item is due on first sight, on a matching
wake_on:event, whenreconcile_everyhas elapsed, or when a retry backoff expired. Not due means the claim is kept and nothing runs. - Prepare the workspace. Create or reuse the durable worktree on
workspace.branch, or use the preferred checkout forin_place. - Run the steps. The ordinary workflow engine, with the entity bound into the step context. A step
approval:gate sets the run toawaiting_approvaland the claim toawait_human— the item stays owned while it waits for a person. - Read the outcome. Parse and validate the document named by
outcome.outputagainst its declared contract. - Deliver and record. The PR or comment itself came from a step's own tools; the runtime records the PR URL and summary onto the claim, sets the next claim status, schedules any retry, and persists a requested child dispatch for the next cycle.
- Release or retain. A terminal outcome releases the claim; anything else retains it with its lease so the next
cycle resumes exactly where this one stopped.
rupu autoflow release <ref>force-releases a stuck one.
Idempotency & ownership
Autoflows are unattended, so the whole tick algorithm is idempotent: running two ticks close together must never duplicate ownership or dispatch.
-
Deterministic run-ids. A polled or webhook delivery yields a deterministic run-id of the shape
evt-<workflow>-<vendor>-<delivery>. That is what lets the polled and webhook tiers process the same logical event without firing it twice. Event ingestion is at-most-once by design: if a process crashes after the cursor advances, the event is dropped rather than re-run, because re-firing a triage workflow is worse than missing one event during a crash. - One claim per issue. Before working an issue the runtime acquires an exclusive claim. Each claim records an owner id and a lease expiry, and the active cycle holds a lock file and renews the lease while it runs. A second process may steal only an expired claim whose active lock is absent — so two autoflows can never grab the same issue at once.
-
Deterministic precedence. It is legal for more than one autoflow to match an issue. v1 resolves it by evaluating
every autoflow whose
entityandselectormatch, choosing the highestpriority, and breaking ties by the workflownamethat sorts first lexicographically. Only the winner may hold the claim — no hidden first-match behavior. - Deferred dispatch. When an outcome asks to dispatch a child workflow, the request is persisted onto the claim and picked up on the next reconciliation cycle rather than run inline. That keeps dispatch idempotent and crash recovery simple.
Claim state lives under ~/.rupu/autoflows/ (one file per owned entity in claims/, durable per-entity
worktrees/, supervisor logs/), separate from the run's own RunStatus. A workflow paused at an
approval: gate maps the run to awaiting_approval and the claim lifecycle to await_human,
so the issue stays owned while it waits.
Event vocabulary
Both trigger tiers and wake_on: share one normalized event vocabulary. The polled connector lifts events from each
vendor's API and maps them onto canonical rupu ids, then derives broader semantic aliases on top.
- Canonical ids name a specific vendor delivery:
github.issue.opened,github.issue.labeled,github.pr.merged,github.push,gitlab.issue.opened,gitlab.mr.merged. - Semantic aliases express intent across deliveries and vendors:
issue.queue_entered,issue.queue_changed,pr.review_activity. They are matchable but never replace canonical ids. - Glob matching.
*matches any sequence of characters and does not special-case.boundaries:github.issue.*matches every GitHub issue event,*.pr.mergedmatches across vendors, and*wakes on anything.
Inside step prompts and the trigger filter: expression, the matched event is bound as {{ event.* }} —
for example {{ event.repo.full_name }}, {{ event.payload.issue.number }}, and
{{ event.canonical_id }}. filter: is the same minijinja you'd write in a step when:, evaluated at
match time against the event payload, and must render to true or false.
Operating autoflows
v1 is idempotent and tick-based — there is no mandatory daemon. An OS scheduler (launchd on macOS, a systemd --user
timer on Linux, Task Scheduler on Windows) invokes rupu autoflow tick periodically; each tick discovers enabled
autoflows, reconciles eligible issues, and exits. The rupu autoflow … family is the operator surface:
| Command | Purpose |
|---|---|
rupu autoflow create [name] | Scaffold a new autoflow-enabled workflow YAML and open it for editing. --scope global|project, --editor. |
rupu autoflow list | List workflow files that declare autoflow.enabled = true. --repo. |
rupu autoflow show <name> | Print a workflow and its resolved autoflow metadata. --repo. |
rupu autoflow run <name> <target> | Run one autonomous cycle for one target (e.g. github:owner/repo/issues/42), bypassing discovery. --repo, --mode bypass|readonly. |
rupu autoflow tick | Discover and reconcile every enabled autoflow once, then exit. The primary tick runtime. |
rupu autoflow serve | Run the reconciler as a long-lived local worker with a live operator view. --repo, --worker, --idle-sleep 10s, --view focused|compact|full, --quiet. |
rupu autoflow stop | Stop a running local serve worker. --worker, --repo. |
rupu autoflow status | Summarize active / waiting / retrying / complete claims. --repo. |
rupu autoflow claims | Inspect persisted claims directly (subject, source, repo, branch, PR, status). --repo. |
rupu autoflow wakes | Inspect queued and recently processed wakes. --repo. |
rupu autoflow monitor | Read-only operator view across workers, claims, wakes, and recent activity. --watch, --interval 2s, --view, --repo, --worker. |
rupu autoflow history [ref] | Durable cycle and event history. --repo, --source, --worker, --event, --limit 50, --watch, --interval, --view. |
rupu autoflow explain <ref> | Explain the current autonomous state for one issue. --repo. |
rupu autoflow doctor | Run consistency checks across claims, wakes, and runs. --repo. |
rupu autoflow repair <ref> | Apply safe, bounded remediation to one claim. --release, --requeue. |
rupu autoflow requeue <ref> | Enqueue one manual wake. --event <id> overrides the synthetic event id, --not-before 10m delays visibility. |
rupu autoflow release <ref> | Force-release a stuck claim. |
# the operator's usual three rupu autoflow status --repo github:Section9Labs/rupu rupu autoflow monitor --watch --interval 2s rupu autoflow history github:Section9Labs/rupu/issues/42 # something is stuck — diagnose, then remediate rupu autoflow explain github:Section9Labs/rupu/issues/42 rupu autoflow doctor rupu autoflow repair github:Section9Labs/rupu/issues/42 --release --requeue
A repo-local [autoflow] config section tunes the runtime around the workflow files:
checkout and worktree_root set the workspace fallback,
max_active caps how many claims one repo may hold at once, permission_mode sets the
unattended permission posture, strict_templates controls undefined-variable strictness, and
cleanup_after ages out terminal claims and their worktrees.
Event ingestion is shared with triggers: rupu cron tick drives both cron-scheduled fires and polled events
(rupu cron list / rupu cron events inspect what's wired), and rupu webhook serve feeds the live
tier. Autoflows consume those events as reconciliation hints via wake_on: rather than as direct one-shot
dispatch — they don't get a second polling config or a second cursor store.