← Holocron Logs

Building a Command Bridge: Six n8n Workflows and the Bugs That Made Them Work

Severity-routed SIEM alerts, a sync health watchdog, and a daily summary digest, all landing in Discord. The workflows are simple. Getting them working meant finding an absolute interpreter path requirement, a webhook payload wrapped one level deeper than expected, and a type-strictness change that made a boolean comparison silently never match.

Why this matters beyond the homelab: Alert routing by severity, freshness monitoring on pipelines, and scheduled digests are the same three patterns every operations team builds regardless of tooling. The value here is less in the architecture, which is straightforward, than in the specific class of bugs that make integration work take four times longer than the design suggests.


The Architecture

Six workflows, one destination. Everything routes to Discord, which serves as the operations console because it has webhooks, channels for routing, threads for incidents, and mobile push, at no cost.

The pattern is consistent across all of them: detect, enrich, decide, act, report. Something triggers, the workflow gathers context, applies a rule, takes an action, and reports the outcome to a channel chosen by severity.

The three most useful:

Severity-routed SIEM alerts. The SIEM pushes every alert above a threshold to a webhook. Level 7-9 goes to a general status channel. Level 10 and above goes to a command channel that pushes to my phone.

A sync health watchdog. Checks daily whether the documentation export actually produced a commit, alerts if the most recent one is stale beyond a threshold.

A daily digest. At 7am, pulls the last 24 hours of alert statistics and posts a summary.


Bug One: The Interpreter Path Must Be Absolute

The SIEM’s custom integration mechanism runs a script when an alert matches. Two files are needed: a Python script and a shell wrapper that invokes it.

The wrapper looked correct and did nothing. No error in the integration log, no execution, no alert forwarded.

The cause was the interpreter path. The wrapper had a normal shebang, which resolves python3 from PATH. The integration process runs in an environment where that resolution does not find the interpreter the platform expects, because the SIEM ships its own bundled Python.

The fix is the absolute path to the bundled interpreter:

#!/bin/sh
/var/ossec/framework/python/bin/python3 /var/ossec/integrations/custom-n8n.py "$@"

The general lesson: scripts invoked by a daemon do not inherit your shell’s environment. Not your PATH, not your virtualenv, not your exported variables. When something works when you run it by hand and does nothing when the service runs it, environment is the first suspect and absolute paths are the fix.

Both files also need to be executable and owned appropriately, and the naming convention matters, since the wrapper must match what the integration configuration block expects.


Bug Two: The Payload Was One Level Deeper

The webhook received data. The code node processing it returned undefined for everything.

I was reaching for fields at the path they occupy in the source system’s alert format:

$input.item.json.rule.level        // undefined

The webhook node wraps the incoming request. The payload sits under a body key alongside headers and query parameters:

$input.item.json.body.rule.level   // works

Obvious once you see it, invisible until you do, because the field path you are copying from the source system’s documentation is correct for that system and wrong for what the webhook node hands you.

The habit worth building: before writing any transformation logic, dump the entire incoming object once.

return [{ json: { received: JSON.stringify($input.item.json, null, 2) } }];

Look at the actual structure. Then write against what is really there rather than what you expect to be there. Thirty seconds of inspection against however long you would otherwise spend debugging undefined values.


Bug Three: Type Strictness in the Conditional Node

The watchdog compares a commit’s age against a threshold. The original approach computed a boolean upstream and tested it in the IF node.

It never matched. Not intermittently, never, in either direction, which is the signature of a comparison that is not evaluating rather than one that is evaluating wrongly.

The IF node in this n8n version is strict about types. A value that is a string "true" rather than a boolean true does not satisfy a boolean condition, and it does not error either. It quietly evaluates false forever.

The fix was to stop passing a boolean and compare a number instead:

// upstream: emit a plain number
const hoursAgo = (Date.now() - new Date(lastCommit).getTime()) / 3600000;
return [{ json: { hoursAgo } }];

Then in the IF node: hoursAgo is greater than 25, as a number comparison.

Numeric comparison is more robust here because the type is unambiguous and because the raw value is visible in the execution log, which makes debugging trivial. A boolean tells you the answer without telling you the input.

There was a related syntax issue: expressions in this version use a leading = to indicate an expression, and doubling it produces something that neither errors nor evaluates as intended.

The pattern connecting all three bugs is the same as everything else I have written about this month: failures that produce no error. A rule that loads and matches nothing. A tag that pulls and never updates. A condition that evaluates and never fires. In every case the diagnostic is the same, inspect the actual intermediate value rather than trusting that the logic is doing what it reads like it is doing.


Why 25 Hours

The freshness threshold is 25, not 24, for a daily job.

A job scheduled for the same time each day does not run at exactly the same second. Scheduler jitter, a slow run, or a daylight saving transition can push it past a 24-hour boundary, and a threshold of exactly 24 produces false alerts on a pipeline that is working correctly.

An hour of slack costs nothing, since a genuinely broken pipeline stays broken and trips the alert on the next check anyway. Thresholds set to the exact expected interval alert on normal variance, and an alert that fires when nothing is wrong is how you train yourself to ignore the channel.


What the Watchdog Found

The first real run reported the documentation export was 211 hours stale. Nearly nine days.

The export workflow had been executing nightly and reporting success the entire time, while producing no commits, because it was failing in a way that did not register as failure.

That is the argument for freshness monitoring in one sentence. Monitor the artifact, not the job. A workflow’s own success status tells you it finished. It tells you nothing about whether it accomplished anything. The only honest signal is checking the thing the workflow was supposed to produce.


Takeaways

Daemon-invoked scripts do not inherit your environment. Absolute interpreter paths, always.

Dump the whole payload before writing transformations. Webhook wrappers change the structure you are expecting.

Prefer numeric comparisons over booleans in conditional nodes. Unambiguous typing and visible values in the execution log.

Add slack to freshness thresholds. Exact-interval thresholds alert on normal jitter.

Monitor artifacts, not job status. A workflow can succeed at doing nothing.

Silent failure is the theme. When something does not error and does not work, inspect intermediate values rather than re-reading the logic.


Related: n8n as Infrastructure Glue | Discord as an Ops Console | Thirty-Two Pushes Racing Each Other

← Back to Holocron Logs