Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Workflows

A workflow is when this happens, do these things, in order. Each entry under workflows: (keyed by name) has one trigger and a list of steps.

workflows:
  copy-trade:
    paused: false
    permissions:
      relayers: [payout]
      max_value_per_tx: 1 ether
    concurrency:
      group: "${{ trigger.args.sender }}"
      on_conflict: queue
    rate_limit: { max: 10, per: 1h }
    circuit_breaker:
      max_failures: 3
      window: 10m
      on_trip: [pause, notify: { channel: ops }]
    trigger:
      event: { contract: AnyPool, name: Swap, network: ethereum }
    steps:
      - id: gate
        read: { ... }
      - id: mirror
        send_transaction: { ... }
        retry: { max_attempts: 3 }
      - id: report
        notify: { ... }
    finally:
      - id: audit
        http_call: { url: "${AUDIT_URL}", body: { run: "${{ run.id }}", status: "${{ run.status }}" } }
    on_failure: dead_letter

Fields

FieldDefaultDescription
triggerโœ… requiredExactly one of event | cron | interval | webhook | read | query | block | stream | all / any | workflow_error
stepsโœ… requiredStrictly sequential list โ€” see below
pausedfalseKill-switch. Runtime pause state (rflow workflow pause) lives in Postgres and overrides this
livenessAlert when the workflow claims no run for max_silence โ€” see Unattended ops
permissionsGuard rails โ€” see below
concurrencyPer-entity serialization + conflict policy โ€” see below
rate_limitCap run starts per window (add durable: true for a restart-proof window) โ€” see below
budgetsNames of top-level budgets: this workflow's sends charge โ€” see below
circuit_breakerTrip the workflow open after repeated failures โ€” see below
strategyMatrix expansion โ€” see below
finallySteps that always run after the run settles โ€” see below
on_reorgSteps that run when a reorg orphans this workflow's trigger โ€” see Reorgs
on_failuredead_letterWhat happens to a failed run: dead_letter (Postgres DLQ + rflow runs retry) | drop | halt

Steps run in order

A step list is strictly sequential: 1 โ†’ 2 โ†’ 3. There is no DAG and no needs: โ€” the default mental model stays simple. Any prior step's results are addressable from any later step: ${{ steps.gate.output }}, ${{ steps.mirror.tx.hash }}, ${{ steps.mirror.status }}.

Every step shares these common fields around its action:

FieldDescription
idStep id โ€” addressable as steps.<id>.*. Optional for steps nothing refers back to
ifCondition gating the step, e.g. "${{ steps.gate.output > 0 }}". False โ†’ step is skipped
foreachFan the action out over a collection โ€” see below
max_parallel / batch_sizeforeach tuning โ€” see below
retryRetry policy โ€” see below
timeoutOverall step timeout, e.g. 5m

The action itself is exactly one of: send_transaction ยท read ยท query ยท command ยท http_call ยท notify ยท delay ยท wait_for ยท choose ยท state_set / list_add / list_remove.

foreach โ€” fan a step out

Run one step's action once per element of a collection expression:

- id: payout
  foreach: "${{ from_json(steps.plan.output.body).recipients }}"
  max_parallel: 4          # default 1 (strictly sequential)
  batch_size: 10           # optional โ€” item becomes the batch (an array)
  send_transaction:
    network: ethereum
    relayer: payout
    contract: USDC
    function: "transfer(address,uint256)"
    args: ["${{ item.address }}", "${{ item.amount }}"]
  • Each iteration sees ${{ item }} and ${{ item_index }}; if: is evaluated per iteration.
  • Each iteration journals under <id>[<i>] with its own idempotency key โ€” sends stay exactly-once per element, and a crash resumes exactly the interrupted iterations (settled ones never re-run).
  • The parent step settles last with output = the array of iteration outputs (skipped iterations contribute null).
  • Failure policy: any iteration failing after its retries fails the whole step. With max_parallel: 1 later items never start; with parallelism, in-flight iterations drain (never cancelled mid-send) but nothing new starts.
  • wait_for runs inside a foreach: each item parks its own <id>[<i>] wait rows and resumes exactly once (placement rules); on_timeout: goto is rejected here.

strategy.matrix โ€” one definition, many runs

strategy:
  matrix:
    network: [ethereum, base, arbitrum]
  fail_fast: false        # stop remaining combos after the first failure
  max_parallel: 2         # cap combos running concurrently

Each trigger fire expands into one run per combination; steps read the combination as ${{ matrix.network }}. Every combination extends the trigger key (...:matrix:network=base), so each is individually exactly-once โ€” a crash mid-expansion resumes idempotently.

finally โ€” always-run steps

finally: steps run after the run settles, success or failure โ€” audit trails, lock releases, "run finished" pings. They see the full context plus run.status (the terminal status). A finally: failure is logged and journaled but does not change the run's outcome. wait_for may park inside finally: (the run's terminal status never changes while it waits โ€” placement rules); on_timeout: goto is rejected here.

permissions

permissions:
  relayers: [payout]          # only these relayers may be used by this workflow
  max_value_per_tx: 1 ether   # cap on native value per send

concurrency

concurrency:
  group: "${{ trigger.args.sender }}"   # expression โ†’ serialization key
  on_conflict: queue                    # queue | skip | cancel_in_progress | replace

Runs whose group expression evaluates to the same key are serialized โ€” per-trader, per-vault, per-anything. Runs in different groups execute concurrently up to config.max_concurrent_runs.

on_conflict decides what a new run does when its group's lane is busy:

ModeBehaviour
queue (default)Wait โ€” FIFO within the group
skipThe newcomer settles as skipped (terminal, journaled with the in-flight run's id)
cancel_in_progressThe in-flight run is superseded at its next safe point โ€” between steps, never mid-send โ€” then the newcomer starts
replaceAlias of cancel_in_progress today

Superseded runs are deliberate cancellations โ€” they never count as circuit-breaker failures.

rate_limit

rate_limit: { max: 10, per: 1h }
rate_limit: { max: 10, per: 1h, durable: true }   # restart-proof

At most max run starts per per window. Claims beyond the limit stay queued (never dropped) and start when the window has room โ€” combined with the exactly-once gate, nothing is lost, just deferred.

By default the window is in-process, per rflow instance (a sliding window): a restart, or an HA takeover, starts with a fresh window, so a burst right after a restart can exceed the configured rate.

Set durable: true to persist the window in Postgres (rflow.rate_limit_buckets). The durable limiter is a fixed window claimed with one atomic INSERT โ€ฆ ON CONFLICT statement: the first run in a window anchors it, the window releases per later, and concurrent runs racing the same bucket can never exceed max. A restart or takeover then cannot reset a live window.

budgets

Top-level budgets: cap cumulative spend inside a rolling window across every workflow, run and send that attaches them โ€” the blast-radius cap when something upstream goes wrong.

budgets:
  treasury-daily:
    window: 24h
    max_native_value: 10 ether      # cap on cumulative native value in the window
    max_token_value:
      USDC: "1000000"               # raw token units (matches contracts.USDC)
    scope:
      relayers: [treasury]          # only sends from these relayers charge it
      networks: [ethereum]          # only sends on these networks charge it
 
workflows:
  sweep:
    budgets: [treasury-daily]       # this workflow's sends charge the budget

Before a send broadcasts (after every other check, right before the relayer call) it reserves its native value first, then a decodable ERC20 amount, against each attached in-scope budget. If a reservation would exceed the cap the send fails budget_exceeded and nothing broadcasts โ€” racing runs cannot overshoot the budget. The reservation is a durable row keyed by the send's idempotency key, so it is exactly-once and crash-safe: a queued send settles as spent, a failed send is released (refunded), and the recovery pass reconciles orphaned reservations by the same key. Inspect live consumption with rflow budgets ls.

See Budgets for the full config surface, the ERC20 second-class charging rules, validation and how budgets compose with permissions.max_value_per_tx.

circuit_breaker

circuit_breaker:
  max_failures: 3
  window: 10m
  on_trip: [pause, notify: { channel: ops }]

After max_failures terminally failed runs inside window, the circuit trips: pause pauses the workflow, notify: alerts a channel (pagerduty channels receive it as critical). State machine, persisted in Postgres (rflow.circuit_state โ€” restarts do not forget a tripped circuit):

  • closed โ†’ failures are counted in the window
  • open โ†’ after a full window with no new failures, the workflow is un-paused and one probe run is allowed through (half_open). Only the breaker's own pause is lifted: an operator's rflow workflow pause placed during the incident survives the auto-reset (resume it yourself when ready)
  • half_open โ†’ exactly ONE run at a time is admitted for the whole workflow โ€” concurrency.group lanes and matrix combos wait behind the probe. Probe fails: re-trips immediately; probe succeeds: closes and clears the window

Retries and the failure taxonomy

Every step failure carries a kind from a fixed taxonomy:

simulation_failed | reverted | insufficient_funds | rpc_timeout | rate_limited |
nonce_conflict | timeout | expired | dropped | data_unavailable | assert_failed |
gas_cap_exceeded

retry: applies to pre-queue and off-chain failures only โ€” rpc_timeout, rate_limited, data_unavailable, gas_cap_exceeded. A reverted or failed onchain transaction is a step failure and is never blind-retried; once a tx is queued, gas bumping and rebroadcast are the embedded relayer's job.

retry:
  max_attempts: 3
  retry_if: "${{ error.kind in ['rpc_timeout', 'rate_limited'] }}"   # optional
  backoff: 10s                                                       # optional

retry_if evaluates against the error context (error.kind, error.message). Without it, any retryable kind retries up to max_attempts.

Failure handling

When a step fails terminally (non-retryable kind, or attempts exhausted), the run fails and on_failure decides its fate:

  • dead_letter (default) โ€” the run lands in the Postgres DLQ with its full journal; inspect with rflow runs list --failed, re-fire with rflow runs retry <id>
  • drop โ€” log and forget
  • halt โ€” pause the workflow itself; no further runs until resumed

finally: steps still run on every path.