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:. 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 ยท simulate ยท 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:

rflow_version: 1
name: payout-fanout
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}   # DEV ONLY
 
relayers:
  payout:
    networks: [ethereum]
    speed: FAST
 
contracts:
  USDC:
    abi: ./abis/erc20.json
    addresses:
      ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
 
workflows:
  payday:
    trigger:
      cron:
        expression: "0 8 * * 1"
    steps:
      - id: plan
        http_call:
          url: https://api.example.com/payout-plan
      - 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

rflow_version: 1
name: multi-network-checks
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
notifications:
  channels:
    ops:
      console: {}
 
workflows:
  heartbeat:
    strategy: 
      matrix: 
        network: [ethereum, base, arbitrum] 
      fail_fast: false        # stop remaining combos after the first failure 
      max_parallel: 2         # cap combos running concurrently 
    trigger:
      cron:
        expression: "*/15 * * * *"
    steps:
      - id: check
        notify:
          channel: ops
          message: "checking ${{ matrix.network }}"

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; see 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.

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

durable: true persists the window in Postgres (rflow.rate_limit_buckets) as a fixed window claimed with one atomic INSERT โ€ฆ ON CONFLICT: the first run anchors the window, it releases per later, racing runs can never exceed max, and a restart or takeover 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.

rflow_version: 1
name: treasury-ops
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}   # DEV ONLY
 
relayers:
  treasury:
    networks: [ethereum]
    speed: FAST
 
contracts:
  USDC:
    abi: ./abis/erc20.json
    addresses:
      ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
 
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 #
    trigger:
      cron:
        expression: "0 8 * * *"
    steps:
      - id: send
        send_transaction:
          network: ethereum
          relayer: treasury
          contract: USDC
          function: "transfer(address,uint256)"
          args: ["0x2222222222222222222222222222222222222222", "1000000"]

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.