Node Flowdocs

Authoring workflows

The JSON DSL in full — every field, every operator, and a worked example of each.

A workflow definition is a JSON document. This page is the reference for every field it may carry, and for each control-flow operator: what it does, what it requires, what it outputs, and a complete example you can register as-is.

Everything here is checked against the zod schemas in @node-flow-dev/core and the blueprint compiler in @node-flow-dev/engine. Where the compiler rejects something at registration, that is called out — those are the errors you want, because the alternative is a workflow that runs and quietly does the wrong thing.

The definition

{
  "name": "fulfil_order",
  "version": 1,
  "description": "Charges, ships, and unwinds itself if shipping fails.",
  "ownerEmail": "payments@example.com",
  "tags": ["team:payments"],

  "inputParameters": ["orderId", "amount"],
  "inputSchema": { "$ref": "order_input" },
  "variables": { "stage": "new" },

  "tasks": [ /* ... */ ],

  "outputParameters": { "receipt": "${charge.output.txnId}" },
  "outputSchema": { "$ref": "order_output" },

  "failureWorkflow": "order_failed",
  "failureWorkflowVersion": 2,

  "restartable": true,
  "timeoutSeconds": 3600,
  "timeoutPolicy": "TIME_OUT_WF",

  "maxConcurrentExecutions": 0,
  "maxConcurrentTasks": 50,
  "rateLimitConfig": { "rateLimitKey": "${workflow.input.customerId}", "concurrentExecLimit": 1 },

  "maskedFields": ["cardNumber", "cvv"]
}
FieldTypeDefaultMeaning
namestringRequired. ^[a-zA-Z_][a-zA-Z0-9_.-]*$, ≤255 chars.
versionint ≥ 11Immutable once registered.
descriptionstring≤4000 chars.
taskstask[]Required, at least one.
inputParametersstring[]Documentation of expected input keys. Not enforced — use inputSchema for that.
outputParametersobjectThe run's output, as expressions. Resolved key by key when it completes.
variablesobjectInitial workflow variables, mutable at runtime by SET_VARIABLE.
inputSchemaJSON or a registered schema nameThe run's input is validated against it at start; a mismatch is a 400 naming the field.
outputSchemaJSON or a registered schema nameAccepted and stored. Not currently enforced at completion.
failureWorkflowstringStarted when this run ends FAILED or TIMED_OUT.
failureWorkflowVersionint ≥ 1latestPins the handler's version.
restartablebooleantrueAccepted and stored; not currently consulted by the operator actions.
timeoutSecondsnumber ≥ 00Whole-run budget. 0 disables. When it fires the run ends TIMED_OUT and its failure workflow starts.
timeoutPolicyenumTIME_OUT_WFAccepted and stored. The task definition's timeoutPolicy is what the decider consults; a whole-run timeout always ends the run.
maxConcurrentExecutionsint ≥ 00Cap on live executions of this definition. 0 disables.
maxConcurrentTasksint ≥ 00Cap on in-flight tasks within one run. Bounds fan-out. 0 disables.
rateLimitConfigobjectPer-key admission: { rateLimitKey, concurrentExecLimit }. Queued, not refused.
maskedFieldsstring[]Key names shown as *** wherever the execution is read. ≤100 entries.
ownerEmailemailContact, shown in the dashboard.
tagsstring[][]key:value. Drives tag-based access, and the api:route / mcp:tool gateways.

The task

{
  "name": "charge_card",
  "taskReferenceName": "charge",
  "type": "SIMPLE",
  "description": "Takes the money.",
  "inputParameters": { "amount": "${workflow.input.amount}" },
  "optional": false,
  "startDelaySeconds": 0,
  "retryCount": 3,
  "domain": "eu-west",
  "cacheConfig": { "key": "${workflow.input.orderId}", "ttlInSecond": 300 },
  "compensateWith": "refund_card"
}
FieldApplies toMeaning
nameallThe task definition name. For SIMPLE, the queue workers poll.
taskReferenceNameallUnique identity in this workflow. What ${...} refers to.
typeallOne of the task types.
inputParametersallArbitrary JSON with ${...} expressions at any depth.
optionalallA failure is absorbed: the task ends COMPLETED_WITH_ERRORS and the workflow carries on.
startDelaySecondsallDelay before the task becomes visible.
retryCountallOverrides only the count from the task definition.
domainworker tasksRoutes to name:domain.
cacheConfignon-operatorsReuse a previous successful output for the same resolved key. ttlInSecond 1–31,536,000. A key resolving to empty caches nothing.
compensateWithnon-operatorsSaga undo: a task definition name, or a whole task.
asyncCompleteAccepted for Conductor compatibility; currently inert. A task completed from outside is expressed with YIELD, WAIT without timing, or WAIT_FOR_WEBHOOK.
evaluatorType, expression, decisionCases, defaultCaseSWITCH
forkTasksFORK_JOIN
dynamicForkTasksParam, dynamicForkTasksInputParamNameFORK_JOIN_DYNAMIC
joinOnJOIN, EXCLUSIVE_JOIN
loopCondition, loopOverDO_WHILE
dynamicTaskNameParamDYNAMIC
subWorkflowParamSUB_WORKFLOW, START_WORKFLOW

Retry policy other than retryCountretryLogic, retryDelaySeconds, jitter, retryBudget, nonRetryableErrors — and every timeout and concurrency setting live on the task definition, not on the task inside a workflow. Those keys on a workflow task are stripped by the schema without complaint. See Execution controls.

Sequence: the default

Tasks in a list run one after another. There is no explicit edge syntax; the list is the edges.

{
  "name": "onboard",
  "version": 1,
  "tasks": [
    { "name": "create_account", "taskReferenceName": "create", "type": "SIMPLE" },
    {
      "name": "send_welcome",
      "taskReferenceName": "welcome",
      "type": "SIMPLE",
      "inputParameters": { "accountId": "${create.output.id}" }
    }
  ]
}

SWITCH

Branches on a value. The matching case runs; every other case head is marked SKIPPED so nothing downstream waits on it.

approved escalated default review completed on_decisionSWITCH pay escalate notify_rejection audit
SWITCH with two cases and a default

Required: expression or inputParameters, plus decisionCases and/or defaultCase.

Output: { "caseValue": "approved" } — always a string, or null.

The two spellings of expression

evaluatorTypeexpression isExample
value-param (default)the name of an input parameter"switchCaseValue" beside inputParameters: { "switchCaseValue": "${review.output.decision}" }
jsonpatha ${...} expression"${review.output.decision}"
javascriptrejected at registration

Both forms are accepted whatever evaluatorType says: an expression containing ${ is resolved as an expression, and one that does not is looked up as an input-parameter name. The distinction exists because almost every Conductor definition uses value-param.

A value-param expression that names no input parameter is rejected at registration: as written it would be compared as literal text, so every run would take the default case with nothing reporting a problem.

Worked example

{
  "name": "approval",
  "version": 1,
  "inputParameters": ["reviewer", "amount"],
  "tasks": [
    {
      "name": "review",
      "taskReferenceName": "review",
      "type": "HUMAN",
      "inputParameters": {
        "title": "Approve payment of ${workflow.input.amount}",
        "assignments": [{ "user": "${workflow.input.reviewer}", "slaMinutes": 120 }]
      }
    },
    {
      "name": "on_decision",
      "taskReferenceName": "on_decision",
      "type": "SWITCH",
      "evaluatorType": "value-param",
      "expression": "decision",
      "inputParameters": { "decision": "${review.output.decision}" },
      "decisionCases": {
        "approved": [
          {
            "name": "pay",
            "taskReferenceName": "pay",
            "type": "SIMPLE",
            "inputParameters": { "amount": "${workflow.input.amount}" }
          }
        ]
      },
      "defaultCase": [
        {
          "name": "notify_rejection",
          "taskReferenceName": "notify_rejection",
          "type": "SIMPLE",
          "inputParameters": { "reason": "${review.output.comment}" }
        }
      ]
    }
  ]
}

Every case rejoins the flow at whatever follows the SWITCH. If no case matches and there is no defaultCase, the run continues past the switch rather than stalling.


FORK_JOIN and JOIN

Runs branches in parallel and waits for all of them.

start enrichFORK_JOIN credit_score order_history wait_for_bothJOIN decide
FORK_JOIN over two branches

Required: forkTasks, a non-empty array of non-empty branches, and a JOIN or EXCLUSIVE_JOIN immediately after it in the same sequence. The compiler refuses anything else:

FORK_JOIN "enrich" must be followed immediately by a JOIN or EXCLUSIVE_JOIN task

JOIN's joinOn lists the references to wait on. Omit it and the join waits on each branch's last task — the tip, not the head. That distinction only becomes visible once a branch has more than one task, and getting it wrong would fire the join as soon as every branch had started.

Outputs:

  • FORK_JOIN{ "forkedBranches": ["credit_score", "order_history"] }
  • JOIN → one key per joined reference, holding that task's whole output: { "credit_score": { "score": 720 }, "order_history": { "orders": [] } }

So ${wait_for_both.output.credit_score.score} works, and so does the direct ${credit_score.output.score}.

Worked example

{
  "name": "parallel_enrichment",
  "version": 1,
  "inputParameters": ["customerId"],
  "tasks": [
    {
      "name": "enrich",
      "taskReferenceName": "enrich",
      "type": "FORK_JOIN",
      "forkTasks": [
        [
          {
            "name": "credit_score",
            "taskReferenceName": "credit_score",
            "type": "SIMPLE",
            "inputParameters": { "customerId": "${workflow.input.customerId}" }
          }
        ],
        [
          {
            "name": "order_history",
            "taskReferenceName": "order_history",
            "type": "SIMPLE",
            "inputParameters": { "customerId": "${workflow.input.customerId}" }
          }
        ]
      ]
    },
    {
      "name": "wait_for_both",
      "taskReferenceName": "wait_for_both",
      "type": "JOIN",
      "joinOn": ["credit_score", "order_history"]
    },
    {
      "name": "decide",
      "taskReferenceName": "decide",
      "type": "SIMPLE",
      "inputParameters": {
        "score": "${credit_score.output.score}",
        "orders": "${order_history.output.orders}"
      }
    }
  ]
}

Branches may contain anything, including nested forks and switches. A nested fork's branch tip is its own inner JOIN, which is exactly the node whose completion means the branch is finished.

Use maxConcurrentTasks on the definition to bound how many branch tasks may be in flight at once. Operators are exempt from the cap — blocking them would stall the control flow that decides what runs next.


EXCLUSIVE_JOIN

Merges branches of which exactly one ran. Its natural partner is SWITCH, where the untaken branches are SKIPPED and would never satisfy a plain JOIN.

left default pickSWITCH left right mergeEXCLUSIVE_JOIN continue
SWITCH followed by EXCLUSIVE_JOIN

Required: joinOn naming the branch references.

Output: the taken branch's output directly, not keyed by branch — so ${merge.output.side} reads the field the branch that actually ran produced. Keying it would force the caller to know which branch ran, which defeats the operator.

It completes as soon as one of its joinOn references is COMPLETED; branches that were SKIPPED are ignored.

{
  "name": "route_and_merge",
  "version": 1,
  "inputParameters": ["route"],
  "tasks": [
    {
      "name": "pick",
      "taskReferenceName": "pick",
      "type": "SWITCH",
      "evaluatorType": "value-param",
      "expression": "switchCaseValue",
      "inputParameters": { "switchCaseValue": "${workflow.input.route}" },
      "decisionCases": {
        "left": [
          {
            "name": "left",
            "taskReferenceName": "left",
            "type": "INLINE",
            "inputParameters": { "expression": "return { side: 'left' };" }
          }
        ]
      },
      "defaultCase": [
        {
          "name": "right",
          "taskReferenceName": "right",
          "type": "INLINE",
          "inputParameters": { "expression": "return { side: 'right' };" }
        }
      ]
    },
    {
      "name": "merge",
      "taskReferenceName": "merge",
      "type": "EXCLUSIVE_JOIN",
      "joinOn": ["left", "right"]
    }
  ],
  "outputParameters": { "side": "${merge.output.side}" }
}

FORK_JOIN_DYNAMIC

Fans out over a list computed at runtime. The branches do not exist in the definition — their count and names come from a previous task's output — so they are materialised during evaluation.

planproduces tasks + inputs fanFORK_JOIN_DYNAMIC item_0 item_1 item_n ... joinJOIN summarise
Dynamic fan-out over a runtime list
FieldDefaultHolds
dynamicForkTasksParamdynamicTasksThe name of the input parameter holding the task list.
dynamicForkTasksInputParamNamedynamicTasksInputThe name of the input parameter holding a map of reference name to that task's input.

Each entry of the task list is an object with at least name and taskReferenceName; type defaults to SIMPLE, and domain defaults to the fork's own domain so a routed fork does not scatter its branches across the shared pool. Entries missing a name or a reference name are skipped.

Output: { "forkedTaskRefs": ["item_0", "item_1", "item_2"] }. That is the only place the downstream JOIN can learn what to wait for, so a JOIN after a dynamic fork normally has no joinOn — it reads the refs from the fork's output.

An empty or absent list is legitimate: the join is satisfied immediately rather than waiting for nothing.

Worked example

{
  "name": "process_documents",
  "version": 1,
  "inputParameters": ["names"],
  "tasks": [
    {
      "name": "plan",
      "taskReferenceName": "plan",
      "type": "INLINE",
      "inputParameters": {
        "names": "${workflow.input.names}",
        "expression": "return { tasks: $.names.map((n, i) => ({ name: 'handle_document', taskReferenceName: 'item_' + i, type: 'SIMPLE' })), inputs: Object.fromEntries($.names.map((n, i) => ['item_' + i, { document: n }])) };"
      }
    },
    {
      "name": "fan",
      "taskReferenceName": "fan",
      "type": "FORK_JOIN_DYNAMIC",
      "dynamicForkTasksParam": "dynamicTasks",
      "dynamicForkTasksInputParamName": "dynamicTasksInput",
      "inputParameters": {
        "dynamicTasks": "${plan.output.tasks}",
        "dynamicTasksInput": "${plan.output.inputs}"
      }
    },
    { "name": "join", "taskReferenceName": "join", "type": "JOIN" }
  ]
}

Nothing static-analysed these branches, so a malformed entry is a runtime problem rather than a registration error — and a dynamically forked task that fails fails the workflow, because there is no declared node carrying an optional flag to consult.


DO_WHILE

Repeats a body. The DO_WHILE task stays open for the whole loop and completes when the condition stops holding.

true false enter loop loopOver bodyiteration 1, 2, 3 ... loopCondition DO_WHILE completesoutput.iteration = last pass next task
DO_WHILE

Required: a non-empty loopOver, and a loopCondition.

Output on completion: { "iteration": 3 } — the number of passes that ran.

The condition grammar

The pure engine does not execute scripts, so conditions are a small, explicit grammar:

true
false
<operand> <op> <operand>      where op is one of  <  <=  >  >=  ==  ===  !=  !==

An operand may be:

  • a number literal (3, 1.5);
  • ${anyRef.output.iteration}, which resolves to the current pass number;
  • any ${...} expression, resolved and coerced to a number when it looks like one, otherwise compared as a string with surrounding quotes stripped;
  • a bare word, treated as a string literal — so ${charge.output.status} == PAID works as written.

An operand that cannot be resolved exits the loop rather than spinning forever. That is the safe direction to fail.

Registration refuses two condition shapes, because both silently run the loop exactly once:

  • no comparison at all${check.output.done} on its own;
  • an operand in another syntax$.loop['iteration'] < 3, which is how Conductor's JavaScript conditions look and therefore what people migrating paste. Written that way it is compared as literal text, which is never less than 3.

Iteration counting

${loopRef.output.iteration} is the number of passes completed, so ${each_item.output.iteration} < 3 runs the body three times: after passes 1 and 2 the condition holds, after pass 3 it does not. Inside the body the same reference reads as the iteration currently running.

Every task in the body gets its own row per pass, distinguished by iteration, so an execution shows handle_item, handle_item#1, handle_item#2 and so on.

Worked example

{
  "name": "process_batch",
  "version": 1,
  "inputParameters": ["itemCount"],
  "tasks": [
    {
      "name": "each_item",
      "taskReferenceName": "each_item",
      "type": "DO_WHILE",
      "loopCondition": "${each_item.output.iteration} < ${workflow.input.itemCount}",
      "loopOver": [
        {
          "name": "handle_item",
          "taskReferenceName": "handle_item",
          "type": "SIMPLE",
          "inputParameters": { "index": "${each_item.output.iteration}" }
        }
      ]
    }
  ],
  "outputParameters": { "processed": "${each_item.output.iteration}" }
}

A loop driven by data rather than a count reads a flag the body produces:

{
  "name": "drain_pages",
  "version": 1,
  "tasks": [
    {
      "name": "pages",
      "taskReferenceName": "pages",
      "type": "DO_WHILE",
      "loopCondition": "${fetch_page.output.hasMore} == true",
      "loopOver": [
        {
          "name": "fetch_page",
          "taskReferenceName": "fetch_page",
          "type": "SIMPLE",
          "inputParameters": { "page": "${pages.output.iteration}" }
        }
      ]
    }
  ]
}

Only the current iteration's tasks are ever loaded, so a loop that has run 10,000 times costs exactly as much to evaluate as one that has run once.


DYNAMIC

Chooses which task definition to run at runtime — Conductor's function pointer.

Field: dynamicTaskNameParam, defaulting to taskToExecute. The named input parameter's value is the task definition name that will actually be dispatched. The task runs as a SIMPLE task on that queue.

If the parameter is missing or is not a string, the node's own name stands and the failure surfaces as an unknown task rather than as a silent no-op.

{
  "name": "route_by_country",
  "version": 1,
  "inputParameters": ["country", "payload"],
  "tasks": [
    {
      "name": "handler",
      "taskReferenceName": "handler",
      "type": "DYNAMIC",
      "dynamicTaskNameParam": "taskToExecute",
      "inputParameters": {
        "taskToExecute": "handle_${workflow.input.country}",
        "payload": "${workflow.input.payload}"
      }
    }
  ],
  "outputParameters": { "result": "${handler.output.result}" }
}

With country: "uk" that leases from the queue handle_uk.


SUB_WORKFLOW

Runs another workflow as a child and waits for it. The parent task sits IN_PROGRESS for the child's whole lifetime, costing no worker and no lease.

SUB_WORKFLOW task scheduled start child, same transaction runs its own tasks child reaches a terminal status parent task completes with the child's output parent task IN_PROGRESS a failed child fails the parent task,which then retries per policy Parent run Engine Child run
SUB_WORKFLOW

Required: subWorkflowParam.name. The compiler refuses a SUB_WORKFLOW without it, because it would start no child and so never be completed by one — the run would hang with no error anywhere.

FieldMeaning
subWorkflowParam.nameThe child definition.
subWorkflowParam.versionPins a version. Omitted means the latest at start.
subWorkflowParam.taskToDomainRoutes the child's worker tasks, so it lands on the same fleet as the parent.

The child's output becomes the task's output, so ${child.output.doubled} reads the child's outputParameters.

A failed child fails the parent task, which then obeys the parent task's retry policy — and a retry starts a genuinely new child, with a fresh idempotency key that includes the attempt.

{
  "name": "order_with_fulfilment",
  "version": 1,
  "inputParameters": ["orderId"],
  "tasks": [
    {
      "name": "fulfil",
      "taskReferenceName": "fulfil",
      "type": "SUB_WORKFLOW",
      "retryCount": 1,
      "subWorkflowParam": {
        "name": "fulfilment",
        "version": 3,
        "taskToDomain": { "*": "eu-west" }
      },
      "inputParameters": { "orderId": "${workflow.input.orderId}" }
    }
  ],
  "outputParameters": { "trackingId": "${fulfil.output.trackingId}" }
}

START_WORKFLOW

Starts another workflow and does not wait. The task is done the moment the start is issued, and the child's fate cannot affect this run.

Required: subWorkflowParam.name — same compiler check, for the same reason: without it the task would report success having started nothing.

Output: { "started": "notify_customer" }.

The child's input is this task's resolved inputParameters, and the start is keyed on workflowId:refName:iteration so a replayed evaluation cannot start it twice.

{
  "name": "order_placed",
  "version": 1,
  "tasks": [
    {
      "name": "kick_off_analytics",
      "taskReferenceName": "kick_off_analytics",
      "type": "START_WORKFLOW",
      "subWorkflowParam": { "name": "record_order_metrics" },
      "inputParameters": { "orderId": "${workflow.input.orderId}" }
    }
  ]
}
SUB_WORKFLOWSTART_WORKFLOW
Waits for the childyesno
Child failure fails the parentyesno
Child output availableyesno
Parent task status while child runsIN_PROGRESSalready COMPLETED

TERMINATE

Ends the run immediately, with a status you choose.

Input parameterMeaning
terminationStatusCOMPLETED, FAILED or TERMINATED. Defaults to COMPLETED.
terminationReasonRecorded as reasonForIncompletion for the non-completed statuses.
workflowOutputUsed as the run's output, for COMPLETED. Must be an object.

Anything after a TERMINATE on the taken path never runs.

{
  "name": "screen_application",
  "version": 1,
  "tasks": [
    {
      "name": "check",
      "taskReferenceName": "check",
      "type": "SIMPLE"
    },
    {
      "name": "gate",
      "taskReferenceName": "gate",
      "type": "SWITCH",
      "evaluatorType": "value-param",
      "expression": "eligible",
      "inputParameters": { "eligible": "${check.output.eligible}" },
      "decisionCases": {
        "false": [
          {
            "name": "stop",
            "taskReferenceName": "stop",
            "type": "TERMINATE",
            "inputParameters": {
              "terminationStatus": "COMPLETED",
              "workflowOutput": { "decision": "not eligible" }
            }
          }
        ]
      }
    },
    { "name": "underwrite", "taskReferenceName": "underwrite", "type": "SIMPLE" }
  ]
}

TERMINATE with TERMINATED does not trigger the failure workflow and does not start compensation. A terminated run was stopped on purpose, and undoing its work is a decision for whoever stopped it. FAILED does both.


SET_VARIABLE

Writes workflow-scoped variables. Every input parameter becomes a variable of that name.

Output: the same object it was given.

The write is visible immediately, including to the very next task in the same evaluation pass — setting a variable and using it in the next step is the whole idiom, and it would be useless if the value only appeared a pass later.

{
  "name": "staged",
  "version": 1,
  "variables": { "stage": "new" },
  "tasks": [
    {
      "name": "mark_shipped",
      "taskReferenceName": "mark_shipped",
      "type": "SET_VARIABLE",
      "inputParameters": { "stage": "shipped", "shippedAt": "${workflow.input.now}" }
    },
    {
      "name": "read",
      "taskReferenceName": "read",
      "type": "INLINE",
      "inputParameters": {
        "expression": "return { seen: $.v };",
        "v": "${workflow.variables.stage}"
      }
    }
  ],
  "outputParameters": {
    "viaTask": "${read.output.seen}",
    "direct": "${global.stage}"
  }
}

Both ${global.stage} and ${workflow.variables.stage} read the same value.


GET_WORKFLOW

Reports the run's own metadata. Useful for correlating, logging, or building a link back to the execution.

Output:

{
  "workflowId": "0193c0f1-...",
  "defName": "fulfil_order",
  "defVersion": 2,
  "status": "RUNNING",
  "correlationId": "order-12345"
}
{
  "name": "self_aware",
  "version": 1,
  "tasks": [{ "name": "meta", "taskReferenceName": "meta", "type": "GET_WORKFLOW" }],
  "outputParameters": {
    "runId": "${meta.output.workflowId}",
    "correlation": "${meta.output.correlationId}"
  }
}

YIELD

Pauses until something signals it. Unlike WAIT, nothing ends it on a schedule — it waits to be told.

Output: whatever the signal supplied.

{
  "name": "external_gate",
  "version": 1,
  "tasks": [
    { "name": "hold", "taskReferenceName": "hold", "type": "YIELD" },
    {
      "name": "after",
      "taskReferenceName": "after",
      "type": "INLINE",
      "inputParameters": { "expression": "return { resumed: $.v };", "v": "${hold.output.approved}" }
    }
  ],
  "outputParameters": { "resumed": "${after.output.resumed}" }
}

Resume it:

curl -X POST "$NF_URL/v1/ns/default/executions/$WF_ID/signal" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{"taskRef":"hold","status":"COMPLETED","output":{"approved":true}}'

taskRef is optional: without it the signal targets the first blocked WAIT or YIELD, searching running sub-workflows too. Add waitForSeconds to have the call answer with the state the signal got the run to.


NOOP

Completes immediately with an empty output. Useful as a join point, a placeholder while a workflow is being built, and a deliberate marker in a diagram.

{ "name": "gap", "taskReferenceName": "gap", "type": "NOOP" }

Compensation (saga)

compensateWith declares how to undo a task. When the run later fails, every completed task that declares one is compensated, most recently finished first, before the run ends as failed.

run COMPLETED (bookingId b-1) run FAILED_WITH_TERMINAL_ERROR run compensation for book_hotel COMPLETED set __compensation = {status, reason}cancel everything still scheduled workflow ends FAILED"card declined (compensated: book_hotel)" Run book_hotel book_flight cancel_hotel
An unwind

Two forms

A name — shorthand for "run this task definition as a SIMPLE task". The generated task has reference name {ref}__compensate and receives:

{ "input": "${book_hotel.input}", "output": "${book_hotel.output}" }

which is what an undo worker almost always needs: enough to know what to reverse.

A whole task — when the compensation needs a different shape:

{
  "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}" }
  }
}

Rules

RuleWhy
Compensations run in reverse completion order, one at a time.A later step may depend on an earlier one — the shipment on the charge — so it is undone before what it built on.
Only FAILED and TIMED_OUT trigger an unwind.A TERMINATED run was stopped deliberately.
A compensation may not be an operator.It has to do work. Registration refuses it.
A compensation may not itself declare compensateWith.Registration refuses it.
A compensation's reference name must be unique.Registration refuses a collision, naming it.
A failed compensation gets its own retries; once spent, the run fails with both reasons.An unwind that stopped halfway is exactly what someone has to go and finish by hand.
The unwind cancels whatever the failing pass meant to start.That work belongs to a run that is no longer going forward.

A run that is unwinding carries a __compensation workflow variable holding { status, reason }, so an operator can see from the execution view that a run is unwinding and why.

Worked example

{
  "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}" },
      "retryCount": 2,
      "compensateWith": "cancel_flight"
    },
    {
      "name": "charge",
      "taskReferenceName": "charge",
      "type": "SIMPLE",
      "inputParameters": { "city": "${workflow.input.city}" }
    }
  ]
}

If charge fails terminally, cancel_flight runs, then cancel_hotel, then the workflow ends FAILED with card declined (compensated: book_flight, book_hotel).


Failure workflows

A definition may name a failureWorkflow, started when a run ends FAILED or TIMED_OUT (never TERMINATED). It runs as its own execution, with its own history and retries.

Its input is the failed run's input, plus:

KeyValue
workflowIdThe id of the run that failed.
workflowTypeIts definition name.
workflowVersionIts definition version.
reasonThe failure reason.
failureStatusFAILED or TIMED_OUT.

It is started with an idempotency key derived from the failed run, so a replayed evaluation or a sweeper racing the decider starts it at most once. A definition that names itself as its own failure workflow is ignored, so a failing handler cannot start itself forever.

{
  "name": "order_failed",
  "version": 1,
  "tasks": [
    {
      "name": "page_oncall",
      "taskReferenceName": "page_oncall",
      "type": "HTTP",
      "inputParameters": {
        "uri": "https://events.pagerduty.com/v2/enqueue",
        "method": "POST",
        "body": {
          "routing_key": "${secrets.PAGERDUTY_KEY}",
          "event_action": "trigger",
          "payload": {
            "summary": "${workflow.input.workflowType} failed: ${workflow.input.reason}",
            "source": "node-flow",
            "severity": "error",
            "custom_details": { "workflowId": "${workflow.input.workflowId}" }
          }
        }
      }
    }
  ]
}

Workflow output

Without outputParameters, a run's output is the last completed task's output. That is convenient for small workflows and ambiguous for anything else, so declare it:

"outputParameters": {
  "receipt": "${charge.output.txnId}",
  "tracking": "${ship.output.trackingId}",
  "total": "${tally.output.amount}"
}

Each key is resolved independently. A key whose expression names a task that never ran — a branch not taken — resolves to null rather than failing a run that has otherwise finished.


Writing definitions in TypeScript

The JSON DSL stays the source of truth, but @node-flow-dev/sdk ships a typed builder that compiles to exactly those shapes. What it buys you is the one thing JSON cannot have: a misspelt ${charge.output.txnid} is a compile error rather than a task that quietly receives nothing.

checkout.ts
import { workflow, compensation } from '@node-flow-dev/sdk';

const flow = workflow<{ orderId: string; amount: number }>('checkout', {
  version: 1,
  description: 'Charge, then ship.',
  timeoutSeconds: 3600,
});

const charge = flow.simple<{ txnId: string }>('charge', {
  input: { amount: flow.input.amount },
  retryCount: 3,
  compensateWith: compensation((s) =>
    s.simple('refund', { input: { txn: '${charge.output.txnId}' } })
  ),
});

flow.simple('ship', { input: { txn: charge.output.txnId } });

flow.output({ receipt: charge.output.txnId });

export default flow.build(); // a WorkflowDefinition, schema-checked

Reading charge.output.txnId yields a typed reference that serialises to "${charge.output.txnId}", and reading a field the type does not declare does not compile.

The builder covers the common shapes — simple, http, inline, wait, yield, human, subWorkflow, setVariable, terminate, switch, fork, loop — and build() runs the result through workflowDefinitionSchema, so a malformed definition fails at build time rather than at registration.

flow.fork(ref, branches) adds the JOIN for you, named {ref}_join, with joinOn set to each branch's last task. flow.switch(...) emits the value-param form with an input parameter called switchCaseValue.

For anything the builder does not cover, write the JSON — the two are the same document, and you can mix a hand-written task into a built definition.


Validating before you register

# Compile-check without registering: catches duplicate refs, joins on nothing,
# a FORK_JOIN without a JOIN, a bad loop condition, a value-param that names
# no input parameter.
curl -X POST "$NF_URL/v1/ns/default/metadata/workflows/validate" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  --data @fulfil_order.json

And run it, with everything external mocked, in milliseconds:

nf test fulfil_order.test.json

See CLI.

Next

On this page