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