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
| Field | Default | Description |
|---|---|---|
via | ✅ required | Approval routes, non-empty: telegram: <channel> (a notifications channel) and/or cli |
timeout | How long to wait for a decision, e.g. 1h | |
on_timeout | fail | What an undecided timeout does: fail | proceed |
message | Template 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-opsAn approver can declare any combination of the four routes:
| Route | Address | via_channel must be | Delivers |
|---|---|---|---|
cli | a named api token (rflow token create) | — | nothing; the member decides with rflow approve --token |
telegram | chat_id (the member's PRIVATE chat) | a telegram channel | private DM: notice + one-time link |
sms | to (E.164) | a twilio channel | SMS: notice + one-time link |
email | address (mailbox, the attribution key) | an email channel | email 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-opsQuorum 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 fromrflow approvals lswith their token), carrying a signed one-time approval link (whenconfig.server.public_urlis 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>(orRFLOW_API_TOKEN). The token's name maps toapprovers.<member>.cli.token.cli:$USERis never accepted for quorums. - Everything is journaled: per-member decisions (member, route, reason,
time) in
rflow.approval_decisions, anoperator_auditrow per decision, and the full trail inrflow 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 theoperator_audittrail 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
- 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.
- The transaction is prepared and simulated. Simulation,
assert_simand gas caps run before anyone is asked; a send that would revert never bothers a human. - 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. - Approvers are notified through every
via:route with the workflow, run and step ids, yourmessage, the decoded call summary and the exactrflow approve/rejectone-liners. Notification failures are logged, never fatal: the gate is the database row, so the CLI works even when every channel is down. - 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.
| Decision | Effect |
|---|---|
| approved | The 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. |
| rejected | The step fails (kind dropped, the reason journaled); on_failure applies |
| expired | on_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 testthe gate does not park. The journal carries anapproval.requiredmarker 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.