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.
- 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:
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.
The command may decide, transform, and prepare data. rflow still 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
simulation, gas caps, relayer policy, idempotency, approvals, and rechecks all
still apply. This keeps the run history able to show 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 split into a program plus inline arguments by shell-style word splitting and then spawned directly — no shell is ever invoked. That means|,&&,>,$(...), backticks, globbing and$VARinterpolation are not interpreted; they become literal arguments.runis for convenience and portability from other tools, not for shell pipelines. If you need a pipeline, put it in a script and call the script.program+argsspawns the program with the exact args — the safer form that avoids any 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 — the rendered input: object, serialized as a JSON object (the empty
object {} 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 — 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. If
a command produces 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 — that would
widen secret exposure and make inputs unstable. You pass exactly what the
command 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. Its env is a
minimal safe base (PATH, HOME) plus the rendered env: map — nothing
else leaks in. Pass secrets a script needs 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.
Dry-run, test & replay
By default a command executes for real even in dry-run (dry_run: execute) —
because it may be needed 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, so a
secrets.*value echoed into a failure message or stderr excerpt is replaced with a[redacted:NAME]marker before it reaches the journal, logs, orrflow runs show. (Successful output is not auto-redacted — see below.) - Marking specific output fields as secret is not in v1. Keep secrets out
of stdout; if you must 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. The command cannot sign or send; 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 — this is the explicit anti-pattern the linter warns about.
Sharing output across steps and workflows
Within a workflow, later steps read steps.<id>.output directly — through if:
gates, send_transaction args,
state_set, notify, and http_call.
Across workflows, do not make another run implicitly depend on this run's in-memory output. Hand it off explicitly:
- 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 performs static checks only — it never executes your
command. It checks the 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.jsonIt renders the step exactly as a run would — the --input fixture supplies the
dynamic context roots (trigger, steps, state, …) the command's
input:/env:/args: reference, while the static roots
(constants/secrets/contracts/lists) come from rflow.yaml — then runs
the command for real and prints the rendered invocation, the (secret-redacted)
stdin, the duration, and the parsed output. Nothing is signed, sent, journaled,
or persisted, and it needs no database; the process 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 alongside its
RPC/channel/database pings: 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 a read-only preparation step, so it is allowed everywhere a
normal step is — including inside foreach fan-outs and
inside on_reorg handlers (it is not a send, so it is not on
on_reorg's rejected-action list).