Menu

scenario

n8n-decanter scenario create <workflow> ["<slug>"] [--execution <id>] [--scaffold] [--json]
n8n-decanter scenario create <workflow> "<slug>" --extend
n8n-decanter scenario check  <workflow> ["<slug>"] [--json]

Both take a workflow ref first. Leaving it off on a terminal opens the picker to choose one (same as every other ref-taking verb); piped/non-TTY runs still error with the usage line, so scripts and agents never block.

A scenario is a named, committed input set for your workflow — captured from a real run or scaffolded from its schemas — that the two replays, test (on your instance) and preflight --simulate (on a local engine), run and diff against. It’s the only committed pin artifact: workflows/<folder>/scenarios/<slug>.json is a self-contained, execution-shaped file, so preflight --simulate --scenario <slug> / test --scenario <slug> replay it directly, no precedence rules to reason about. Everything here is offline — no engine for scenario create/check themselves, and no LLM API or key: you (or your IDE agent) author the values.

A gap is a network node reached during a replay with no pinned data — a node added or reparametrized since the capture, or every node when building a scenario from scratch. Both replays hard-error on a gap; a scenario is how you supply the missing data as a reproducible, reviewable set.

The two replays do not demand the same nodes

This matters when you fill a scenario by hand, so it is stated plainly:

demands pin data for
preflight --simulate network nodes the capture actually reached. An unreached node is neutralized (it throws if something reaches it), so an untaken branch is exempt.
test every enabled, non-pure, non-loop-driver node — reachable or not.

test is stricter on purpose: it runs on the live instance with real credentials, so a node reached unexpectedly there would hit the real world, while a --simulate node reached unexpectedly only stops the replay.

You do not normally have to care, because scenario create --execution closes the difference for you: a pinnable node the capture never reached is pinned to an empty run and listed under _decanterScenario.notExercised. That is an honest claim (“this branch isn’t exercised”), not invented output, and it keeps the node from running for real. Review it — if a branch should have run, give it real data instead.

For a scenario written before this, or one you hand-edited, scenario check tells you where you stand and --extend fills the difference:

n8n-decanter scenario check order-sync happy-path
# ✓ scenario "happy-path": valid
# ! complete for `preflight --simulate`, but `test` needs 2 more nodes: Notify, Archive
#   → n8n-decanter scenario create order-sync "happy-path" --extend

scenario create

Two seeds, composable:

n8n-decanter scenario create order-sync "happy-path" --execution 4812
#   copies executions/4812.json -> scenarios/happy-path.json, flags the gap nodes

n8n-decanter scenario create order-sync "happy-path" --execution 4812 --scaffold
#   same, plus annotates each gap with its output JSON Schema

n8n-decanter scenario create order-sync "from-scratch" --scaffold
#   no capture: every pinnable node becomes a schema-annotated fill entry

n8n-decanter scenario create order-sync "happy-path" --extend
#   existing scenario: add the pinnable nodes it is missing, keep every value

Watch the size. A capture-seeded scenario is a verbatim copy of every item of every node — one from a busy production run can be tens of megabytes. scenario create prints the size, and warns above 1 MB, because scenarios/ is tracked: the folder-wide auto-commit that pull and push perform sweeps it into git history on the next sync, permanently.

scenario create itself never commits, so an oversized scenario is still only a file on disk when you are warned. Two ways to act on that, both immediate:

  • Cut it down by hand. Delete items from data.resultData.runData in your editor. The file is written indented for exactly this — keep at least one item per output a node actually emitted, and do not remove whole runs (a loop capture’s run counts are what mark it as a loop).
  • Do not seed from the capture at all. Delete the file and re-create it with --scaffold instead of --execution. A scaffold carries no capture data — you author the pins yourself, guided by the fill list.

There is no --trim flag, deliberately: a trimmed capture is no longer a faithful record of a real run, so it could not serve as the diff baseline that makes a scenario worth tracking.

  • <slug> names the scenario (happy-path, empty-cart, error-case) and becomes the filename (kebab-cased). Optional — omit it and the scenario is named after the execution id (scenarios/4812.json, or scaffold for a slug-less pure scaffold). Keep a library of scenarios per workflow.

  • --execution <id> seeds the scenario from a captured execution (executions/<id>.json); nodes with captured output are recorded as capture provenance, each remaining gap is listed under _decanterScenario.fill.

  • --scaffold calls n8n’s read-only MCP tool prepare_test_pin_data and annotates each gap with its output JSON Schema (expectedSchema), provenance scaffolded. It never invents values — the tool returns schemas and coverage counts only, no data (readOnlyHint: true); a person or agent still authors every value, reviewed in the diff like any other scenario edit. Composes with --execution: the capture seeds what it covers, --scaffold annotates the remaining gaps. A bare --scaffold with no --execution builds a from-scratch set where every pinnable node is a fill entry.

    It works with no instance. The fill entries come from your local workflow.json; the schemas are an annotation on top. With no N8N_HOST configured, --scaffold says so and scaffolds anyway — every node lands as provenance authored instead of scaffolded, and _decanterScenario.source still reads scaffold. That is the difference between a less-annotated scenario and no scenario at all, and it is what makes preflight --offline --simulate reachable from a train.

  • Neither --execution nor --scaffold given → defaults to the newest capture under executions/ (same as the replays’ default).

  • --json prints { slug, file, gaps, coverage } for tooling (coverage only present when --scaffold ran).

  • A capture-seeded scenario copies real captured data (which can hold credentials/PII) — scenario create prints a review warning; check before committing. It refuses to overwrite an existing scenario, so it never clobbers data you’ve filled in.

The written file is a verbatim copy of the capture (or a bare skeleton for a pure scaffold) plus a _decanterScenario block listing each gap node with its type, parameters, an inputSample, and — when scaffolded — its expectedSchema:

{
  "id": 4812,
  "data": { "resultData": { "runData": {
    "Trigger": [ /* real captured runs, untouched */ ],
    "Compute": [ /* … */ ]
    // add "Enrich Customer" here ↓
  } } },
  "_decanterScenario": {
    "source": "capture+scaffold",
    "sourceExecution": "4812",
    "createdAt": "2026-07-21",
    "workflowVersionId": "…",
    "guidance": "For each node in \"fill\", add data.resultData.runData[\"<node>\"] = [ { \"data\": { \"main\": [ [ { \"json\": { …output… } } ] ] } } ], using its type/parameters/inputSample/expectedSchema as context. Keep \"fill\" as-is — scenario check validates it. Then: preflight --simulate --scenario happy-path.",
    "fill": [
      {
        "node": "Enrich Customer",
        "type": "n8n-nodes-base.httpRequest",
        "parameters": { "url": "https://api.crm.internal/customers/{{$json.id}}" },
        "inputSample": [ { "id": 42, "email": "a@b.com" } ],
        "expectedSchema": { "type": "object", "properties": { "id": { "type": "number" }, "name": { "type": "string" } } }
      }
    ]
  }
}

You (or your agent) add each fill node’s runData using its context (type, parameters, inputSample, and expectedSchema when present), and leave fill in place — it records which nodes are synthetic (the provenance signal) and is what scenario check validates.

scenario check

Structurally validates a scenario offline — the fast loop while filling, no Docker needed:

n8n-decanter scenario check order-sync happy-path   # one scenario
n8n-decanter scenario check order-sync              # every scenario in the folder

Exits 1 if any scenario is malformed or has a fill node still empty, with a node-named error:

scenario scenarios/happy-path.json is invalid:
  - Enrich Customer run 0 item 0: each item needs a "json" field
  - incomplete: add runData for Enrich Customer (still listed in _decanterScenario.fill)
  expected per node: runData["<node>"] = [ { "data": { "main": [ [ { "json": { … } } ] ] } } ]

n8n publishes no JSON Schema for execution data — the format lives only in the n8n-workflow TypeScript types (IRunExecutionDataITaskDataINodeExecutionData). scenario check is decanter’s own structural check of the exact shape it replays. preflight --simulate --scenario and test --scenario run the same check when they load a scenario, so a bad file never reaches the engine or the instance.

The shape to match, per node:

"runData": {
  "Enrich Customer": [            // one entry per run (a normal node runs once)
    { "data": { "main": [         // outputs — ONLY index 0 is ever replayed
      [                           // the items array for that output
        { "json": { "id": 42, "name": "Ada" } }   // each item is { "json": … }
      ]
    ] } }
  ]
}

Several outputs: the sim replays them, test cannot

A node can emit on more than one output — an error output, an IF’s two branches — and your scenario may carry items on each of them. The two replay paths differ there, and scenario check tells you which one you are looking at:

  • preflight --simulate replays every populated output. Decanter owns that workflow copy, so each extra output gets its own stand-in node fed by the same input as the original — the error branch really runs.
  • test replays main[0] only. It hands n8n a pinData map, and that format is one flat items array per node: there is no output dimension to hand it. So on the instance, whatever the further outputs feed receives nothing and emits nothing — and the run still finishes “successfully”.

scenario check says so rather than letting you find out from an empty run. It warns when

  • a node in the scenario has items on more than one output, naming the indices test will drop, and
  • a node source reads a pinned node’s non-first output$('Enrich Customer').all(1) or $items('Enrich Customer', 1) — the call that quietly returns nothing on the instance. The warning also says whether the scenario covers that output, i.e. whether preflight --simulate can answer it.

Both are warnings, not errors. The way out is preflight --simulate for a branch the scenario covers, pinning that output as its own node, or running the workflow live with test instead of replaying it. The test coverage line is the same problem caught one step later.

The full loop

n8n-decanter preflight order-sync --simulate --execution 4812   # ✗ gap: Enrich Customer has no data
n8n-decanter scenario create order-sync "happy-path" --execution 4812
#   → fill scenarios/happy-path.json's runData for the flagged nodes
n8n-decanter scenario check order-sync happy-path               # ✓ valid   (offline, fast)
n8n-decanter preflight order-sync --simulate --scenario happy-path   # replay the scenario

Provenance and synthetic pins

Each node’s pins in a scenario carry a provenance: capture (real execution data — can serve as the diff baseline), authored (hand/agent-filled with no schema), or scaffolded (schema-guided fill, --scaffold’s expectedSchema). A scenario with any non-capture node is synthetic pins — both replays label the run “synthetic pins — proves executability, not output correctness”: no per-node diff is asserted, and divergence is informational, not a fail. A capture-only scenario (no fill entries left) keeps the full per-node diff and exit-1-on-divergence semantics unchanged. --json reports gain syntheticPins: boolean and provenance: Record<node, "capture"|"authored"|"scaffolded">.

Committed and reproducible

Unlike executions/ (gitignored temp data), scenarios/ is tracked in git, so a scenario-based replay is reproducible for teammates and CI. Scenarios are chosen explicitly by slug (preflight --simulate --scenario <slug> / test --scenario <slug>) — they’re named scenarios, not a “latest” default.

Migration and removed mechanisms

  • A legacy mocks/ dir (the pre-rename name) auto-migrates to scenarios/ the first time any verb touches it — a plain git-recorded rename, so history follows. It refuses when both the legacy mocks/ and scenarios/ exist (merge them by hand first). The legacy metadata key _decanterMock is still read (as _decanterScenario) for files written before the rename.
  • The legacy per-node fixtures/<node>.json mechanism and the old --pin flag are removed outright — no read path. A leftover legacy fixtures/ dir is a hard error from the compliance guard (so from push, watch, and preflight alike), naming the replacement: recreate the data as a scenario (scenario create --execution <id>), then delete the legacy fixtures/ dir.

Relation to the official n8n skills

n8n’s own n8n-workflow-lifecycle-official skill teaches agents an ephemeral in-session pin flow: prepare_test_pin_data → the agent generates values → test_workflow, per-execution, nothing persisted. Scenarios are decanter’s durable counterpart to the same tool pair: a scenario is committed, human-reviewed, reused across runs, and (when capture-seeded) diffed against real data — composing with the official flow rather than competing with it.