A Slack Front Door for n8n, with a Local LLM Doing the Tagging
Why I had Claude Code build a 230-line Socket Mode bridge instead of trusting a community node, how n8n v2 changed the secrets game, and what it took to get a local model to tag articles without inventing a taxonomy.
I wanted something simple: post a link into a Slack channel from my phone and have it show up in my read-later service (wallabag, self-hosted), fetched, saved, and tagged, with a ✅ reaction on the message as the receipt. A self-hosted n8n was already sitting on my Docker host for workflow automation, and the Framework Desktop I set up as a local AI server had capacity to spare. What I did not have was a way for Slack events to reach n8n without exposing anything to the internet. This post is about the piece I ended up building in the middle.
Why I did not use a community node
n8n has community nodes for Slack Socket Mode, and the obvious move was to install one. Before doing that I ran code and security reviews on the three candidates. None survived. The best of them shipped a frozen dependency bundle with known vulnerabilities and a release that did not match any git commit. The second leaked the Slack bot token into n8n’s execution data, where it sits in the database with every run. The third had the right architecture and four bugs, and had been abandoned. Nothing malicious anywhere, but nothing I would give a Slack token to either. n8n v2 makes the stakes explicit: unverified community packages are something you have to opt into, and the default is off for a reason.
The bridge
So the bridge is custom. The architecture and the design decisions are mine; the roughly 230 lines of code were written by Claude Code while I reviewed them, which is how most code gets written around here these days. It sits on the official Slack SDK with 16 locked dependencies, running as a container next to n8n.
The design question worth explaining is why a bridge with a WebSocket instead of just opening the firewall for n8n. The standard way to receive Slack events is the Events API over HTTPS: Slack POSTs to a public URL you give it. For a homelab that means a port forward or a reverse proxy, a TLS certificate, a DNS name, and n8n itself facing the internet around the clock, including its login page, its webhook endpoints, and whatever CVE ships next. I did not want any of that surface for the sake of saving links. Slack’s Socket Mode flips the direction of the connection: the bridge dials out to Slack over one WebSocket and holds it open, so events arrive without anything on my network listening for inbound traffic. No firewall change, no certificate, no URL for anyone to discover. The trade-off is that you need a long-running process to hold the socket, and that process is the bridge.
Events come down the socket, get matched against a YAML routing table (event type, channel, optional regex on the message text), and matching ones are POSTed over the LAN to n8n’s built-in webhook trigger with a shared secret in a header. The workflow does its work and the bridge reacts ✅ or ❌ on the original Slack message. Secrets stay compartmentalized on both sides. The Slack tokens live only in the bridge’s environment; n8n never sees them. On the workflow side, everything sensitive lives in n8n’s credential store: n8n v2 blocks environment variable access from workflows by default, and the flag that unblocks it would expose the container’s entire environment to every workflow you ever import, so the credential store is the right answer, not just the default one. The webhook rejects calls with a bad header secret before any execution starts, and switching the wallabag account the articles land in is an edit in the credentials UI, not a config change. Adding a new integration is a channel invite, four lines of YAML, and a new workflow.
The reactions carry precise meaning, which took some design. ✅ means the article is saved, full stop. The workflow answers the webhook from a Respond node placed right after the save, so enrichment steps that run afterwards can never turn a successful save into a false ❌. The bridge deduplicates Slack’s redeliveries with an LRU of event IDs, and Slack’s Delayed Events feature retries missed deliveries for 24 hours, so the bridge being down turns into “the reaction arrives late” instead of “the link is lost.” No reaction means the event never reached the bridge; ❌ means it arrived and something failed. Either way the message is still in the channel, and reposting it is the whole recovery procedure.
Inside the wallabag workflow
On the n8n side there is one workflow per integration, and the first one is wallabag-save. The flow is short. A webhook trigger receives the bridge’s POST and checks the shared header secret before anything else runs. A step extracts the URL from the Slack message text and refuses anything that points inside my network (more on that below). The article gets saved to wallabag through its API, and the workflow answers the webhook right there: the save is the point of truth, so the ✅ in Slack means exactly that and nothing more. Everything after the response is enrichment, which is where the AI tagging chain hangs. A second workflow, wallabag-tag-sweeper, runs nightly and re-tags whatever the inline chain missed, so a transient failure never needs my attention; it heals itself within a day. New integrations repeat the same shape: webhook in, verify, do the one thing that matters, respond, enrich afterwards.
Tagging with a local model
The fun part is what happens after the save. Every article gets tagged by gemma4:26b running on the Framework’s Ollama server. My rule for this chain was that tagging is enrichment and never a gate: it runs strictly after the webhook response, every node in it continues on error, and a nightly sweeper re-tags anything that slipped through, capped at eight entries a run. The Framework being off or a model loading cold cannot delay a save or fake a failure. The request itself uses a 120-second timeout to survive an 18GB cold load, keeps the model warm for 30 minutes afterwards, and forces JSON output against a schema.
The interesting problem was taxonomy discipline. Ask an LLM to tag articles freely and you get a junk drawer of near-duplicates within a week. So the model does not get a free choice. It answers with two lists: existing, which may only use wallabag’s current tag vocabulary, and new, which allows at most one tag. The normalize step then verifies every claimed-existing tag against the real vocabulary and demotes anything hallucinated into that single new-tag slot. Worst case, an article adds one tag to the taxonomy. The first live results came back exactly on vocabulary: a Tailscale article tagged networking, security, privacy; a Google Photos alternative tagged self-hosting, docker, media, photography. Zero invented tags. One model note: I picked gemma4 over qwen3 because thinking-mode models interact badly with schema-constrained output.
The security pass
Before calling it done I ran a security review over the whole thing. The design held up well (no inbound surface, small dependency chain, secrets compartmentalized per component), with one structural gap I had not initially considered: anyone who can post in the routed channel can make wallabag fetch a URL, and wallabag fetches from inside my network. A link to an internal address would have been a server-side request forgery with a Slack message as the delivery vehicle. The URL extraction now rejects private-range IP literals, IPv6 literals, and internal name suffixes before wallabag ever sees them. Channel membership was always the access control, but membership meant “can save articles,” not “can probe my LAN,” and now it means the former again.
What did all this buy me? More than a read-later pipe. The bridge is now a permanent front door: any Slack channel can become the trigger for any workflow n8n can run, from any device with a Slack client on it, with nothing exposed, receipts I can trust, and a tagging librarian that costs electricity. Saving links to wallabag is just the first route through it. The next integration (receipts posted to an expenses channel is the leading candidate) is a channel invite, four lines of YAML, and one new workflow. Fifteen minutes, most of it in the n8n editor. And the whole exercise reinforced a belief I keep coming back to in client work: the glue code is never the hard part. The hard parts are knowing what you refuse to install, where the secrets live, what a green checkmark is allowed to mean, and what happens when each piece is down. Those decisions fit in a YAML file and a page of notes, but somebody has to make them.