---
name: n8n-code-to-native
description: Convert n8n Code (JS) nodes into native declarative nodes (Set / Filter / Merge / Crypto / Aggregate / ConvertToFile) so the workflow is readable by n8n-experts who don't know JavaScript. Use when the user asks to "原生化", "改成 n8n 原生 node", "reduce Code nodes", or refactor an n8n workflow JSON to remove JS where possible.
---

# n8n Code → Native node refactor

The audience for these workflows is **n8n experts who read the canvas but cannot read JavaScript**. A Code node is opaque to them. Replacing it with declarative native nodes — even if the result has more nodes — is the right trade-off.

## Hard rules

1. **Every conversion bumps the workflow's sticky note** with a `v<N> <date>: ...` change-history line. The first sticky note in the file is the change log. Do not skip this — it is the audit trail.
2. **Never break in-place semantics without confirming with the user.** If a single Code node becomes 1 native node → safe (swap-in-place, no connection edits). If it becomes 2+ nodes → confirm scope first (connections must be re-wired).
3. **Connections in n8n JSON are by node *name*.** Keep the node name identical when swapping types in-place; connections will survive automatically.
4. **n8n expressions support array methods.** `.map`, `.reduce`, `.filter`, `.includes`, regex `.match`, `Math.max(...arr)`, ternaries, optional chaining `?.`, nullish `??`, spread `...` — all work inside `={{ ... }}`. Use them. They are NOT "code" — they are expressions inside declarative nodes.
5. **Do NOT introduce IIFE `(() => { ... })()` in expressions.** That is JS in disguise and defeats the purpose. If the logic needs IIFE, either (a) split into two sequential Set nodes so each step is one expression, or (b) keep it as a Code node.
6. **Keep what genuinely belongs in code.** See "Keep as Code" below. Forcing every Code node into native makes things worse.

## Process

### Step 1 — Inventory

For each workflow JSON, list every `n8n-nodes-base.code` node with its name and JS body. Group by intent (callback builder, parser, aggregator, file builder, …). Useful Python:

```python
import json, glob
for f in sorted(glob.glob("*.json")):
    d = json.load(open(f, encoding="utf-8"))
    codes = [n for n in d["nodes"] if n["type"] == "n8n-nodes-base.code"]
    for n in codes:
        print(f, "::", n["name"], "::", n["parameters"]["jsCode"][:120])
```

### Step 2 — Classify each Code node

| Pattern | Action |
|---|---|
| Returns one item with field assignments (constants + expressions) | **Set (swap-in-place)** |
| Reduces array → scalar (sum/count/max/avg) for fields | **Set with `.reduce/.map/Math.max` expression** |
| Filters items by criteria | **Filter node** (in-place) |
| Pairs items from two upstream nodes by index | **Merge (mode: combine, combineBy: position)** + Set (structural, 2 nodes) |
| Writes file from string | **Set (build content)** + **Convert to File** (structural, 2 nodes) |
| Parses LLM JSON with multi-shape fallback (try/catch over Ollama/OpenAI shapes, regex extract on parse failure) | **Keep as Code** — fallback logic is too branchy for expressions |
| Pure-JS crypto polyfill (SHA-1, UUID v5) | **Crypto node** (n8n native) for SHA-1; UUID v5 needs Crypto + Set + bit-twiddling expressions — feasible but separate effort. Default: keep |
| Dynamic nested object/filter assembly with conditional branches (e.g. Qdrant filter built from for-loops) | **Keep as Code** |

### Step 3 — Convert in-place (Set node, typeVersion 3.4)

The Set v3.4 node shape used in these workflows:

```json
{
  "name": "Build Callback",
  "type": "n8n-nodes-base.set",
  "typeVersion": 3.4,
  "parameters": {
    "assignments": {
      "assignments": [
        { "id": "a1", "name": "folder_path",  "value": "={{ $('Extract Payload').first().json.folder_path }}", "type": "string" },
        { "id": "a2", "name": "chunk_count",  "value": "={{ $input.all().reduce((s, x) => s + (x.json.chunk_count || 0), 0) }}", "type": "number" },
        { "id": "a3", "name": "results",      "value": "={{ $input.all().map(i => i.json).filter(r => r.file_name) }}", "type": "array" },
        { "id": "a4", "name": "status",       "value": "Step 03 進行中", "type": "string" }
      ]
    },
    "options": {}
  }
}
```

- `value` starting with `=` is treated as an expression. Otherwise it is a literal.
- `type` must match the expression's actual return: `string`, `number`, `boolean`, `array`, `object`.
- For empty literal arrays/objects, use `={{ [] }}` / `={{ {} }}` (expression returning the value) with `type: "array"` / `"object"`.
- Newlines inside expression-built strings: use `\\n` in the JSON source (which encodes to `\n` in the actual string value).
- Preserve every other field on the node (`id`, `position`, `credentials`, `disabled`, `notes`, `webhookId`, …) — only change `type`, `typeVersion`, and `parameters`. Conversions are surgical.

### Step 4 — Update the sticky note (MANDATORY)

The first sticky note in the workflow is the change log. Append a line like:

```
--- v1.0.2 2026-05-30: Code -> Set (Build Callback x5 + Build Exclude Request + Build RAG Context, native expressions with .reduce/.map/.filter)
```

Format: `v<file-internal-version> <YYYY-MM-DD>: <what changed in plain language>`. Do **not** rename the workflow file (filename version stays — internal patch bump only). If the file has no sticky note, create one — that file was non-conformant.

### Step 5 — Verify before committing

- Re-read the node JSON: type is `n8n-nodes-base.set`, typeVersion is `3.4`, parameters has `assignments.assignments[]`.
- Node count and connections unchanged for in-place swaps (count by `n['type']` before/after; connections map is by name → still valid).
- Manually import one converted workflow into n8n and run an execution if the user can spare the time; otherwise list the converted nodes in the commit message so the user can spot-check.

## What to KEEP as Code (do not convert)

1. **LLM output parsers** with multi-shape fallback. Example pattern:
   ```js
   const raw = r.message?.content || r.choices?.[0]?.message?.content || r.response || '';
   let s = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
   let parsed; try { parsed = JSON.parse(s); } catch (e) { const m = s.match(/\[[\s\S]*\]/); ... }
   ```
   The branchy try/catch + regex-extract fallback is unreadable as a chain of native nodes.

2. **Pure-JS crypto polyfills.** n8n sandbox blocks `require('crypto')`. SHA-1 and UUID v5 implementations are long but they belong in Code (or can be replaced by the native `Crypto` node — separate, deeper refactor).

3. **Dynamic nested object assembly.** Building something like a Qdrant `filter: { should: [...] }` where the `should` array is built from a for-loop over field names × keywords is genuinely loop-shaped. Native node doesn't model this naturally.

4. **Per-item branching on shape detection.** e.g. `if Array.isArray(item.json) ... else if Array.isArray(item.json.data) ... else if (item.json.text) ... else continue`. An IF node only branches the whole stream, not per-item.

## n8n expression cheat sheet

- Current item: `{{ $json.x }}` (NOT `$.x`)
- All items from current input: `{{ $input.all() }}` → returns `[{json,binary}, ...]`
- First item: `{{ $input.first().json.x }}`
- Reference another node by name: `{{ $('Node Name').first().json.x }}` or `{{ $('Node Name').all() }}`
- Array methods: `{{ $input.all().map(i => i.json.x) }}`, `{{ ... .filter(x => x > 0) }}`, `{{ ... .reduce((s, x) => s + x.json.n, 0) }}`
- Conditionals: `{{ x > 0 ? 'pos' : 'neg' }}`, `{{ x?.y ?? 0 }}`
- Math: `{{ Math.max(...arr) }}`, `{{ Math.round(n) }}`
- String building: `{{ 'Got ' + n + ' items' }}` or backticks via `{{ \`Got ${n} items\` }}` (works in expressions)

## Common pitfalls

- **`includeOtherFields` semantics**: by default Set v3.4 returns ONLY the assigned fields. To preserve input fields and add new ones, set `parameters.includeOtherFields: true` at the parameter level (NOT inside `options`). Verify in the n8n UI after import.
- **`$('NodeName').first()` errors** if the referenced node hasn't run on this execution path. Wrap with `?.`: `{{ $('X')?.first()?.json?.y ?? 0 }}`. If the original Code wrapped this in try/catch, that's a signal the node may be optional — preserve safety.
- **Sticky note encoding**: workflow JSONs are UTF-8. When editing via Python, open with `encoding='utf-8'` and dump with `ensure_ascii=False` to keep Chinese readable in the JSON.
- **Don't rename nodes.** Connections key by node name. Renaming requires also updating `d['connections']`. Not worth it.
- **`Math.max(...[])` returns `-Infinity`.** Guard with `(arr.length ? Math.max(...arr) : 0)`.

## When user asks "原生化" — recommended interaction

1. Inventory all Code nodes; classify by the table above.
2. Report counts: how many fall in each category (in-place Set / structural / keep).
3. Recommend doing in-place swaps first as Phase 1 (safe, no connection edits, fast).
4. For structural (Phase 2) and complex (Phase 3), get explicit go-ahead per node — they involve adding nodes or non-trivial expression chains.
5. After each phase: commit with a clear message listing what converted, what stayed and why; push.
