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

Approvals — human-in-the-loop sends

approval: on a send_transaction step parks the run with the transaction fully prepared and simulated, notifies the approvers, and broadcasts only after an explicit yes.

rflow_version: 1
name: treasury-ops
 
config:
  port: 3947
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}
 
relayers:
  treasury:
    networks: [ethereum]
 
contracts:
  USDC:
    abi: ./abis/erc20.json
    addresses:
      ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
 
constants:
  cold_storage: "0x000000000000000000000000000000000000dEaD"
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
workflows:
  treasury-sweep:
    trigger:
      cron: { expression: "0 9 * * 1" }
    steps:
      - id: balance
        read:
          contract: USDC
          network: ethereum
          function: "balanceOf(address)"
          args: ["${{ relayers.treasury.address }}"]
      - id: sweep
        send_transaction:
          network: ethereum
          relayer: treasury
          contract: USDC
          function: "transfer(address,uint256)"
          args: ["${{ constants.cold_storage }}", "${{ steps.balance.output }}"]
          approval: 
            via: [telegram: ops, cli] 
            timeout: 4h
            on_timeout: fail        # fail (default) | proceed (warned) 
            message: "sweeping ${{ format_units(steps.balance.output, 6) }} USDC to cold storage"
          recheck: "${{ steps.balance.output > wei('1000', 6) }}"

The approval block

FieldDefaultDescription
via✅ requiredApproval routes, non-empty: telegram: <channel> (a notifications channel) and/or cli
timeoutHow long to wait for a decision, e.g. 1h
on_timeoutfailWhat an undecided timeout does: fail | proceed
messageTemplate shown to the approver. The decoded call + simulation result are always included

Approval policies — N-of-M quorums

For N-of-M review, name the people and the quorum instead of a route list. Approvers are identity objects: channels hold credentials (via_channel points at a notifications channel); approvers hold addresses, and the address is the attribution key.

rflow_version: 1
name: treasury-ops
 
config:
  port: 3947
  db_connection: ${DATABASE_URL}
 
# the channels hold the credentials the approver routes deliver over
notifications:
  channels:
    ops:
      telegram: { bot_token: ${TG_BOT_TOKEN}, chat_id: ${TG_CHAT_ID} }
    ops-sms:
      twilio:
        account_sid: ${TWILIO_ACCOUNT_SID}
        auth_token: ${TWILIO_AUTH_TOKEN}
        from: "+15550001111"
        to: "+15552223333"
    audit:
      email:
        from: "rflow ops <ops@example.com>"
        to: [treasury@example.com]
        smtp: { host: smtp.example.com, username: ${SMTP_USER}, password: ${SMTP_PASS} }
 
approvers: 
  alice: 
    cli: { token: ops-alice }                          # named api token (rflow token create) 
    telegram: { chat_id: "12345678", via_channel: ops } # her PRIVATE chat with the bot 
  bob: 
    sms: { to: "+447700900123", via_channel: ops-sms }  # over the twilio channel's account #
  carol: 
    cli: { token: ops-carol } 
    email: { address: carol@example.com, via_channel: audit }  # over the email channel's transport #
 
approver_groups: 
  ops: [alice, bob, carol] 
 
approval_policies: 
  two-of-ops: 
    group: ops
    required: 2          # validated: 1 <= required <= group size (groups max 25) 
    timeout: 4h          # hard 30-day ceiling; absent = wait the full ceiling #
    remind_every: 1h     # re-notify undecided members (journaled) 
    on_timeout: fail     # NEVER manufactures a decision; `proceed` is warned #
    on_reject: fail      # first rejection VETOES (default); `wait` = fail only #
                         # once `required` approvals become impossible 
    escalate: ops        # notify-only when the quorum expires undecided 
 
workflows:
  treasury-sweep:
    trigger:
      cron: { expression: "0 9 * * 1" }
    steps:
      - id: sweep
        send_transaction:
          # ...the prepared send from the example above, gated by the policy:
          approval:
            policy: two-of-ops

An approver can declare any combination of the four routes:

RouteAddressvia_channel must beDelivers
clia named api token (rflow token create)—nothing; the member decides with rflow approve --token
telegramchat_id (the member's PRIVATE chat)a telegram channelprivate DM: notice + one-time link
smsto (E.164)a twilio channelSMS: notice + one-time link
emailaddress (mailbox, the attribution key)an email channelemail over whichever transport the channel declares: notice + one-time link

Reference it from the send: a bare policy, or amount tiers (ordered, first when: match wins, a trailing no-when tier is the default, no match = no gate; value is the prepared send's native value in wei):

          approval:
            policy: two-of-ops
# or
          approval:
            - when: "${{ value > wei('50', 18) }}"
              policy: two-of-ops
            - policy: one-of-ops

Quorum semantics:

  • The snapshot is pinned at park time. Members, required, timeouts and routes are frozen onto the approval row; a later config edit never changes a pending quorum.
  • One member counts once. Decisions are journaled per member with a unique key; a duplicate command, replayed link, or the same person on two channels cannot double-count.
  • Members with a telegram:/sms:/email: route get their own private notice over the channel's credential (CLI-only members decide from rflow approvals ls with their token), carrying a signed one-time approval link (when config.server.public_url is set) plus the CLI one-liners. The link is bound to one member of one approval: single-use, expires with the quorum, GET only renders the confirm page (scanners cannot spend it), the decision is a POST.
  • CLI decisions must prove membership: rflow approve <id> --token <api-token> (or RFLOW_API_TOKEN). The token's name maps to approvers.<member>.cli.token. cli:$USER is never accepted for quorums.
  • Everything is journaled: per-member decisions (member, route, reason, time) in rflow.approval_decisions, an operator_audit row per decision, and the full trail in rflow runs show / GET /api/runs/{id}. Retention keeps everything by default; when a retention window prunes a decided approval (or its run), the decision/link rows cascade away, but the operator_audit trail survives pruning by design.

Safe proposal mode

send_transaction.propose_to_safe: PROPOSES the prepared tx to a Safe via the Safe Transaction Service instead of broadcasting. The Safe's own owner threshold then governs execution in Safe{Wallet}. An integration, not custody: rflow holds a revocable delegate key that can only propose, never execute.

rflow_version: 1
name: treasury-ops
 
config:
  port: 3947
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}
 
relayers:
  treasury:
    networks: [ethereum]
 
contracts:
  USDC:
    abi: ./abis/erc20.json
    addresses:
      ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
 
constants:
  cold_storage: "0x000000000000000000000000000000000000dEaD"
 
approvers:
  alice: { cli: { token: ops-alice } }
  bob: { cli: { token: ops-bob } }
 
approver_groups:
  ops: [alice, bob]
 
approval_policies:
  two-of-ops: { group: ops, required: 2 }
 
workflows:
  treasury-sweep:
    trigger:
      cron: { expression: "0 9 * * 1" }
    steps:
      - id: balance
        read:
          contract: USDC
          network: ethereum
          function: "balanceOf(address)"
          args: ["${{ relayers.treasury.address }}"]
      - id: sweep
        send_transaction:
          network: ethereum
          relayer: treasury          # unused in this mode (no broadcast)
          contract: USDC
          function: "transfer(address,uint256)"
          args: ["${{ constants.cold_storage }}", "${{ steps.balance.output }}"]
          approval:
            policy: two-of-ops       # rflow's gate = proposal hygiene
          propose_to_safe: 
            safe: "0xYourSafe..."
            delegate_key: ${SAFE_DELEGATE_KEY}
            service_url: https://api.safe.global/tx-service/eth
            api_key: ${SAFE_API_KEY}   # optional; anonymous is rate-limited #

Every gate still runs first (permissions, simulation, gas caps, recheck, rflow approval); the step then signs the safeTxHash (v1.3.0+ domain) with the delegate key and posts the proposal. The output carries safe_tx_hash and the nonce. Register the delegate once with the service (an owner signs the Delegate message). Edges: a proposal cannot be un-proposed (an rflow approval timeout upstream means nothing is proposed), and the Safe queue is nonce-ordered, so concurrent proposers race for the next nonce.

The lifecycle

  1. Prior steps have already run. The gate is on this transaction, not the workflow: reads, HTTP enrichment and guard steps complete first, so the approver sees final values.
  2. The transaction is prepared and simulated. Simulation, assert_sim and gas caps run before anyone is asked; a send that would revert never bothers a human.
  3. The run parks durably (waiting_approval): the approval row (rflow.approvals), its expiry and the prepared tx summary are journaled in the same transaction that parks the step. A crash while parked loses nothing; a recovering executor adopts the pending row.
  4. Approvers are notified through every via: route with the workflow, run and step ids, your message, the decoded call summary and the exact rflow approve/reject one-liners. Notification failures are logged, never fatal: the gate is the database row, so the CLI works even when every channel is down.
  5. A decision settles it. Decisions are single-shot (WHERE status = 'pending'): a double-approve or a race against the expiry poll changes nothing and reports what actually happened.
DecisionEffect
approvedThe send re-evaluates recheck:, if set, and simulates against current RPC state when simulation is enabled or required by assert_sim. A failed guard stops submission. Earlier read/command outputs remain journaled values; they are not fetched again. The attempt keeps its idempotency key.
rejectedThe step fails (kind dropped, the reason journaled); on_failure applies
expiredon_timeout decides: fail — the step fails with kind expired, nothing broadcasts. proceed — see below

Deciding: CLI

rflow approvals ls              # pending approvals (--all includes decided/expired)
rflow approve <id> [--yes]      # <id> is the approval id or the run id
rflow reject <id> --reason "gas too high today"

rflow approve prints the prepared transaction and asks for confirmation (--yes skips it). A run has at most one pending approval (steps are sequential), so the run id works as the <id>. Pending approvals also show in rflow runs show <run> and the run-trace viewer.

An AI agent operating the project via MCP can see pending approvals but has no approve/reject tool: deciding a money gate stays with humans and the CLI, deliberately.

on_timeout: proceed — the sharp edge

Semantics worth knowing

  • Webhook callers see 429 while their workflow's run is parked at a gate. The caller's retry loop redelivers naturally once the run settles.
  • Dry-run sessions auto-proceed: in rflow replay / rflow test the gate does not park. The journal carries an approval.required marker so you still see where a human would have been asked.
  • on_reorg: steps may not contain approval gates (validated): reorg responses must stay fast and unattended.
  • The pagerduty/opsgenie channels classify approval requests as info/P5: a request for a decision, not an incident.