A Rubric for n8n Workflow Quality (v0)
Six dimensions I'd use to review an n8n workflow, and the workflow that applies them. Written as part of a job application to n8n — expect it to be wrong in specific ways.
I’m applying to a Director of Engineering role at n8n, and the application asks for a screenshot of a workflow you’ve built in n8n. Fair ask — it’s a workflow automation company, you want to see that the candidate has actually used the product.
Most applicants will probably submit a personal automation: a journal thing, an RSS summarizer, a Slack digest. I took a different angle: I built a workflow that reviews other n8n workflows against a rubric. To make that meaningful I had to write the rubric first, and the rubric is the part I actually want to share. The workflow is mostly scaffolding around it.
What follows is v0. I expect it to be wrong in specific ways, and I’d want to sit with someone from the Core Experience team and watch them disagree with me before calling it v1.
The six dimensions
Each is scored 1–5. For a given workflow the final score is the mean, but the dimension scores are the signal — a 4 overall with a 1 on secret hygiene is a different problem than a 4 overall with a 1 on observability.
1. Reliability. Does this workflow survive a bad day? 1: no error output is wired anywhere, and external writes would duplicate on retry. 5: error paths route to a handler via continueErrorOutput, external writes are idempotent (upsert keys, request IDs, or a check-before-write), and the workflow can re-run a failed execution without damage.
2. Secret hygiene. Where do credentials live? 1: an API key pasted into an HTTP Request header, or a token sitting inside a Set node value. 5: every credential uses n8n’s credential store, nothing secret shows up in the exported JSON, and no env-var-looking strings leak into logs.
3. AI usage. When the workflow calls an LLM, does it call it well? 1: a single free-text completion parsed downstream by string matching, default temperature, no structured output, tools with broad permissions. 5: structured output parser with a real JSON schema, temperature calibrated to the task (≤0.2 for classification, higher only where creativity is the point), tools scoped to the minimum, and the prompt actually names the tools and the response contract.
4. Observability. Can someone else — or future-you at 11pm — figure out what this workflow does? 1: nodes named “HTTP Request”, “Set”, “If”; no stickies; no tracing metadata. 5: descriptive node names, stickies that document why a section exists rather than what it does, and tracing metadata tagged so executions are filterable from the executions view.
5. Data flow correctness. The most common n8n bug I see in examples is the N-items trap: a summary node fires N times because it inherited items from a fetch upstream. 1: per-item semantics where they’re wrong, silent chain breaks when a lookup returns empty. 5: executeOnce where it belongs, alwaysOutputData where a downstream branch must run even on empty results, and Merge nodes when combining independent sources rather than daisy-chaining them.
6. Builder readability. Could I hand this workflow to a new team member? 1: one giant Code node doing five unrelated steps, branches that cross each other, nodes scattered randomly on the canvas. 5: each node has one job, branches are labeled with stickies, and the flow reads roughly left-to-right top-to-bottom so a reader’s eye knows where to go.
What’s missing
Things I left out on purpose, for now. Performance — I don’t have a good sense yet of which patterns cost real money at n8n scale. Testing — n8n has pin data and test executions, and I haven’t used them enough to form a view. Versioning and review process — that’s more about team practice than the workflow itself. Cost tracking for AI calls — overlaps with observability, but probably deserves its own dimension once there’s AI Gateway telemetry to lean on.
The workflow
The companion workflow is workflow-quality-review. POST a workflow JSON to its webhook, it feeds the JSON + rubric to an AI Agent with a structured output parser, and returns a scored markdown review with line-item suggestions. The review routes to Telegram with inline approve/reject buttons before anything further happens — I don’t want an LLM posting review comments on other people’s workflows unsupervised.
The screenshot I’m submitting with the application is of that canvas. The rubric above is what the Agent node consults. If I’m lucky the rubric provokes a conversation, and if I’m unlucky it provokes a correction — either is useful.
A deliberately bad sample
To verify the reviewer was scoring usefully I fed it a workflow that tries to fail on every dimension at once: hardcoded tokens in HTTP headers, a giant Code node that builds a prompt, calls OpenAI over fetch, and parses the response with .split(/positive|negative/i), nodes named “HTTP Request” and “HTTP Request1”, no error paths, and a per-item summary that runs N times per day. If you want to try the reviewer on something, this is the JSON I POST to the webhook:
{
"workflowJson": {
"name": "Daily User Digest",
"nodes": [
{
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "days" }] } }
},
{
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://api.example.com/users",
"headerParameters": {
"parameters": [
{ "name": "Authorization", "value": "Bearer sk-prod-REDACTED-HARDCODED-12345" }
]
}
}
},
{
"name": "Code",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const items = $input.all();\nconst summary = items.map(i => i.json.name).join(', ');\nconst prompt = `Summarize: ${summary}`;\nconst res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Authorization': 'Bearer sk-openai-HARDCODED' }, body: JSON.stringify({ model: 'gpt-4', messages: [{ role: 'user', content: prompt }] }) });\nconst data = await res.json();\nconst text = data.choices[0].message.content;\nconst parts = text.split(/positive|negative/i);\nreturn [{ json: { report: text, sentiment: parts.length > 1 ? 'mixed' : 'unknown' } }];"
}
},
{
"name": "If",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"combinator": "and",
"conditions": [
{ "leftValue": "={{ $json.sentiment }}", "rightValue": "mixed", "operator": { "type": "string", "operation": "equals" } }
]
}
}
},
{
"name": "HTTP Request1",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://hooks.slack.com/services/T00/B00/XXXXHARDCODED",
"body": "={{ $json.report }}"
}
}
],
"connections": {
"Schedule Trigger": { "main": [[{ "node": "HTTP Request", "type": "main", "index": 0 }]] },
"HTTP Request": { "main": [[{ "node": "Code", "type": "main", "index": 0 }]] },
"Code": { "main": [[{ "node": "If", "type": "main", "index": 0 }]] },
"If": { "main": [[{ "node": "HTTP Request1", "type": "main", "index": 0 }]] }
}
}
}