I built a client-onboarding workflow in n8n: one intake form in, six artifacts out — a client folder, a welcome doc, a contract from a template, a real .ics calendar invite, a CRM row, and a task checklist with dates computed off the kickoff.
It worked on the first client. It worked on the third client. On the second one it wrote every file to the wrong place and told me everything was fine.
The baseline, because "saves hours" is not a number
Before automating anything I did the same onboarding by hand with a stopwatch running, and I gave the manual version every advantage: templates already open, find-and-replace instead of typing, no interruptions.
2 minutes 58 seconds. That is the honest number, not the four hours a landing page would claim.
Building the workflow took 2:35 from blank canvas to a live form on a production URL. Running it took 17 seconds, and that clock starts when the form opens and stops when the last file hits disk — my typing included.
The shape
One Form Trigger, then five Code nodes, each writing one artifact and passing the item down the chain. No SaaS, no AI, no npm packages — fs and path, both already allowlisted in a self-hosted n8n.
The first node is the one that matters here:
const fs = require('fs');
const path = require('path');
const base = $env.EP09_DIR;
const j = $input.first().json;
const dir = path.join(base, 'clients', j['Company name']); // <- the bug
fs.mkdirSync(dir, { recursive: true });
Read that and it looks fine. The company name becomes the folder name. Obvious, readable, and it worked perfectly for Maple Street Studio.
Client two
The second client was Acme/West Coast Consulting.
mkdirSync with recursive: true did exactly what it is documented to do. The slash is a path separator, so it created clients/Acme/, then West Coast Consulting/ inside it, and dropped all six files two levels deep.
No error. No warning. The workflow reported success, the form showed the confirmation page, and n8n's execution list showed a green run. The only way to know was to go and look at the disk.
That is the part worth sitting with. This is not a crash you find in a log. Every signal your automation gives you says it worked, because from the code's point of view it did work — it created the directory it was asked to create.
The fix is one line
const slug = j['Company name']
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const dir = path.join(base, 'clients', slug);
Acme/West Coast Consulting becomes acme-west-coast-consulting. One folder, correct level, files where the next step expects them.
Note it is an allowlist, not a blocklist: strip everything that is not a lowercase letter or a digit, rather than trying to enumerate the characters that cause trouble. If you go the other way you will remember / and forget .., and .. is a considerably worse afternoon.
path.join will not save you here either — joining a segment that itself contains separators is not an error, it is just a longer path.
The general version
Anything a user types that ends up in a filesystem path, a URL, a shell argument or a database key needs to be normalised at the boundary, and the failure mode is usually silence rather than an exception. A crash is a gift. Files landing one directory deeper than you expected, with a green checkmark on the run, is the expensive version.
Two cheap habits that would have caught it:
-
Assert the shape of what you produced, not just that the step ran. One line — does
clients/<slug>exist and contain six files? — turns a silent misfile into a failed execution. - Put a hostile name in your test data. My test client list was three tidy company names. The moment one of them contained a slash, the bug surfaced in seconds. It would have surfaced just as fast on day one if the list had been hostile from the start.
The workflow JSON and the templates are in the build repo: https://github.com/Ships-Itself/builds/tree/main/ep09-client-onboarding
Everything above was measured on camera in one sitting — the manual baseline, the build time, the 17 seconds, and the wrong folder.
Top comments (0)