Node Flowdocs

The nf CLI

Every command, with examples — registering definitions, running and tailing executions, replay, bundles, and offline tests.

npx --yes @node-flow-dev/cli@1.0.0 help

Or from a clone, which is what the contributor docs assume:

git clone https://github.com/dsardar099/node-flow.git
cd node-flow && pnpm install
pnpm nf help

nf is not in the server image. That image carries the bundled server and nothing else, deliberately — it is the thing that runs in production, and a toolbox is not. Reach for npx above, including inside a cluster: the Kubernetes migration Job below does exactly that.

Installing it pulls @node-flow-dev/store and @node-flow-dev/tasks with it, because migrate and bootstrap talk to Postgres directly rather than through the API. It is a heavier install than @node-flow-dev/sdk for that reason.

How it connects

Most commands talk to a running server. Three — bootstrap, create-user and migrate — talk to Postgres directly, because they cannot be done through the API: bootstrap breaks the credential chicken-and-egg, and migrate runs schema changes as a deliberate step rather than racing them across every replica. nf test talks to nothing at all.

FlagEnvironmentDefault
--urlNF_URLhttp://localhost:3000
--api-keyNF_API_KEYnone — the command fails naming it
--namespaceNF_NAMESPACEdefault
--database-urlDATABASE_URLnone

Flags win over the environment.

export NF_URL=https://flows.example.com
export NF_API_KEY=nf_...
export NF_NAMESPACE=default

Both --key value and --key=value work. Every command takes --json for scripts; without it the output is for people.

Errors are one line, no stack trace. A stack trace for "you forgot --namespace" buries the one sentence that matters.


Setup commands

These connect to Postgres, not to the API.

nf migrate

DATABASE_URL=postgres://nodeflow:pw@localhost:5433/nodeflow nf migrate
Applied migrations:
  0036-ai
  0037-trace-context
nf migrate --json
# { "applied": ["0036-ai", "0037-trace-context"] }

Already up to date prints Schema already up to date and applies nothing. Each migration runs in one transaction, so it is safe under a race — but running it as a job and disabling DATABASE_MIGRATE_ON_BOOT is the right shape for a multi-replica rollout. See Self-hosting.

nf bootstrap

Creates a namespace and an API key with admin and platform:admin.

Since the server seeds its own first namespace and administrator on a fresh database, this is no longer part of the normal install. It is still the right tool for scripting a new tenant, for recovering an install whose credentials were lost, and for CI that needs a key without a browser.

nf bootstrap --namespace default
Created namespace "default" (0193c0f1-…)
Applied migrations: 0001-core-schema, …

API key:
  nf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

This is shown once and cannot be recovered. Store it now.

Try it:
  curl -H "X-API-Key: nf_xxxx…" http://localhost:3000/v1/auth/whoami
FlagMeaning
--namespace <slug>Required. Created if absent, reused if present.
--key-name <name>Name for the credential in the audit trail. Default bootstrap.
--scopes <a,b,c>Default admin,platform:admin. Validated — a scope nobody can hold is refused.
--no-migrateSkip migrations.
--jsonMachine-readable.
# A narrowly-scoped key for a CI pipeline
nf bootstrap --namespace acme --key-name ci --scopes executions:read,executions:start --json
{
  "namespaceId": "0193c0f1-…",
  "namespaceCreated": true,
  "migrationsApplied": [],
  "apiKeyId": "0193c0f2-…",
  "token": "nf_…"
}

platform:admin is included by default because creating the second namespace needs a scope that admin deliberately does not satisfy. Without it the namespace API would be unreachable on a fresh install and the only way to add a tenant would be SQL.

nf create-user

A human account for the dashboard and the human-task inbox.

nf create-user --namespace default \
  --email ada@example.com --name Ada \
  --password 'a-password-that-meets-the-policy'
FlagMeaning
--namespace <slug>Required. Must already exist.
--email <email>Required.
--password <pw>Required. Must satisfy the password policy.
--name <name>Display name. Defaults to the email.
--scopes <a,b,c>Default admin.
--json

An API key cannot reach the human-task inbox by design — claiming and completing are statements about who did the work — so exercising human tasks needs a real user.


Definitions

nf workflows list

The latest version of every registered workflow.

nf workflows list
NAME             VERSION  TAGS           DESCRIPTION
checkout         v3       team:payments  Charge, then ship.
fulfil_order     v1       -
rag_answer       v2       ai:enabled     Answers from the handbook.
nf workflows list --json | jq '.[] | select(.tags | index("team:payments"))'

nf workflows get

nf workflows get checkout
nf workflows get checkout --version 2

JSON on stdout, so it pipes:

nf workflows get checkout > checkout.json
nf workflows get checkout | jq '.tasks[].taskReferenceName'

nf workflows register

nf workflows register checkout.json
nf workflows register ./workflows/          # every *.json in the directory, sorted
nf workflows register a.json b.json c.json
registered checkout v3  (checkout.json)
registered refund v1    (refund.json)

Each file is registered independently; a failure is reported against that file and the rest continue. The exit code is 1 if any failed, which is what makes it usable in CI.

nf workflows register ./workflows/ --json
# { "file": "workflows/checkout.json", "name": "checkout", "version": 3 }

Registering the same name and version twice is a conflict — versions are immutable. Bump version in the file, or use nf import --workflow-conflicts new-version.


Running

nf run

# Fire and forget — prints the id
nf run checkout --input '{"orderId":"A-1","amount":99}'
# started checkout: 0193c0f1-…

# Input from a file
nf run checkout --input @order.json

# Wait for the result
nf run checkout --input '{"orderId":"A-1"}' --wait 60
0193c0f1-…  COMPLETED
{
  "receipt": "txn_abc"
}
FlagMeaning
--input JSON or --input @fileMust be a JSON object.
--wait [SECONDS]Block for the result. Bare --wait is 30 s; the server caps it at 60.
--version NPin a definition version.
--correlation-id IDYour identifier, for finding the run later.
--json

Exit codes, so a script can branch:

CodeMeaning
0COMPLETED
1Reached a terminal status that is not COMPLETED
2Still running when the wait elapsed
if nf run nightly_reconcile --wait 60; then
  echo "reconciled"
else
  case $? in
    1) echo "failed — check the run" ;;
    2) echo "still running, not waiting" ;;
  esac
fi

--wait uses POST /executions/{name}/execute, which is bounded at 60 seconds server-side. The execution continues past the timeout — a caller that stopped waiting has not cancelled anything.

nf executions list

nf executions list
nf executions list --workflow checkout --status FAILED --limit 50
ID                                    WORKFLOW      STATUS     STARTED
0193c0f1-…                            checkout v3   FAILED     2026-09-19T14:22:03.112Z
0193c0e8-…                            checkout v3   COMPLETED  2026-09-19T14:19:44.001Z
FlagMeaning
--workflow WDefinition name.
--status SRUNNING, PAUSED, COMPLETED, FAILED, TIMED_OUT, TERMINATED.
--limit NDefault 20, max 200.
--json

These map onto the search query language: --workflow and --status become workflow:X status:Y. The API supports considerably more — see API → searching executions.

nf executions get

nf executions get 0193c0f1-…
checkout  FAILED
reason: task "ship" ended as FAILED after 4 attempt(s)
TASK       TYPE     STATUS     ATTEMPT
charge     SIMPLE   COMPLETED  1
ship       SIMPLE   FAILED     4

ATTEMPT is 1-based for reading; the API's attempt is 0-based. A task inside a loop is shown as ref#iteration.

nf executions get 0193c0f1-… --json | jq '.tasks[] | select(.status=="FAILED")'

nf executions cancel-task

Stops a running task and pauses the run around it.

nf executions cancel-task 0193c0f1-… transcribe
cancelled transcribe; workflow is PAUSED
the worker was not stopped — its result will be refused when it reports

The task is marked CANCELED, not failed: no retry is spent and no failure workflow runs. The second line is not a footnote — the process doing the work keeps going, and only its result is refused.

nf executions rerun-tasks

Runs named tasks again. Takes one or more references.

nf executions rerun-tasks 0193c0f1-… transcribe
re-running transcribe

stale — still holding output from the run being replaced:
  transcreate, speech_generate
re-run with --cascade to replace these too

--cascade re-runs everything that depended on those tasks as well, so nothing is left stale:

nf executions rerun-tasks 0193c0f1-… transcribe --cascade

The workflow must be paused or finished. On a running one you get a 409 telling you to pause first — re-running a task while its downstream is still executing races the decider and has no agreed meaning.

nf executions rerun-tasks 0193c0f1-… transcribe --json | jq .staleDownstream

nf tail

Follows a run, printing each task change as it happens, until it ends.

nf tail 0193c0f1-…
14:22:03  charge                   SCHEDULED
14:22:03  charge                   IN_PROGRESS
14:22:04  charge                   COMPLETED
14:22:04  ship                     SCHEDULED
14:22:06  ship                     FAILED (attempt 1) — connection refused
14:22:08  ship                     SCHEDULED (attempt 2)
── workflow RUNNING
FlagMeaning
--interval MSPoll interval. Default 1000.
--jsonOne JSON object per line — { at, task, attempt, status, reason? }.

Exits 0 if the run completed, 1 otherwise.

# Start and follow in one line
ID=$(nf run checkout --input '{"orderId":"A-1"}' --json | jq -r .workflowId)
nf tail "$ID"

For a live UI-grade stream, the server also exposes server-sent events at GET /v1/ns/{ns}/executions/{id}/stream.

nf replay

Re-derives a recorded run through the engine and reports whether a definition version still reproduces it. Nothing is executed and no worker is touched.

nf replay 0193c0f1-…
# v3 reproduces the run exactly

nf replay 0193c0f1-… --version 4
# 2 difference(s) against v4:
#   - task "ship" was scheduled with a different input
#   - workflow output key "receipt" resolved to null

Exits 0 when it matches, 1 when it diverges — so a "does v4 break anything that ran on v3?" check belongs in CI.


Bundles

nf export

nf export --out bundle.json                       # everything
nf export --workflows checkout,refund --out b.json
nf export | jq .                                  # to stdout

A bundle carries workflow and task definitions.

nf import

nf import bundle.json --dry-run
nf import bundle.json
FlagMeaning
--dry-runReport what it would do; change nothing.
--workflow-conflicts skip|new-versionWhat to do when a name and version already exist.
--task-conflicts skip|overwriteLikewise for task definitions.
# Promote from staging to production
NF_URL=https://staging.example.com NF_API_KEY=$STAGING nf export --workflows checkout --out checkout.json
NF_URL=https://prod.example.com    NF_API_KEY=$PROD    nf import checkout.json --dry-run
NF_URL=https://prod.example.com    NF_API_KEY=$PROD    nf import checkout.json --workflow-conflicts new-version

nf test

Runs definitions through the real engine with no server: same decider, same expression resolution, same retry semantics, same registration rules — with anything external mocked. Milliseconds, and it drops straight into CI.

nf test greet.test.json
nf test ./tests/                      # every *.json in the directory
nf test a.json b.json
✓ tests/happy-path.json   COMPLETED  7 ms
✗ tests/saga.json         FAILED     11 ms
    task cancel_hotel is never run, expected COMPLETED

1 passed, 1 failed

Exit code 1 if any expectation failed.

The test file

saga.test.json
{
  "definition": {
    "name": "book_trip",
    "version": 1,
    "inputParameters": ["city"],
    "tasks": [
      {
        "name": "book_hotel",
        "taskReferenceName": "book_hotel",
        "type": "SIMPLE",
        "inputParameters": { "city": "${workflow.input.city}" },
        "compensateWith": {
          "name": "cancel_hotel",
          "taskReferenceName": "cancel_hotel",
          "type": "SIMPLE",
          "inputParameters": { "bookingId": "${book_hotel.output.bookingId}" }
        }
      },
      {
        "name": "book_flight",
        "taskReferenceName": "book_flight",
        "type": "SIMPLE",
        "inputParameters": { "city": "${workflow.input.city}" }
      }
    ]
  },

  "input": { "city": "lisbon" },

  "taskDefs": {
    "book_flight": { "retryCount": 0 }
  },

  "mocks": {
    "book_hotel": { "output": { "bookingId": "b-1" } },
    "book_flight": { "status": "FAILED_WITH_TERMINAL_ERROR", "reason": "no seats" },
    "cancel_hotel": { "output": { "cancelled": true } }
  },

  "expect": {
    "status": "FAILED",
    "tasks": { "cancel_hotel": "COMPLETED" }
  }
}
KeyMeaning
definitionThe workflow, inline.
input, variables, envStarting state.
mocksPer task reference name: one outcome for every attempt, or an array — one per attempt, the last repeating.
taskDefsRetry and timeout policy, by task definition name. An entry that does not say otherwise gets retryCount: 0, so a test does not silently take four attempts. A task with no entry falls back to the schema defaults, including retryCount: 3.
subWorkflowsChild definitions by name. They then run for real rather than being mocked.
expect.statusCOMPLETED, FAILED, TIMED_OUT, TERMINATED, or STUCK.
expect.outputKey by key, compared as JSON.
expect.tasksReference name to expected final status.

A mock outcome is { "output": {...} } (implicitly COMPLETED) or { "status": "FAILED" \| "FAILED_WITH_TERMINAL_ERROR" \| "TIMED_OUT", "reason": "...", "output": {...} }.

Building a case from parts

nf test --definition checkout.json \
        --input '{"orderId":"A-1"}' \
        --mocks '{"charge":{"output":{"txnId":"t1"}}}'

Unmocked tasks

A task with no mock completes with an empty output and is listed in unmocked. The human-readable output says so:

✓ tests/partial.json  COMPLETED  4 ms
    note: completed with empty output because nothing mocked ship, notify

Assert on it in code with @node-flow-dev/testkit:

const result = await simulate(definition, { input, mocks });
expect(result.unmocked).toEqual([]);

--json output

nf test ./tests/ --json
{"file":"tests/saga.json","passed":false,"status":"FAILED","output":{},"problems":["task cancel_hotel is never run, expected COMPLETED"],"unmocked":[]}

One object per line, so it streams into a reporter.

nf test applies the same registration rules the server does. A javascript SWITCH evaluator, a value-param naming no input parameter, or a Conductor-style $.loop['iteration'] < 3 loop condition all throw here — because a simulation that accepted what the server refuses would pass tests for a workflow that can never be deployed.


In CI

.github/workflows/workflows.yml
name: workflows
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with: { node-version: 24 }

      # No server, no database, no containers.
      - run: npx --yes @node-flow-dev/cli@1.0.0 test ./workflows/tests/

  deploy:
    needs: validate
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    env:
      NF_URL: ${{ secrets.NF_URL }}
      NF_API_KEY: ${{ secrets.NF_API_KEY }}
      NF_NAMESPACE: production
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with: { node-version: 24 }
      - run: npx --yes @node-flow-dev/cli@1.0.0 workflows register ./workflows/

Command summary

CommandTalks toPurpose
nf bootstrap --namespace <slug>PostgresCreate a namespace and an admin API key.
nf create-user --namespace <slug> --email … --password …PostgresCreate a human account.
nf migratePostgresApply pending migrations.
nf workflows listAPILatest version of every workflow.
nf workflows get <name> [--version N]APIA definition, as JSON.
nf workflows register <file|dir>…APIRegister definitions.
nf run <workflow> [--input …] [--wait S]APIStart a run, optionally waiting.
nf executions list [--workflow W] [--status S] [--limit N]APISearch runs.
nf executions get <id>APIOne run and its tasks.
nf tail <id> [--interval MS]APIFollow a run until it ends.
nf replay <id> [--version N]APIReplay against a definition version.
nf export [--workflows a,b] [--out f]APIExport a bundle.
nf import <bundle.json> [--dry-run]APIImport a bundle.
nf test <file|dir>…nothingRun workflows offline.
nf help

Next

  • API — everything the CLI does not cover.
  • Workflows — what to put in those JSON files.
  • Self-hostingnf migrate in a rollout.

On this page