CTI AI Agent Update: The Before/After of Improving an n8n Workflow for Production

2026-08-18 · Neetrox

The CTI AI Agent takes any mix of indicators IPs, domains, URLs, hashes, CVEs enriches them across six threat-intel sources, has a local LLM write the verdict, and returns a branded report plus a STIX 2.1 bundle. It already sits in our catalog alongside our other n8n security automations, but this
 release reworked it from the inside out.

Here is the update as a straight before/after. A few of these, the three traps that pass every “did it run” check while doing the wrong thing, are worth
 stealing.

The Before

The CTI agent workflow before the rewrite: one linear chain from webhook and form trigger through normalize, enrich, AI agent and STIX bundle to the output channels

The After

The CTI agent workflow after the rewrite, now with a results cache, parallel filtered lookups to VirusTotal, AbuseIPDB, GreyNoise, URLhaus and Shodan, and output channels gated by config

1. API keys: code → credential vault

Before. One enrichment node with keys pasted into the source. A secret in a Code node travels with the workflow export it and the key goes too.

After. Every source authenticates through an n8n credential (first-class for OTX and VirusTotal, generic Header/Query Auth for the rest). The exported JSON now carries no secret at all. This one rule drove the whole redesign.

2. Enrichment: one blind node → type-aware nodes

Before. A single node fired every API at every indicator wasting quota on lookups a source can’t answer (AbuseIPDB, Shodan and GreyNoise only take IPs; URLhaus only URLs, domains and hashes).

After. One node per source, each behind a Filter that only passes the types it handles. A classifier up front tags every value (IPv4, IPv6, domain, URL, MD5, SHA1, SHA256, CVE) and routes it to the right sources.

3. Convergence: fragile join → merge-by-value

Before. Positional assumptions that broke the moment a branch dropped an item.

After. A Merge node (append) waits for all branches and doesn’t hang when one produced nothing then a Code node zips each response back onto its indicator by matching the artifact string, not the row index.

4. Entry points: one convenient trigger → three real ones, one universal parser

Before. A public form for humans and a webhook for machines - two separate input shapes, two code paths, and a webhook that (see trap #1 below) never actually answered.

After. The form and an experimental chat trigger are gone. Three entry points remain, a POST webhook, a Telegram bot, and a Slack app and all three feed a single “Extract IOCs” node. One parser, one behavior, no matter how the indicators arrive.

The webhook now takes input two ways, and picks automatically:

  • Send a list ({"artifacts": ["1.1.1.1", "evil.com"]}) and it's trusted verbatim - no guessing. This is what a SIEM or SOAR should send.

  • Send a sentence ({"text": "beaconing to 185.220.101.1 and hxxp://evil[.]com"}) and the parser finds the indicators itself: every IOC type, many per message, de-fanging hxxp:// and evil[.]com on the way in, and deliberately ignoring filenames (payload.exe) and email addresses so they aren't mistaken for domains.

That means you can forward a raw alert body from Wazuh or just message a Telegram bot in plain English and get a report back.

5. Behavior: always-live → safe-by-default

Before. Activate and it would send to real people with placeholder addresses.

After. A Config node with dry_run: true by default and per-channel switches. It builds and previews the full report inside n8n and sends nothing until you flip it. Delivery requires both dry_run: false and the channel's own flag so dry-run is always a safe pause button.

6. Quotas: unbounded → batching + optional cache

Before. A hundred-indicator batch blew through free-tier limits in one run.

After. Per-source request batching (VirusTotal’s node sends four, then waits), plus an optional Data Table cache with a TTL. The cache is off by default and fails soft: if it errors, the run degrades to a normal live lookup. It can make the flow cheaper; it can never make it fail.

7. AI verdict: trusted → grounded

Before. The model was loosely in the loop, the kind of setup that invents indicators.

After. The LLM (Ollama, nothing leaves the building) never looks anything up; it only summarizes the evidence returned, cites source names and numbers, writes “no data” instead of guessing, and outputs strict JSON. Malformed JSON is repaired, falling back to “unknown” rather than crashing.

8. Report: plain + duplicated → one branded template

Before. Inline HTML, and two paths that could send the same email twice.

After. A dedicated HTML template node pulls the company name from Config; channels are independently switchable. The trap: n8n’s HTML node drops binary data, so the STIX attachment silently vanished. A small node after it re-attaches the bundle before delivery caught only by checking the output, not the green checkmark.

9. Delivery: five channels → six, with Telegram

Before. Discord, Slack, SMTP, Gmail, and the API response.

After. Telegram joins as a sixth channel. When a request arrives from Telegram, the verdict replies straight back to that chat; otherwise it goes to a configured chat ID. Same dry-run + per-channel gating as the rest.

10. Onboarding: import-then-error → activates on import

Before. A fresh import wouldn’t activate, a credential-needing trigger threw “Missing required credential” the moment you clicked Activate, for a channel the buyer might not even use.

After. Every node that needs a credential, both message triggers and all five send nodes, ships disabled. A fresh import turns Active with zero credentials configured, and the webhook still returns the full report (the API response isn’t gated by dry-run). That’s the zero-config demo path: import, activate, POST a sentence, read the verdict.

The three silent traps

Each of these passed “did the node run” while doing the wrong thing.

1- The webhook that never answered. responseMode: responseNode with no Respond node, every POST hung until timeout. It never showed because the entry point being tested was the form, not the webhook that ships. Test the entry point you ship, not the convenient one.

2- The STIX attachment that vanished. n8n’s HTML node drops binary data, so the bundle disappeared between “report built” and “email sent.” The execution was all green. Check the output, not the checkmark.

3- The parser that enriched English. The old free-text path split on whitespace and commas, so "please check 1.1.1.1 and evil.com" became five "indicators" including please and and , and the workflow dutifully spent VirusTotal and OTX quota looking them up. Every run reported success. The rewrite runs real IOC extraction instead, and returns exactly 1.1.1.1 and evil.com. A run that "succeeds" can still be doing something absurd, read what it actually produced.

Also new in this release

  • Telegram in, Telegram out message a bot, get the verdict back in the same chat.

  • Three swappable report themes (dark SOC, executive-light, MSP-blue) drop-in replacements for the HTML node that keep every data binding.

  • A detailed run guide (RUNNING.md) every entry point, both webhook payload shapes with curl / PowerShell / Python examples, the full API response schema, honest timing (a batch is paced by VirusTotal's 4-per-minute free tier), and integration recipes for Wazuh, SOAR, and cron sweeps.

  • A sanitized, import-ready export no credentials, no instance IDs, neutral defaults, verified to import cleanly.

Takeaways

  • Never let a secret touch a Code node.

  • Route work only to sources that can answer it.

  • Recombine by value, not by index.

  • One list or one sentence, meet the caller where they are, then parse for real.

  • Ship safe by default, and ship so it activates on import.

  • Check the output, not the green checkmark, all three silent traps above passed “did the node run.”

← Back to blog