<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: TALVIN LEE</title>
    <description>The latest articles on Forem by TALVIN LEE (@talvin29).</description>
    <link>https://forem.com/talvin29</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897692%2F60f89918-e392-4fec-ba03-2894428daf99.jpeg</url>
      <title>Forem: TALVIN LEE</title>
      <link>https://forem.com/talvin29</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/talvin29"/>
    <language>en</language>
    <item>
      <title>How to build a configurable scoring engine in n8n (and avoid 6 footguns I hit)</title>
      <dc:creator>TALVIN LEE</dc:creator>
      <pubDate>Sat, 25 Apr 2026 15:15:36 +0000</pubDate>
      <link>https://forem.com/talvin29/how-to-build-a-configurable-scoring-engine-in-n8n-and-avoid-6-footguns-i-hit-2b3e</link>
      <guid>https://forem.com/talvin29/how-to-build-a-configurable-scoring-engine-in-n8n-and-avoid-6-footguns-i-hit-2b3e</guid>
      <description>&lt;p&gt;&lt;strong&gt;Same input, same score, every time. Zero LLM cost per execution.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I shipped a scoring engine that handles both alert triage (SecOps) and lead qualification (Sales) from one workflow template. It reads rules from a Google Sheet, classifies into priority tiers, routes to Slack, and writes every decision to an audit log. Two workflows total. About 16 hours of build time over 4 weeks.&lt;/p&gt;

&lt;p&gt;Open-source repo: &lt;a href="https://github.com/TALVIN29/nexus-scoring-engine" rel="noopener noreferrer"&gt;TALVIN29/nexus-scoring-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Below: the architecture, the design decisions worth stealing, and the n8n quirks I'd want someone to warn me about before I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;Most ops teams write per-domain scoring code: one ad-hoc Python script for alerts, a different rule set in HubSpot for leads, an Airtable formula for support tickets. The scoring logic is functionally identical — score against rules, classify, route — but the implementation reinvents itself per domain.&lt;/p&gt;

&lt;p&gt;So the engine is &lt;strong&gt;domain-agnostic&lt;/strong&gt;. The workflow doesn't know whether it's scoring an alert or a lead. It reads a &lt;code&gt;config_pack&lt;/code&gt; parameter, filters all config rows in the Google Sheet by that value, and runs.&lt;/p&gt;

&lt;p&gt;To switch domains, the buyer changes one Sheet column value. No JS edits, no workflow changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Two workflows. One Google Sheet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflow 1 — Core Engine (event-driven)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook → Validate → Load Config → Score → Classify → Route &amp;amp; Notify → Write Item → Audit Log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;7 stages. Fires on every incoming webhook event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflow 2 — SLA Monitor (scheduled)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Schedule (5 min) → Read Open Items → Check SLA → Escalate → Update Item → Audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cron-style. Catches drift on items that aren't acknowledged within their SLA window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sheet schema
&lt;/h2&gt;

&lt;p&gt;Six tabs in a single Google Sheets workbook:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tab&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Scoring_Rules&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rule definitions: field, operator, value, weight, active flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tier_Thresholds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Score ranges per tier per pack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification_Targets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Slack/email per tier per pack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SLA_Config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SLA minutes + max escalations per tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runtime — every scored event appended here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Execution_Log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audit — every state change appended here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every tab has a &lt;code&gt;config_pack&lt;/code&gt; column. The engine filters every read by the pack. Add a new pack (&lt;code&gt;support_tickets&lt;/code&gt;, &lt;code&gt;order_review&lt;/code&gt;, &lt;code&gt;content_moderation&lt;/code&gt;) by adding rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rule-based, not AI
&lt;/h2&gt;

&lt;p&gt;The temptation to slap an LLM on a scoring problem is real. I considered it. Here's why I didn't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Determinism&lt;/strong&gt;: Same input must produce the same score, otherwise the audit trail is useless. LLM scorers drift across runs, model versions, temperature settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: Pure arithmetic costs $0 per execution. LLM-based scoring at 100 events/day at $0.01/call = $30/month minimum. For a $49 product, that's the buyer's payback period gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auditability&lt;/strong&gt;: A rule engine's decisions are inspectable. "Why was this lead scored 80?" → "Rules 101, 103, 105, 108 fired with weights 6+8+12+15." A vector embedding can't give you that.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;LLMs are great where data is sparse and patterns are fuzzy. Scoring against known fields and known weights is the opposite of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six footguns I hit (so you don't)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. SplitInBatches loops don't aggregate downstream nodes the way you think
&lt;/h3&gt;

&lt;p&gt;I built a test data generator that loops through 7 preset payloads, fires each at the engine, then summarizes pass/fail. First version used SplitInBatches with &lt;code&gt;$('FormatNode').all()&lt;/code&gt; in the summary code node.&lt;/p&gt;

&lt;p&gt;Result: summary saw only the LAST iteration's output. Every loop call overwrites the previous run's data.&lt;/p&gt;

&lt;p&gt;Fix: switch to a linear pipeline using HTTP Request's built-in &lt;code&gt;options.batching&lt;/code&gt; (&lt;code&gt;batchSize: 1&lt;/code&gt;, &lt;code&gt;batchInterval: 30000&lt;/code&gt;). n8n natively iterates per item with throttle. Downstream Code node with &lt;code&gt;Run Once for All Items&lt;/code&gt; mode sees all results at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;$getWorkflowStaticData('global')&lt;/code&gt; mutations don't persist across iterations
&lt;/h3&gt;

&lt;p&gt;My second attempt: accumulate results in &lt;code&gt;$getWorkflowStaticData('global')&lt;/code&gt; inside the loop. Static data is supposed to share state across nodes.&lt;/p&gt;

&lt;p&gt;Result: total_scenarios = 0. Mutations within a single execution don't reliably persist across loop iterations in current n8n versions.&lt;/p&gt;

&lt;p&gt;Fix: same as above — drop the loop, use linear pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Google Sheets free-tier rate limit hits at ~4 sequential webhook calls
&lt;/h3&gt;

&lt;p&gt;Each engine call does 3 Sheets reads (rules, thresholds, targets). At 5-second throttle between webhooks, the 4th request returns 0 rows = engine triggers its &lt;code&gt;SHEETS_UNAVAILABLE&lt;/code&gt; guard.&lt;/p&gt;

&lt;p&gt;Fix: 30-second throttle for batch testing. For production traffic, plan to cache config in n8n static data or move to Postgres.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Hardcoded sheet IDs leak into shipped templates
&lt;/h3&gt;

&lt;p&gt;n8n's Google Sheets node embeds the document ID + cached URL with full &lt;code&gt;gid&lt;/code&gt; per tab. If you don't strip these before distribution, every buyer downloads your live workbook ID.&lt;/p&gt;

&lt;p&gt;Fix: replace the document ID with a placeholder (&lt;code&gt;REPLACE-WITH-YOUR-GOOGLE-SHEET-ID&lt;/code&gt;) and scrub &lt;code&gt;cachedResultUrl&lt;/code&gt; fields before shipping. I wrote a 30-line Python script for this.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Renaming nodes in raw JSON breaks &lt;code&gt;$('OldName')&lt;/code&gt; references in OTHER nodes
&lt;/h3&gt;

&lt;p&gt;I stripped Phase-3 fallback nodes from the Lite version and renamed the kept ones. The n8n editor handles cross-references automatically. Editing JSON outside the editor does NOT.&lt;/p&gt;

&lt;p&gt;Fix: grep for &lt;code&gt;\$\('OldName'\)&lt;/code&gt; after any rename. Rewrite all occurrences.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. n8n requires sticky notes inside the workflow for community submission
&lt;/h3&gt;

&lt;p&gt;Marketplace reviewers reject templates without inline documentation. Stickies must explain setup, flow, and customization right inside the editor canvas.&lt;/p&gt;

&lt;p&gt;Fix: add 3 stickies per workflow at minimum — Setup (blue), Flow (green), Output (purple). I generate them programmatically because the n8n editor's sticky-creation UX is friction-heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance targets I committed to
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Webhook-to-classification: &amp;lt; 5 seconds (p95)&lt;/li&gt;
&lt;li&gt;Notification delivery: &amp;lt; 15 seconds after classification&lt;/li&gt;
&lt;li&gt;Scoring accuracy: 100% deterministic (verified across 3 runs of 5 test scenarios)&lt;/li&gt;
&lt;li&gt;Error rate: &amp;lt; 1% of executions&lt;/li&gt;
&lt;li&gt;SLA breach detection: 100% within configured window&lt;/li&gt;
&lt;li&gt;Audit completeness: 100% of items logged with all required fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine hits all six on the test bench. End-to-end p95 sits around 9 seconds, mostly Sheets read latency. Caching is on the v1.1 roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rule-based scoring isn't dead&lt;/strong&gt; just because LLMs exist. Determinism + auditability + zero per-call cost is a real moat for any domain where decisions get audited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain-agnosticism via a single config column&lt;/strong&gt; is one of the highest-leverage design patterns I've used. One workflow, three product variants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production-grade error handling was 40% of build time.&lt;/strong&gt; Slack outages, Sheets timeouts, cascading parallel-read bugs. The happy path is 60% of the work; the failure modes are the other 40%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sticky notes mattered more than I expected.&lt;/strong&gt; They're the difference between "buyer imports and figures it out" and "buyer imports and refunds 2 days later."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the code
&lt;/h2&gt;

&lt;p&gt;Repo (free Lite versions + architecture diagram + sample payloads): &lt;a href="https://github.com/TALVIN29/nexus-scoring-engine" rel="noopener noreferrer"&gt;github.com/TALVIN29/nexus-scoring-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free Lite Alert Triage on n8n: under review (search "Production-Ready Alert Triage")&lt;/p&gt;

&lt;p&gt;Free Lite Lead Qualification on n8n: under review (search "Production-Ready Lead Scoring")&lt;/p&gt;

&lt;p&gt;Premium bundle ($49 — Core Engine + SLA Monitor + Test Data Generator + both config packs): &lt;a href="https://talvinleegen.gumroad.com/l/nexus-scoring-engine" rel="noopener noreferrer"&gt;talvinleegen.gumroad.com/l/nexus-scoring-engine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build something with this — or hit a different footgun I missed — I'd love to hear about it. Email's in the GitHub README.&lt;/p&gt;

&lt;p&gt;— Talvin&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>googlesheets</category>
      <category>ops</category>
    </item>
  </channel>
</rss>
