command
Run a project-owned command that decides, transforms, enriches, scores, or
prepares data. rflow renders an input: object, pipes it to the command as
JSON on stdin, and stores what the command prints on stdout as
steps.<id>.output for later steps to consume.
rflow_version: 1
name: trade-prep
config:
port: 3947
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
trader:
networks: [ethereum]
contracts:
USDC:
abi: ./abis/erc20.json
addresses:
ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
Vault:
abi: ./abis/vault.json
addresses:
ethereum: "0x000000000000000000000000000000000000dEaD"
secrets:
PRICE_API_KEY: ${PRICE_API_KEY}
workflows:
trade-prep:
trigger:
cron: { expression: "*/10 * * * *" }
steps:
- id: balance
read:
contract: USDC
network: ethereum
function: "balanceOf(address)"
args: ["${{ relayers.trader.address }}"]
- id: decide
command:
run: "node ./scripts/decide.js"
timeout: 10s
output: json
input:
trigger: "${{ trigger }}"
balance: "${{ steps.balance.output }}"
env:
PRICE_API_KEY: "${{ secrets.PRICE_API_KEY }}"
- id: execute
if: "${{ steps.decide.output.should_send == true }}"
send_transaction:
network: ethereum
relayer: trader
contract: Vault
function: "deposit(uint256)"
args: ["${{ steps.decide.output.amount }}"]
recheck: "${{ now() < steps.decide.output.valid_until }}"The boundary: decide vs execute
command is an action-preparation step, not an execution authority: it
may decide, transform, and prepare data, but rflow owns signing, transaction
submission, simulation, approvals, gas caps, idempotency, rechecks, history,
and durable state.
The command has no signing access and no rflow send API. If a transaction
should happen, the command returns structured parameters and a normal
send_transaction step performs it, so
every send guarantee still applies and the run history shows exactly why
money moved.
The command-trade-prep example runs this end to end; the command-decision example shows the monitoring-only shape (no signer at all).
Invocation: run vs program + args
Set exactly one of run or program (both, or neither, is a validation
error).
command:
run: "node ./scripts/decide.js --mode quote" # convenient, portablecommand:
program: node # explicit, unambiguous
args: ["./scripts/decide.js", "--mode", "quote"]runis word-split into a program plus inline arguments and spawned directly: no shell is ever invoked.|,&&,>,$(...), backticks, globbing and$VARinterpolation become literal arguments. If you need a pipeline, put it in a script and call the script.program+argsspawns the program with the exact args; no word-splitting ambiguity. Prefer it for anything non-trivial.argsmay accompany either form; its entries are rendered as expressions and appended after any inline args.
The stdin / stdout contract
stdin is the rendered input: object, serialized as JSON ({} when
input: is omitted). Read it all, then parse:
#!/usr/bin/env node
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
// ...decide...
process.stdout.write(JSON.stringify({ should_send: true, amount: "100000000" }));stdout is controlled by output::
output: | Behaviour |
|---|---|
json (default) | stdout (trimmed) is parsed as JSON into steps.<id>.output. Unparseable stdout fails the step command_output_invalid |
text | stdout is stored verbatim as a string |
stderr is advisory only: it never becomes the step's output. On a non-zero exit an excerpt is surfaced in the failure message (redacted, see below).
Keep output small and structured: it lives in the journal, CLI, and API.
For bulk data, store it externally and return a pointer
({ "report_url": "s3://…", "summary": "42 checked, 3 alerts" }).
Context injection via input
rflow does not dump the whole runtime context into the command; you pass
exactly what it needs. Every value is a normal expression, so any root works:
trigger, steps, state, lists, constants, secrets, relayers
(addresses only, never signing handles), contracts, matrix, run, and
reorg (inside on_reorg).
input:
trigger: "${{ trigger }}"
balance: "${{ steps.balance.output }}"
relayer_address: "${{ relayers.main.address }}"Passing whole roots wholesale (especially input: { secrets: "${{ secrets }}" })
triggers a validate warning. Reference individual secrets instead.
Environment
The child does not inherit rflow's process environment: it gets a
minimal safe base (PATH, HOME) plus the rendered env: map. Pass
secrets through env: (redacted in logs), never through stdout:
env:
QUOTE_API_KEY: "${{ secrets.QUOTE_API_KEY }}"Fields
| Field | Default | Description |
|---|---|---|
run | Executable + inline args, word-split, spawned with no shell. Exclusive with program | |
program | Executable name/path. Exclusive with run | |
args | Argument list (expressions); appended after any inline run args | |
cwd | project root | Working directory (rendered; resolved relative to the project root when not absolute) |
input | {} | Object rendered to a JSON object on stdin |
env | Explicit env values (rendered), layered over the minimal base | |
timeout | 30s | Wall-clock limit; may be templated (rendered, then range-checked 1..=600s at run time). Hard max 600s (a larger static value is a validation error). On timeout the process is killed → command_timeout |
output | json | json | text |
dry_run | execute | execute | skip — what replay/test/dry-run does, see below |
max_stdout_bytes | 1048576 (1 MiB) | Exceeding it fails command_output_too_large |
max_stderr_bytes | 65536 (64 KiB) | stderr excess is truncated (advisory only) |
Failure taxonomy
Each mode maps to a stable failure kind you can
match in retry.retry_if:
| Kind | Cause | Retryable by default |
|---|---|---|
command_failed | non-zero exit | ❌ |
command_timeout | killed on timeout | ✅ |
command_output_invalid | stdout not parseable for output: json | ❌ |
command_output_too_large | stdout over max_stdout_bytes | ❌ |
command_spawn_failed | executable missing / not executable / not resolvable | ✅ |
Override any default with retry.
A crash after the command changes an external system but before its result is journaled can cause the command to execute again. Its side effects are not part of the relayer's idempotency protocol. Prefer commands that calculate and return data; otherwise give their effects stable identities and deduplicate them in the receiving system. A timeout also cannot undo work the command already did.
Dry-run, test & replay
By default a command executes for real even in dry-run (dry_run: execute):
it may need to produce the would-be transaction parameters a later step
rehearses. rflow skips its own side effects in dry-run
(send_transaction stops before the
relayer, http_call/notify never fire), but it cannot know what arbitrary
local code does: a script that writes files or calls external APIs still
does so.
If a command has side effects you don't want during a rehearsal, set
dry_run: skip. It short-circuits without spawning the process, settling:
{ "skipped": true, "reason": "command dry_run: skip" }See Backtesting for the full replay/test model.
Redaction & security
- Every outcome passes rflow's shared redaction choke point: a
secrets.*value echoed into a failure message or stderr excerpt becomes[redacted:NAME]before it reaches the journal, logs, orrflow runs show. Successful output is not auto-redacted. - Marking specific output fields as secret is not in v1. Keep secrets out
of stdout; to persist one, write it via a later
state_set, not by returning it. - Commands run with your local machine's permissions: treat scripts as
trusted code you own. A command outside the project root (absolute path or
..) raises avalidatewarning. - No implicit signing. Only a later
send_transactionmoves money, with all of rflow's guarantees. A command that brings its own key/RPC and sends directly bypasses every guarantee; the linter warns about this anti-pattern.
Sharing output across steps and workflows
Within a workflow, later steps read steps.<id>.output directly: in if:
gates, send_transaction args,
state_set, notify, and http_call.
Across workflows, hand the output off explicitly, never as an implicit dependency on another run's in-memory output:
- Durable state —
state_setthe decision; another workflow readsstate['…']in awhere:or step. - List mutation —
list_adda wallet the command classified; another workflow's trigger matches onlists.<name>. - Webhook —
http_calla second workflow's webhook trigger with an idempotency key.
See state & lists for the mechanics.
Validation & preflight
rflow validate is static only: it never executes your command. It checks
field shape (run xor program), timeout/output/dry-run validity, the 600s
timeout ceiling, and that expressions in run, program, args, cwd,
env, and input parse and reference only earlier steps. It warns on:
shell-like run strings, paths outside the project, ${{ secrets }} passed
wholesale, a missing (defaulted) timeout, an above-default stdout cap, and a
command placed before a send_transaction that has no recheck:.
To debug a command against real input without firing the whole workflow, use
rflow command test:
rflow command test <workflow> <step-id> --input ./fixtures/context.jsonThe --input fixture supplies the dynamic context roots (trigger, steps,
state, …); the static roots (constants/secrets/contracts/lists) come
from rflow.yaml. It runs the command for real and prints the rendered
invocation, the (secret-redacted) stdin, the duration, and the parsed output.
rflow does not create a workflow journal or invoke its relayer, and it needs no
database. The command's own file writes, API calls and direct transactions still
run: command test executes it even when the step has dry_run: skip. It exits
non-zero when the command fails, so a fixture doubles as a CI check. Add --json
for a machine transcript.
command test - 'supply-watch' / 'decide'
field | value
program | node ./scripts/decide.js
cwd | /path/to/examples/command-decision
duration | 24ms
stdin (rendered input, secrets redacted):
{ "decimals": 18, "run": "command-test", "supply": "1000000000000000000000000" }
output (-> steps.decide.output):
{ "severity": "warning", "reason": "…", "supply": "1000000" }rflow validate --preflight adds command-aware live checks: for every
command step it confirms the executable resolves (on PATH, or as a
project-relative path with the executable bit), that a statically-detectable
local script argument (./scripts/decide.js) exists, and, for a known
interpreter, reports its version:
command supply-watch/decide | node | ok | resolves on PATH (/usr/local/bin/node); v25.9.0Anything behind a ${{ }} expression is rendered at run time, so preflight
reports it as skipped rather than guessing.
Allowed contexts
command is allowed everywhere a normal step is, including inside
foreach fan-outs and
on_reorg handlers (not a send, so not on on_reorg's
rejected-action list).