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.

trigger cron / event claim issue lock + worktree run workflow agents + steps open PR / comment reconcile on the next tick — the claim persists across runs
The autoflow loop: wake, claim, run, deliver — then reconcile again on the next tick.

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.

TriggerWhen it firesMechanism
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:

One logical event fires once. The deterministic run-id lets the polled tier and the webhook tier coexist on the same workflow without double-firing the same delivery — see Idempotency & ownership below.

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.

FieldTypeDefaultMeaning
enabledboolfalse 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”.
entityissue | pull_requestissue The entity type this autoflow owns. issue claims issues from an issue tracker; pull_request claims PRs and unlocks the PR-only selector fields.
sourcestringinferred 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.
priorityint0 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.
selectorblockempty (matches everything of that entity) Candidate filter — states, labels, authors, and PR-only draft/base narrowing. Fields below.
wake_onlist 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_everyduration stringunset (never due on a timer) Maximum time between reconciliations of an already-claimed item, measured from the claim's updated_at.
claimblockunset (key: issue, no lease TTL) The claim/lease policy that stops two workers from grabbing the same item. Fields below.
workspaceblockunset (falls back to [autoflow].checkout, itself worktree) Where the run's checkout comes from and what branch it sits on. Fields below.
outcomeblockunset (no structured outcome consumed) Names the declared workflow output the runtime should parse to decide what happens next. Fields below.
Duration grammar. reconcile_every and claim.ttl take a compact relative duration: digits followed by exactly one unit of s, m, h, or d30s, 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.

Two modes, one file. Run through 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.

FieldTypeDefaultMeaning
stateslist of open / closed[] — any stateItem state must be one of these. A single-element list also narrows the connector query itself.
labels_alllist 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_anylist of strings[]At least one of these labels must be present. Applied locally after the fetch.
labels_nonelist 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.
limitintunset — connector defaultCap on how many candidates one discovery query returns.
draftinclude | exclude | onlyunset — bothPR only. exclude skips drafts, only matches nothing but drafts. Setting it on entity: issue is a parse error.
basestringunset — any basePR only. Restrict to pull requests targeting this base branch, e.g. main. Setting it on entity: issue is a parse error.
authorslist of logins[]Explicit author allowlist. A login in this list is always allowed, whatever authors_from says.
authors_fromcollaborators | org_membersunsetBroader 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_skipskip | label_needs_humanskipWhat 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.

FieldTypeDefaultMeaning
keyissue | pr_head_shaissueWhat 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.
ttlduration stringunset — no lease expiry writtenHow 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:

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.

FieldTypeDefaultMeaning
strategyworktree | in_placeworktree 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.
branchtemplated stringderived 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.

Branch templating is strict — and PR autoflows ignore it. Autoflow template rendering uses strict undefined handling: a variable that doesn't resolve is a protocol error, not an empty string. And for 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:

FieldTypeDefaultMeaning
outputstring (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 firesWho feeds itWhat 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.

  1. Wake. A cycle starts — rupu autoflow tick from launchd / a systemd timer / Task Scheduler, or a long-lived rupu autoflow serve worker. Enabled autoflows are discovered and each one's repo binding is resolved from the repo registry.
  2. 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.
  3. Resolve precedence. Every autoflow whose entity and selector match is evaluated; highest priority wins, ties break on the workflow name that sorts first. Only the winner may hold the claim.
  4. Claim. Acquire the claim lock, then write or renew the claim record with an owner id and a lease expiry from claim.ttl.
  5. Check due. An item is due on first sight, on a matching wake_on: event, when reconcile_every has elapsed, or when a retry backoff expired. Not due means the claim is kept and nothing runs.
  6. Prepare the workspace. Create or reuse the durable worktree on workspace.branch, or use the preferred checkout for in_place.
  7. Run the steps. The ordinary workflow engine, with the entity bound into the step context. A step approval: gate sets the run to awaiting_approval and the claim to await_human — the item stays owned while it waits for a person.
  8. Read the outcome. Parse and validate the document named by outcome.output against its declared contract.
  9. 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.
  10. 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.
eligible claimed running complete released non-terminal outcome waiting states await_human · await_external retry_backoff · blocked next reconcile
The lease keeps the item owned through every waiting state — only a terminal outcome or an operator release lets it go.

Idempotency & ownership

Autoflows are unattended, so the whole tick algorithm is idempotent: running two ticks close together must never duplicate ownership or dispatch.

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.

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:

CommandPurpose
rupu autoflow create [name]Scaffold a new autoflow-enabled workflow YAML and open it for editing. --scope global|project, --editor.
rupu autoflow listList 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 tickDiscover and reconcile every enabled autoflow once, then exit. The primary tick runtime.
rupu autoflow serveRun 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 stopStop a running local serve worker. --worker, --repo.
rupu autoflow statusSummarize active / waiting / retrying / complete claims. --repo.
rupu autoflow claimsInspect persisted claims directly (subject, source, repo, branch, PR, status). --repo.
rupu autoflow wakesInspect queued and recently processed wakes. --repo.
rupu autoflow monitorRead-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 doctorRun 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.