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.
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.customerIdexists, 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:
(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:
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:
- Compliance. Structural violations are hard errors — including a
$('Some node')call naming a node the workflow doesn’t have.--forcedoes not bypass this one: if it were bypassable it would be bypassed. - Per-node drift. If the remote code moved off the hash recorded at last
sync, the push aborts rather than overwrite someone. This one
--forcedoes 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:
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.