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

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, portable
command:
  program: node                                  # explicit, unambiguous
  args: ["./scripts/decide.js", "--mode", "quote"]
  • run is 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 $VAR interpolation are not interpreted; they become literal arguments. run is 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 + args spawns the program with the exact args — the safer form that avoids any word-splitting ambiguity. Prefer it for anything non-trivial.
  • args may 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
textstdout 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

FieldDefaultDescription
runExecutable + inline args, word-split, spawned with no shell. Exclusive with program
programExecutable name/path. Exclusive with run
argsArgument list (expressions); appended after any inline run args
cwdproject rootWorking directory (rendered; resolved relative to the project root when not absolute)
input{}Object rendered to a JSON object on stdin
envExplicit env values (rendered), layered over the minimal base
timeout30sWall-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
outputjsonjson | text
dry_runexecuteexecute | skip — what replay/test/dry-run does, see below
max_stdout_bytes1048576 (1 MiB)Exceeding it fails command_output_too_large
max_stderr_bytes65536 (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:

KindCauseRetryable by default
command_failednon-zero exit
command_timeoutkilled on timeout
command_output_invalidstdout not parseable for output: json
command_output_too_largestdout over max_stdout_bytes
command_spawn_failedexecutable 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, or rflow 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 a validate warning.
  • No implicit signing. The command cannot sign or send; only a later send_transaction moves 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 statestate_set the decision; another workflow reads state['…'] in a where: or step.
  • List mutationlist_add a wallet the command classified; another workflow's trigger matches on lists.<name>.
  • Webhookhttp_call a 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.json

It 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.0

Anything 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).