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_letterFields
| Field | Default | Description |
|---|---|---|
trigger | โ required | Exactly one of event | cron | interval | webhook | read | query | block | stream | all / any | workflow_error |
steps | โ required | Strictly sequential list โ see below |
paused | false | Kill-switch. Runtime pause state (rflow workflow pause) lives in Postgres and overrides this |
liveness | Alert when the workflow claims no run for max_silence โ see Unattended ops | |
permissions | Guard rails โ see below | |
concurrency | Per-entity serialization + conflict policy โ see below | |
rate_limit | Cap run starts per window (add durable: true for a restart-proof window) โ see below | |
budgets | Names of top-level budgets: this workflow's sends charge โ see below | |
circuit_breaker | Trip the workflow open after repeated failures โ see below | |
strategy | Matrix expansion โ see below | |
finally | Steps that always run after the run settles โ see below | |
on_reorg | Steps that run when a reorg orphans this workflow's trigger โ see Reorgs | |
on_failure | dead_letter | What 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:
| Field | Description |
|---|---|
id | Step id โ addressable as steps.<id>.*. Optional for steps nothing refers back to |
if | Condition gating the step, e.g. "${{ steps.gate.output > 0 }}". False โ step is skipped |
foreach | Fan the action out over a collection โ see below |
max_parallel / batch_size | foreach tuning โ see below |
retry | Retry policy โ see below |
timeout | Overall 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 contributenull). - Failure policy: any iteration failing after its retries fails the whole step.
With
max_parallel: 1later items never start; with parallelism, in-flight iterations drain (never cancelled mid-send) but nothing new starts. wait_forruns inside a foreach: each item parks its own<id>[<i>]wait rows and resumes exactly once (placement rules);on_timeout: gotois 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 concurrentlyEach 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 sendconcurrency
concurrency:
group: "${{ trigger.args.sender }}" # expression โ serialization key
on_conflict: queue # queue | skip | cancel_in_progress | replaceRuns 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:
| Mode | Behaviour |
|---|---|
queue (default) | Wait โ FIFO within the group |
skip | The newcomer settles as skipped (terminal, journaled with the in-flight run's id) |
cancel_in_progress | The in-flight run is superseded at its next safe point โ between steps, never mid-send โ then the newcomer starts |
replace | Alias 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-proofAt 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 budgetBefore 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 pauseplaced 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.grouplanes 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_exceededretry: 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 # optionalretry_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 withrflow runs list --failed, re-fire withrflow runs retry <id>dropโ log and forgethaltโ pause the workflow itself; no further runs until resumed
finally: steps still run on every path.