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.

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, portable
command:
  program: node                                  # explicit, unambiguous
  args: ["./scripts/decide.js", "--mode", "quote"]
  • run is word-split into a program plus inline arguments and spawned directly: no shell is ever invoked. |, &&, >, $(...), backticks, globbing and $VAR interpolation become literal arguments. If you need a pipeline, put it in a script and call the script.
  • program + args spawns the program with the exact args; no 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 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
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. 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

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.

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, or rflow 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 a validate warning.
  • No implicit signing. 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; 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_set the decision; another workflow reads state['…'] in a where: or step.
  • List mutation — list_add a wallet the command classified; another workflow's trigger matches on lists.<name>.
  • Webhook — http_call a 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.json

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

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