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 helpOr 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 helpnf 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.
| Flag | Environment | Default |
|---|---|---|
--url | NF_URL | http://localhost:3000 |
--api-key | NF_API_KEY | none — the command fails naming it |
--namespace | NF_NAMESPACE | default |
--database-url | DATABASE_URL | none |
Flags win over the environment.
export NF_URL=https://flows.example.com
export NF_API_KEY=nf_...
export NF_NAMESPACE=defaultBoth --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 migrateApplied migrations:
0036-ai
0037-trace-contextnf 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 defaultCreated 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| Flag | Meaning |
|---|---|
--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-migrate | Skip migrations. |
--json | Machine-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'| Flag | Meaning |
|---|---|
--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 listNAME 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 2JSON 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.jsonregistered 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 600193c0f1-… COMPLETED
{
"receipt": "txn_abc"
}| Flag | Meaning |
|---|---|
--input JSON or --input @file | Must be a JSON object. |
--wait [SECONDS] | Block for the result. Bare --wait is 30 s; the server caps it at 60. |
--version N | Pin a definition version. |
--correlation-id ID | Your identifier, for finding the run later. |
--json |
Exit codes, so a script can branch:
| Code | Meaning |
|---|---|
0 | COMPLETED |
1 | Reached a terminal status that is not COMPLETED |
2 | Still 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 50ID WORKFLOW STATUS STARTED
0193c0f1-… checkout v3 FAILED 2026-09-19T14:22:03.112Z
0193c0e8-… checkout v3 COMPLETED 2026-09-19T14:19:44.001Z| Flag | Meaning |
|---|---|
--workflow W | Definition name. |
--status S | RUNNING, PAUSED, COMPLETED, FAILED, TIMED_OUT, TERMINATED. |
--limit N | Default 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 4ATTEMPT 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-… transcribecancelled transcribe; workflow is PAUSED
the worker was not stopped — its result will be refused when it reportsThe 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-… transcribere-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 --cascadeThe 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 .staleDownstreamnf 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| Flag | Meaning |
|---|---|
--interval MS | Poll interval. Default 1000. |
--json | One 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 nullExits 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 stdoutA bundle carries workflow and task definitions.
nf import
nf import bundle.json --dry-run
nf import bundle.json| Flag | Meaning |
|---|---|
--dry-run | Report what it would do; change nothing. |
--workflow-conflicts skip|new-version | What to do when a name and version already exist. |
--task-conflicts skip|overwrite | Likewise 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-versionnf 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 failedExit code 1 if any expectation failed.
The test file
{
"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" }
}
}| Key | Meaning |
|---|---|
definition | The workflow, inline. |
input, variables, env | Starting state. |
mocks | Per task reference name: one outcome for every attempt, or an array — one per attempt, the last repeating. |
taskDefs | Retry 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. |
subWorkflows | Child definitions by name. They then run for real rather than being mocked. |
expect.status | COMPLETED, FAILED, TIMED_OUT, TERMINATED, or STUCK. |
expect.output | Key by key, compared as JSON. |
expect.tasks | Reference 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, notifyAssert 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
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
| Command | Talks to | Purpose |
|---|---|---|
nf bootstrap --namespace <slug> | Postgres | Create a namespace and an admin API key. |
nf create-user --namespace <slug> --email … --password … | Postgres | Create a human account. |
nf migrate | Postgres | Apply pending migrations. |
nf workflows list | API | Latest version of every workflow. |
nf workflows get <name> [--version N] | API | A definition, as JSON. |
nf workflows register <file|dir>… | API | Register definitions. |
nf run <workflow> [--input …] [--wait S] | API | Start a run, optionally waiting. |
nf executions list [--workflow W] [--status S] [--limit N] | API | Search runs. |
nf executions get <id> | API | One run and its tasks. |
nf tail <id> [--interval MS] | API | Follow a run until it ends. |
nf replay <id> [--version N] | API | Replay against a definition version. |
nf export [--workflows a,b] [--out f] | API | Export a bundle. |
nf import <bundle.json> [--dry-run] | API | Import a bundle. |
nf test <file|dir>… | nothing | Run workflows offline. |
nf help | — |
Next
- API — everything the CLI does not cover.
- Workflows — what to put in those JSON files.
- Self-hosting —
nf migratein a rollout.
