Malte Buttjer · 5 August 2026

Free your Code nodes

n8n ships an MCP server, so a coding agent can edit your workflows directly. That works right up until it reaches a Code node.

Normalize orderScore risk{ }{ }normalize-order.tsscore-risk.tsone file per Code node — named after it

n8n is the best thing that happened to the boring half of my work. Hundreds of integrations somebody else keeps current. Credentials in a vault. An execution history that knows which item failed, on which node, and will retry exactly that one. A canvas a colleague can read without opening an editor. And all of it self-hostable if you want it — no lock-in.

Recent versions also ship a built-in MCP server — further than most tools have gone. Point a coding agent at it and it genuinely works: search workflows, read their structure, add nodes, rename them, wire connections, publish. For the first ten minutes it feels like the future arrived on schedule.

Then it edits a Code node, and you notice what you gave up.

A Code node is a string inside a JSON blob

In n8n, a Code node’s source lives in a jsCode field — a string, inside a node object, inside a workflow document. A perfectly reasonable way for a workflow engine to store it. A terrible way to own a few hundred lines of business logic.

Every tool you’d normally point at code stops at that boundary:

  • No diff worth reading. A rewritten node is one changed string; a 40-line logic change diffs about like a node nudged 20 pixels.
  • No types. Nothing checks that $input.first().json.customerId exists, or that the helper you pasted into four nodes still agrees with itself.
  • No sharing. Common logic is duplicated per node; a shared module has nowhere to live.
  • No review surface. The change is invisible until it runs.

The problem was never that agents write bad code. It’s that they write unreviewable code, quickly.

Two fixes that don’t work

Take MCP away. Now you’ve discarded the part that was working. Structural edits — add a node, rewire a branch, wire the error path — are tedious by hand, well-suited to an agent, and validated by the engine itself. A real capability traded for a blunt one.

Mirror the whole JSON into git. The usual “n8n as code” answer, and it is write-hostile: pushing a whole document back clobbers whatever moved on the other side, and workflow JSON churns for reasons that aren’t yours — node positions, version ids, credential references. You get a history of noise around occasional signal, and a sync direction you don’t quite trust.

Both fixes draw the boundary around the product: all of n8n, or none of it. The problem is one field wide.

Split ownership at the sharp edge

The observation everything else follows from: structure and code want different owners.

Structure is n8n’s job, and n8n is good at it — that’s the premise, not a concession. Rebuilding the canvas, the validation, the execution model in git means writing a worse engine to get better diffs. Code is git’s job: files, types, review, blame, history. n8n never claimed otherwise — storing a function as a string is the correct call for a workflow engine. Storage was never the problem. Ownership was.

So don’t take MCP away from the agent. Take one write off it. n8n-decanter — my own project — sits between agent and n8n as an MCP server of its own, forwards the entire tool surface — create, read, update, rename, connect — and refuses one write: the update_workflow that sets jsCode. The agent connects through a scaffolded .mcp.json, never holds a second set of credentials, and sees n8n’s full toolset minus one door. The structural write sails through; the source write comes back with an address:

coding agent — Order enrichment

(One other call is read before it’s forwarded: publish_workflow. Going live is gated, not owned — more under Being wrong should be cheap.)

What it writes instead

A folder per workflow, one file per Code node:

workflows/
  order-enrichment/
    workflow.json          # read-only mirror of the structure
    .decanter.json         # node id → file, per-node sync hashes
    code/
      normalize-order.ts
      score-risk.ts
shared/
  money.ts                 # imported by both, bundled in at push

workflow.json is a snapshot, never a source of truth — each Code node’s source is replaced by a pointer:

{
  "name": "Normalize order",
  "type": "n8n-nodes-base.code",
  "parameters": { "jsCode": "//@file:code/normalize-order.js" }
}

Structure still diffs cleanly in git; the code it points to lives where code belongs. When the agent adds a Code node over MCP, it arrives empty, and the first push seeds it.

Put together — who owns what, and how code reaches n8n:

coding agentany MCP clientMCP⊘ jsCode writeedits the filesn8n-decanterMCP guard✓ forwards everything⊘ refuses jsCode writespublish gated — read firstsync — the filespush → jsCodepull ← mirrorgit — owns the codecode/score-risk.tsshared/money.tsworkflow.json · mirrorPR review happens herestructure ✓push → jsCoden8n — owns the structureWebhookNormalizeScore risk — Code node[ jsCode ] — the one fielddraft first — publish is separate

The wrinkle nobody warns you about

n8n Code node source is not a module. It’s a function body: top-level return, top-level await, $input in scope from nowhere. tsc rejects a perfectly valid node file outright — TS1108: A 'return' statement can only be used within a function body.

You can’t fix the files; they must stay byte-identical to what n8n runs. So typechecking wraps each node file in an in-memory async function through a custom CompilerHost and maps the diagnostics back. On disk, verbatim; in your editor, real types, real completion on $input, real errors before anything is pushed.

TypeScript nodes compile through esbuild on push, shared/ helpers and opted-in npm dependencies inlined — each node arrives in n8n as the single self-contained function body it has to be.

Being wrong should be cheap

Guarding the write is half of it. The other half: a bad edit shouldn’t reach production.

Pushes land on the workflow’s draft. The published version keeps running, untouched. publish is a separate, deliberate act — so an agent that misunderstands the task produces a wrong draft, which is a thing you read, not a thing your customers hit.

A push also runs two independent gates:

  1. Compliance. Structural violations are hard errors — including a $('Some node') call naming a node the workflow doesn’t have. --force does not bypass this one: if it were bypassable it would be bypassed.
  2. Per-node drift. If the remote code moved off the hash recorded at last sync, the push aborts rather than overwrite someone. This one --force does bypass, because “I know, do it anyway” is a legitimate thing to mean.

The split matters more than either gate: one is a rule about correctness and isn’t negotiable; the other is a warning about concurrency and sometimes is.

Going live runs the correctness check again — also when the agent skips the CLI and calls publish_workflow itself. That’s the guard’s second refusal, for a boring reason: a gate you can walk around isn’t a gate. And it fails closed — if the draft can’t be read, nothing ships, and the error blames the check, not the workflow. “Couldn’t verify, so we shipped it” is the one outcome a gate must never have.

The whole shape of it — an edit that looked fine going in, a publish that isn’t allowed to be, production serving the old version the entire time:

coding agent — going live

What it is, plainly

A CLI. MIT, on npm, one process next to a git repo you already have. init scaffolds, pull/push sync, watch pushes on save, diff shows drift, mcp connect is the guard. Works against any n8n new enough to ship the MCP server, cloud or self-hosted — no plugin, no fork, nothing changes for the rest of the team. No service in the middle: nothing hosted, credentials stay on your machine, delete it tomorrow and your workflows keep running. n8n stays the product. This is a bracket around one field.

It’s for people whose Code nodes stopped being one-liners: real logic across a handful of nodes, a repo you already review in, an agent you’d rather keep than confine. It isn’t for workflows that are all integrations and barely any code — three return items one-liners don’t need a repo — and it isn’t a deployment tool: it won’t promote workflows between environments or manage credentials. Those are n8n’s problems, and n8n has its own answers.

What I’d flag before you trust it

It’s pre-1.0, and the data model may still move in minor versions. Beyond that, the honest sharp edges:

  • The guard is policy, not security. It stops an agent that goes through decanter; an agent handed your raw n8n credentials writes whatever it likes. For keeping a cooperating agent inside the lines, not for containing a hostile one.
  • Pull re-baselines even on conflict. After a conflicting pull, the next push overwrites the remote edit. Deliberate — files are the source of truth — but it will surprise you once.
  • TypeScript is one-way. n8n stores the compiled JS; the browser shows output, not source.
  • n8n takes a single-writer lock. A human with the workflow open in the editor will fail your push. Correct behaviour, occasionally annoying behaviour.

Where this leaves the agent

Restructuring a workflow, wiring a new branch, moving a step, fixing the error path — the agent keeps all of it, through the engine’s own validated API. And the part that needed review gets review, because it’s a file in a pull request instead of a string in a payload.

That’s the whole idea. Not don’t let agents touch it. Just: don’t let the part that needs a diff live somewhere a diff can’t reach.


n8n-decanter is my own project — MIT-licensed and on npm. The getting-started guide takes about five minutes against an existing instance, and the agent setup covers wiring the guard into Claude Code or any other MCP client.