A self-hosted n8n workflow that scores incoming sales leads with local AI (intent, urgency, a 1-10 qualification score), checks that the AI actually returned usable structured data, and uses n8n's native Switch node — not custom code — to route hot leads one way and cold/invalid ones another.
Second n8n portfolio piece, alongside
inquiry-triage.
Same "never trust the raw AI reply" pattern, different shape: this one
leans on n8n's own visual building blocks (Schedule Trigger, native
file nodes, Switch) instead of Code nodes doing all the work.
Everything runs locally: n8n self-hosted, AI via Ollama on-device. No lead data reaches a cloud AI provider. Nothing routes anywhere real automatically — this polls a local "leads inbox" folder that stands in for a CRM export or a form-to-file integration, so it's fully demoable with no real CRM, Slack, or email credentials. Swap the file nodes for your CRM connector to make it real.
Part of Hunter Terry's AI Orchestration & Verification Portfolio · GitHub profile
- Schedule Trigger polls on an interval (demoed at 1 minute; set it to whatever fits your real inbox).
- Read/Write Files from Disk (native n8n node) reads every lead file waiting in the inbox folder.
- Extract from File (native n8n node) parses each file's JSON into structured fields — no Code node needed for this step.
- Ollama Score Lead — a local model (
llama3.1:8b) reads the lead's name/company/source/message and returns an intent summary, an urgency level, and a 1-10 qualification score. - Parse & Validate — the model's reply is checked before it goes
anywhere:
- SCORE must be a real whole number from 1-10
- URGENCY must be low, medium, or high
- INTENT and SUMMARY must actually be present
- The lead itself must have a name and a message (rejects malformed/empty CRM exports)
- Rejects cleanly (not a crash) if Ollama is unreachable, and gives a distinct message if the model just isn't pulled yet
- A
SCORE >= 7alone is never enough to auto-route as hot — the raw message text must also carry an independent buying-intent signal (budget, timeline, etc.) and no plain-language disinterest phrase ("just browsing", "no rush"), or it's flagged for human review instead. The model's own score is never trusted alone.
- Switch: Route Lead (native n8n Switch node, not an if/else in
Code) routes on a single
routefield:hot,cold, orinvalid, with a built-in fallback output for anything unexpected. - Passing leads land in
hot-leads/orcold-leads/; anything that failed validation lands inflagged/with the specific reason(s). The original inbox file is archived toprocessed/either way, so the next poll doesn't reprocess it.
Schedule Trigger --> Read Lead Files --> Extract Lead Data --> Ollama Score Lead
(poll) (native, disk) (native, JSON) (local AI call)
|
v
Parse & Validate
(rule checks)
|
v
Switch: Route Lead
(native, 3-way + fallback)
/ | \
hot-leads/ cold-leads/ flagged/
The full workflow is in workflow.json — importable
directly into n8n.
Tested against 6 real cases — see
test-leads/test-results.md for the
full record, including a real bug found and fixed (n8n's newer
file-access restriction blocking the native file-read node) and a
prompt tightening made after watching the model's actual behavior on
edge cases.
The standout test: two deliberately bad lead files (one with blank
required fields, one that wasn't even valid JSON) both got the local
model to produce a confident-looking score and summary anyway — it
didn't refuse. Validation caught both and routed them to flagged/
regardless of what the model said. That's the same lesson
inquiry-triage's prompt-injection test proved: the model's compliance
is never the safety net, the check downstream is.
A second-party security review (agent-redteam,
2026-08-15) found that lesson had a gap: the original prompt-injection
check only caught injections that literally spelled out the output
format (SCORE:, URGENCY:, etc.). Two natural-language payloads that
requested the same score inflation without those literal keywords got
the model to comply and would have landed in hot-leads/ undetected.
Fixed the same day — see Case 8 in
test-leads/test-results.md for the real
before/after runs, including a bug in the first attempt at the fix
(caught by testing, not by reading the code) where a naive keyword
check was itself fooled by a negated sentence ("not really looking to
buy").
Requirements: n8n (self-hosted, no account needed), Ollama with a model pulled.
The workflow defaults to llama3.1:8b. Same rule of thumb as
inquiry-triage:
| Total RAM | Suggested model |
|---|---|
| ~8GB | llama3.2:3b |
| ~16GB | llama3.1:8b (default) |
| 32GB+ | a larger model, e.g. llama3.1:70b or mixtral |
To use a different model, edit the model field inside the "Ollama
Score Lead" node's JSON body.
ollama pull llama3.1:8b
The Code nodes (validation + save/archive) need Node's fs, path,
and os built-ins — n8n blocks these by default, so allow them before
starting. This n8n version also defaults to restricting the native
file nodes to ~/.n8n-files only, so the leads-data folder needs to be
explicitly allowlisted too:
Windows (PowerShell):
$env:NODE_FUNCTION_ALLOW_BUILTIN = "fs,path,os"
$env:N8N_RESTRICT_FILE_ACCESS_TO = "~/.n8n-files;~/lead-qualifier-data"
npx n8n
macOS/Linux:
NODE_FUNCTION_ALLOW_BUILTIN=fs,path,os N8N_RESTRICT_FILE_ACCESS_TO="~/.n8n-files;~/lead-qualifier-data" npx n8n
Then, with n8n running:
npx n8n import:workflow --input=workflow.json
npx n8n update:workflow --id=lead-qualifier-001 --active=true
(Restart n8n once after activating — CLI-side workflow changes need a restart to take effect. Editing in the n8n UI instead doesn't have this limitation.)
All data lives under ~/lead-qualifier-data/, resolved from the home
directory at runtime — no username or machine-specific path is ever
baked into the workflow:
inbox/— drop lead files here (JSON:name,company,source,message) to simulate a CRM export landinghot-leads//cold-leads/— passing leads, split by scoreflagged/— anything that failed validation, with reasonsprocessed/— archived copies of every inbox file once handled, so the poller doesn't reprocess it
Try it — copy one of the files from test-leads/ into the inbox and
wait for the next poll:
cp test-leads/lead-hot-01.json ~/lead-qualifier-data/inbox/
- Local AI only — no lead data leaves the machine.
- Nothing auto-sends or auto-imports anywhere real. Every output is a local file for a human (or the next integration) to pick up.
- Every AI output is checked against hard rules before it moves on — the model's compliance with the requested format is never assumed.
- Failures (bad input, AI unreachable, rule violations) are logged
with a specific, plain-language reason and routed to
flagged/— never silently dropped, never crash the workflow. - No real CRM/Slack/email credentials are used or required.
workflow.json— the exported n8n workflow (the actual deliverable)test-leads/— the real test cases and resultsdocs/walkthrough.html— plain-language, client-facing demo page with two real captured runs- Runtime output (
hot-leads/,cold-leads/,flagged/,processed/,inbox/) lives under~/lead-qualifier-data/, outside this repo — nothing runtime is shipped with the repo.