One SSH Key, One Job: Swapping Ollama for the Claude CLI
Two separate afternoons this week, both spent on the same n8n workflow: the one that watches a Telegram group chat, grabs invoice PDFs as they land, OCRs them, and asks a local model to turn the mess into structured JSON. It's been quietly doing its job for a couple of weeks now. This week it told on itself twice.
Bug one: it choked on hello
The workflow's trigger fires on every message in the group chat, not just the ones with attachments — that's just how a Telegram Trigger node works, you don't get to filter at the trigger level. So when someone typed a plain "thanks" or dropped a sticker in the chat, the next node down the line — Get a file, which asks Telegram for the file behind message.document.file_id — got handed a message with no document at all, and predictably fell over. Every plain chat message was quietly generating a failed execution.
The fix was the kind of thing that's obvious in hindsight and easy to skip when you're building the happy path first: an IF node right after the trigger, checking whether message.document.file_id actually exists before doing anything with it. Document present, keep going into Get a file like before. No document, drop into a no-op node and let the execution finish clean instead of erroring out. Tested it by literally sending a text-only message into the group chat and watching the next execution log — trigger, IF check, no-op, done, no red X.
Bug two: retiring one model, but only one
The bigger job this week was pulling one branch of the workflow off a local Ollama model and onto Claude instead — but only one branch. The workflow actually splits invoices into two paths now: a general extraction agent for anything that comes through the chat, and a separate, more specific agent just for Expertrum's manpower billing statements, which have their own layout with per-site line items and fixed fee lines that needed their own prompt. I asked which branch should move, half-expecting "both" — got "just the Expertrum one, leave the other alone" instead. Good call, honestly. No reason to touch something that's working while you're mid-experiment on something that isn't the priority yet.
Here's the part that made this more interesting than a simple node swap: there's no n8n node for "the Claude CLI." Ollama has a proper LangChain chat-model node you can wire straight into an Agent node. Claude, running as a CLI tool logged into a subscription on the homelab box, doesn't have that — so the LangChain Agent node itself had to go, replaced by something that could reach across the network and run a command on a machine that isn't the n8n box.
Which meant SSH. And SSH from an automation tool into a box that also runs other things is exactly the kind of decision you want to slow down on, not because it's hard, but because getting it wrong is the sort of mistake that doesn't show up until someone's poking at your invoice bot's credentials six months from now. So: a dedicated SSH key, scoped down as far as it'll go. On the homelab side, the key's entry in authorized_keys carries a forced command — no matter what you ask that key to run, the server ignores it and runs one specific script instead. The script itself is three lines: strip a literal cd / ; prefix that n8n always tacks onto the front of whatever it sends (found that one the hard way on an earlier SSH node, wrote it down so I wouldn't relearn it), then pipe whatever's left straight into claude -p --restricted --output-format text --model claude-sonnet-5.
That last detail — piping the raw string in rather than trying to build a shell command out of it — turned out to matter a lot. My first instinct was to worry about invoice text breaking out of quotes, hitting a stray backtick or dollar sign in some vendor's OCR'd letterhead and getting interpreted as shell syntax. Turns out that risk doesn't exist here: the forced command never treats what SSH sends as code to execute, just as a string it hands to printf and then to claude. Whatever garbage OCR throws at it, it's inert. No escaping gymnastics required, which was a relief, because escaping gymnastics are exactly the kind of thing that look fine in testing and then bite you on the one invoice with a euro sign in the company name.
The actual n8n change ended up small once the SSH side was sorted: swap the old Ollama chat-model node and its Agent wrapper for a single SSH node whose "command" is just the exact same extraction prompt that used to go to Ollama, verbatim — and a tiny code node afterward to rename the SSH node's stdout field to output, because that's the field name the existing parser downstream was already expecting. Everything after that — the JSON parsing, the validation, the review flow — never had to change at all.
The one hiccup that had nothing to do with n8n or SSH: pushing the updated workflow definition got blocked by my own tooling's permission classifier, which took one look at a JSON payload containing an SSH credential and a command field piping text into a CLI and flagged it as a potential remote-code-execution surface. Which — fair, actually. It couldn't tell that command field was just prompt text, not something dangerous, and I'd rather it ask than wave through anything that looks like "SSH plus pipe plus execute." Got the one-off approval, pushed the change, and the very next Expertrum invoice through the chat ran clean end to end — trigger, vendor detection, SSH out to Claude, parsed, validated, review notice sent.
Nothing about this was hard, exactly. But it's a good reminder that "just call the API instead" isn't always the shape a fix takes — sometimes the tool you want to reach doesn't have a network API at all, just a CLI logged into a subscription on a box you already trust, and the honest way to reach it is the boring one: a scoped key, a forced command, and no cleverness beyond what the job actually needs.