Engineering

n8n writes the post. The publish button is still mine.

Self-hosted n8n, a model writing, Telegram as the approval desk, and one rule: the AI drafts, a human taps, nothing reaches a platform without the tap. Five weeks in production, including the bug I found in my own publishers while drawing a diagram for this article.

By Mohammed Jawed · · 28 min read

ShareXLinkedIn

Five weeks in production, more outages than I'd like, and one rule I refuse to negotiate on.

01.png

Most mornings, this is the entire interface.


Every morning, an AI writes the lab's social post. It is not allowed to publish one, and it never will be.

I build automation. I also went about four months without posting anything on the lab's own LinkedIn, which is roughly the same as a dentist with a chipped tooth.

It wasn't a content problem. I had plenty to say. Posting is just a chore shaped in the worst possible way: you open LinkedIn intending to write one paragraph, you end up reading about someone's leadership lessons from a marathon, and forty minutes later you close the tab having published nothing. Then you owe Instagram and Facebook the same idea in two other accents.

So I built a pipeline. Self-hosted n8n, AI writing, Telegram as the approval desk. It has run on a schedule since early August, every morning at the moment. I want to write down how it works, including the bits that fell over, because most writing on this subject comes from people who ran their workflow for one weekend and then drew a very confident diagram about it. I have drawn some diagrams too. One of them showed me a bug.

The argument underneath all of it fits in a line: automate the work around the judgement, not the judgement. Everything below is either an implementation of that or an admission of where I haven't finished implementing it.

The whole thing in one paragraph, for the impatient

A schedule fires at eight, every morning, and I can start a run from Telegram in between. The workflow works out what to write about: a briefing someone dropped in a Drive folder, a command I typed, or a rotation over the lab's own products that remembers what it said last time. One model call comes back with a single JSON object holding four platform drafts. A validator inspects that object before anything else is allowed to touch it. A second, smaller call acts as art director for the picture, and an image model paints it. Draft and picture reach my Telegram as one message with two buttons. I tap approve, the execution wakes up, the logo is stamped on, and three publishers run. I tap decline, and I can tell it what to change. Nothing goes out on a timer. Nothing goes out without the tap.

02.png

The only blocking step is the one with a person in it. Everything that can write to a platform sits below it.

The model does not get a key to the front door

It drafts. It never publishes. There is no path anywhere in that workflow where model output reaches a platform API without a human thumb in between, and there is not going to be one.

I know this is the unfashionable position. Every second demo on my feed is an agent that posts by itself and the caption is always some variation of "no humans involved." Fine. Enjoy. An LLM with write access to a company page is a liability on a cron schedule, and the fact that it hasn't embarrassed you yet is not evidence, it's just Tuesday.

Think about what a bad generation costs. It goes out in public, under your name, at eight in the morning, while you are asleep, and the first person to read it is a stranger. The tap costs three seconds and takes that to zero. I don't understand why this is a debate.

People say the point of automation is removing the human. Wrong human, wrong point. The chore was never the fifteen seconds of judgement. The chore was the forty minutes around it: four platforms, finding an angle, resizing an image, rewriting the same thought three ways, hunting for the Instagram password. Kill all of that. Keep the judgement.

The rule also decides where the credentials live. Publish tokens sit on the far side of the approval node. Nothing in the drafting half holds anything that can write to a platform. So if that half ever gets poisoned, through a briefing file or a prompt injection in something I fed it or whatever it turns out to be next year, the worst case is an ugly draft sitting in a chat, waiting for a tap it is not getting.

One call, four rooms

The writer runs once per run and hands back one JSON object with a field per platform.

LinkedIn gets the long version. Facebook gets something warmer and shorter. Instagram gets a caption plus its hashtag block and deliberately no URLs, because they aren't clickable there and putting one in just makes you look like you don't use Instagram; a code step appends the link-in-bio line instead. X gets a compressed version that currently goes nowhere, for reasons I'll come back to.

I started lazier. For a while Facebook just got the LinkedIn text, unchanged, and it read exactly like what it was: someone in a suit at a barbecue. The audiences really are different, and this is the cheapest fix in the entire build, because it is the same single API call either way. The model fills four fields instead of two. That's it. That's the whole upgrade.

The prompt is mostly a list of things it must not do. Voice, the claims I never make, no invented numbers, no manufactured urgency, no borrowed statistics, no engagement bait, no rhetorical questions as openers. The encouraging half is a paragraph. The prohibitions run much longer. Write guardrails like a contract, because to the model that is precisely what they are, and it will find the gap you left.

Model output gets treated as untrusted input. This is the habit I would fight for hardest, and it is also the place where my implementation is thinner than my opinions. Models return text. Text is not an object, however confidently it is shaped like one. So the parse step strips the code fences the model insists on adding, parses, refuses anything missing the two fields that cannot be missing, and truncates the X version at its limit. The two newer fields fall back to the LinkedIn text if the model drops them, on the principle that format drift should cost me a duller post rather than the whole morning.

What it does not do yet: cap the hashtags, enforce a length ceiling on the other three, or fail gracefully. A malformed response throws, which stops the run loudly instead of routing to a branch that explains itself. Loud is survivable. Silent would not be.

Take the principle anyway, since I am standing evidence that it gets skipped. Model output is a form submission from a stranger. Validate it like one.

Memory, and why it kept writing the same post anyway

There's a memory, and it has had two lives.

The first version kept the last forty approved posts in the workflow's own static storage and fed them back as an exclusion list. Without it the writer rediscovers its favourite hook inside a week, with total sincerity. Left alone, an LLM has roughly three ideas and enormous confidence in all of them.

Static storage has an edge worth knowing about: it only persists across executions of an active workflow, and it belongs to that one workflow. Spend a week testing by hand and the memory stays stubbornly empty. Paste the workflow into a fresh copy and it starts again from nothing. Either way you go looking for the bug in your code, where it is not.

The memory now lives in Postgres: the last sixty posts, declined ones included, each marked with how it was framed. The declined ones matter more than I expected. A draft I turned down is a framing I have already rejected, and the writer should know that as well as it knows what I approved.

The memory didn't fix the repetition, though. The rotation did. The posts kept circling the same few points because only one product in the bank had angles to rotate through; the rest had a single description the model could only restate in new words. Now every site carries several angles, and on top of that there is a second axis, a lens: explain one mechanism, defend one decision, tell one failure, name one tradeoff, write to one reader, lead with what changed, start from the problem. The angle decides the subject. The lens decides the shape.

There are seven lenses, and seven is not a random number. Each site advances its angle and its lens on separate cursors, so a pair repeats only after the least common multiple of the two counts. Keep the lens count prime and nothing short of seven angles can share a factor with it, so the cycle runs the full product every time. A site with four angles gets twenty-eight distinct posts before an exact repeat instead of four. One number choice, an order of magnitude of variety. I used the same trick again for the pictures, with bigger numbers.

The approval button was harder than the AI

I underestimated this badly. "Send a message with two buttons" is a five minute node. Making it behave took a fortnight of small annoyances.

cover-01.png

The execution parks at a wait step and resumes when the button sends its callback. Congratulations, you now own a distributed system that looks like a toy.

Double taps. Two taps must publish once. I had a state flag half designed for this before I found out the wait step only resumes the once, so a second tap arrives at an execution that has already left. Free correctness, which is rare enough to be worth checking for before you build the guard I nearly built.

Stale approvals. A pending draft ought to have a shelf life. Mine does not have one. There is no expiry on that wait, so a draft I ignored on Friday publishes quite cheerfully when I tap it on Monday, as Friday's news. It is one field on one node, it sits near the top of my list, and it has sat there long enough to be a fair illustration of how these lists work.

Partial success is the one that actually bit, and it bit while I was drawing a diagram for this article. I believed the three publishers were independent. On the canvas they are: three branches fanning out side by side. That is a picture, not a guarantee. n8n runs those branches one after another rather than at once, and not one of my publishers has an error path. So a publisher that throws doesn't only fail its own platform. It ends the execution. The platforms that already ran stay published, the ones queued behind it never start, and none of them report. The first missing message is both where it broke and the last thing you hear about it.

I haven't caught it in an execution log yet, which is its own small indictment: either nothing has failed since I went looking, or something did and I missed it. The fix is a setting on each publisher plus a report that says "failed" out loud. It has gone to the top of the list, above the stale approvals, because it is the one that makes my own rule untrue.

And a side effect I didn't design and now rely on: the chat is the audit log. Every draft, every decision, every rewrite, every publish result, in order, in one scroll. When someone asks why a post went out on a Tuesday, I don't go looking through execution history. I scroll up.

Decline used to be a dead end

For the first month, declining a draft meant the idea died there. If the draft was eighty per cent right, I either approved something I didn't like or lost the topic until the rotation came back round.

Now a declined draft is parked. I reply /revise and what's wrong, "shorter, and open with the actual bug", and a fresh execution rewrites the same topic, angle and lens with the declined text and my note in front of the writer. It comes back through the same gate, like everything else. Three rewrites at most, within twenty-four hours; after that it tells me to start fresh, because by the fourth attempt I am having a conversation with a model instead of writing the post myself.

Two details made it work. The rewrite is a new execution rather than the old one resuming, so there is no long-lived wait sitting around going stale. And the declined draft is in the history as declined, which normally tells the writer never to serve that framing again. For the draft being rewritten, that line is filtered out, so the model isn't told to avoid the very thing it is meant to be fixing.

Telegram became the second front door

The Drive folder, below, is for people who plan. Telegram is for things that happened this morning. The bot that asks for approval also takes commands: /post with a sentence about what to say, /quick for a short one, a photo with either as its caption, /revise for a rewrite. A command goes through the same pipeline as a scheduled run, tap included. Same rule, shorter fuse: a command can start a run. It can never finish one.

Three checks come before anything else in that code path. The chat has to be on an allowlist; any other chat is ignored without a reply, because a bot that answers strangers is a bot telling strangers it exists. Only an explicit command starts anything, so ordinary chatter doesn't. And a picture sent as a file instead of a photo gets a reply saying so, because Telegram treats the two differently and nobody knows that. Photos get cropped to what Instagram will accept, which is a narrower range of shapes than a phone camera produces: 4:5 at the tall end, 1.91:1 at the wide end.

Groups have their own traps, which I met when a second workflow got its approval desk in a group chat instead of a private one:

- In a group, commands arrive as /post@YourBot, always when picked from the command menu. Strip the suffix once, early, or every check below it silently fails.

- With privacy mode on, a bot doesn't reliably see a command written in a photo caption. Making the bot an admin fixes that.

- Making the bot an admin can quietly upgrade the group to a supergroup, and a supergroup has a new chat ID. The old one simply stops working. Every node that sent to that chat, and the allowlist, needed the new number. There is no error for this. There is only silence.

The feature nobody asked for, which everyone uses

My favourite part of this system has almost no AI in it.

There's a Google Drive folder. Drop a text or Markdown file in it: four sentences about what you want announced. Forget about it. Next morning the pipeline picks it up, writes proper platform versions, and asks for approval like normal. Put a date line in and it waits for that date. Drop an image in the folder, name it on an image: line, and that becomes the picture. Approved briefings get moved to an archive folder, which is also how the workflow avoids doing the same file twice.

It reads plain text and Markdown only. A Google Doc, which is the first thing everyone tries, gets ignored. That's the obvious next format and it's on the list.

So a colleague who has never seen n8n and never needs to can schedule a week of company posts in a folder they already use.

If I had to delete everything except one feature, I think it would be this one and not the model. Which stings slightly.

The general lesson: put the input where your least technical colleague already lives. Every internal dashboard I have ever built for someone else got opened twice, once when I demoed it and once when I asked whether they'd used it.

The card, and the bit nobody warns you about

Instagram won't take a post without an image, so the pipeline has always drawn one. Brand colours, title, the lab's look, rendered inside the workflow. No designer in the loop, no Canva subscription, and above all no stock photo of five people in business casual pointing at a whiteboard. These days the card is the fallback rather than the default, which I'll come to, but every picture goes through the same plumbing.

Here's the part that ate a weekend. The image has to be reachable by the platform, not by me. Meta's publish flow is a two-step affair: you hand over a URL, you get back a container, then you publish the container. Which means the render step also has to park the file somewhere fetchable over HTTPS for the length of the publish. Drawing the picture took an afternoon. The plumbing around the picture took considerably longer, and I was in a bad mood about it the entire time.

Mine used to write to one fixed path, served over a webhook, overwritten by the next run. A puddle that never gets any bigger. Then Telegram started runs whenever I liked, two runs could overlap, and one could publish the other's picture. So each run now writes its own copy, named after the execution, and the publisher asks for that one. Nothing deletes those. The puddle gets a little bigger every run, which is the kind of small problem that stays small right up until it is the reason a disk is full.

The posts draw their own pictures now

The card worked, but it was a template, and templates age in public. So the default picture is now painted for each post. Getting there took four attempts, and each one taught me something.

Attempt one: mood images. Read the draft, take the mood, paint something on-brand, no text. Pretty and interchangeable. You could have swapped the pictures between posts and nobody would have noticed, which is a polite way of saying they said nothing.

Attempt two: an infographic. I pasted a post into a chat assistant and asked for an image. It produced a clean infographic that genuinely explained the post, and looked exactly like what every other automated account posts. Correct is the floor, not the goal.

Attempt three: an art director. A second text call, to the same model as the writer, now reads the finished post before any pixels exist and returns one visual concept and, at most, a short headline. Code, not another model, assembles the image prompt from that. That is the difference between a picture near the topic and a picture about this post.

Attempt four: make it hard to repeat. A good concept rendered in the same style every time is a template again, just a more expensive one. So the look rotates on four axes: eleven art styles, from paper-cut to miniature diorama to risograph print; seven compositions; five moods per brand; three type treatments for the headline. All four counts are prime and they step together on scheduled runs, so a full combination comes back only after 11 × 7 × 5 × 3 = 1,155 posts. At one post every morning, that is a little over nine years.

05.png

The model paints before the tap. Code stamps after it.

The guards come from code, not from the art director. Two are checked: a headline containing a number the post doesn't contain is dropped, because image models love inventing statistics, and a headline over nine words is dropped, because nobody reads a paragraph on a poster at phone size. The rest get appended to every prompt whatever the art director says: people only if they can't be recognised; no readable screens and nothing that could pass for one of the lab's real products, because a convincing fake screenshot is a lie with good lighting; and a short list of banned clichés. Rockets, light bulbs, padlocks and glowing brains are out unless someone finds a genuinely new twist, and nobody has.

The reason those live in code rather than in the art director's prompt is that a prompt is a request and code is a rule. I ask the art director for a concept. I do not ask it to remember the things I refuse to publish.

The logo goes on after the tap. This is the one place where the picture changes after I have judged it, and it needed more thought than the rest of the image work, because this whole article hangs on one rule. The rule is that nothing a model produces reaches a platform without a person looking at it first. The painted poster, the model's output, is on my phone before I tap, exactly as it will ship. What gets added after the tap is a strip with the logo and the web address on posters and cards, and a small corner badge on photos. Those are fixed pixels, drawn by code, identical every time, with no model involved. I judge everything that could surprise me, and the stamp can't surprise anyone.

That's the test I'd hand anyone building something similar. Anything added after the human has looked has to be incapable of surprising them. Fixed pixels pass. A second model pass does not, however tempting it is to just enhance it a bit on the way out the door.

Choosing a model means reading its obituary first. The first version used Google's image model. It returned "too many requests" on day one, for reasons in the next section, and the pictures now come from OpenAI's image API over a plain HTTP call, for reasons also in the next section. Of the image models I looked at, one was already shut down and others were approaching retirement, so the one running today is the successor to the one I tested with. Its name lives in a single constant at the top of one code step, which is closer to configuration than the writer's model name is, and still not a fallback.

Short video comes next, probably. The same model families do clips now, and a six-second branded piece would beat a static picture on Instagram every single day. But video pricing is a different animal from cent-scale images, so it gets a hard spend cap, a budget check before the call, and the same non-negotiable tap before anything renders, never mind posts. The rule doesn't soften as the output gets more expensive. It tightens.

What actually broke

This is the section I'd read first if someone else wrote it, and it is the reason the rest of the article is worth anything. Five weeks is not long, but it has been long enough for production to take apart several things I was confident about.

A model got retired from under me. One morning: 404, plus a polite note that the model is no longer available to new users and would I please update my code. Nothing changed on my side. It just stopped existing. If your pipeline cannot survive its own LLM being discontinued, you don't have a pipeline, you have a demo with good lighting. The right fix is obvious: model names belong in configuration with a fallback behind them. I have not made it. The writer's model is still picked in the node with nothing behind it, exactly as it was on the morning it broke. What I gained was knowing which line kills me next, which is not the same thing as having fixed it, however much it feels like it at the time.

Rate limit errors lie, and so did my code. I lost most of an evening to a message about receiving too many requests. Further down the full response body was the actual story: a free-tier quota of exactly zero. Not too many requests. Zero permitted requests. And billing attaches to the project rather than the account, so you can have perfectly healthy billing and a dead pipeline because the key lives somewhere else. Ask me how I know.

Then it happened again with the image model, which no longer has a free tier at all. This time my own code joined in. The error came back as a plain string instead of an object, my handler only looked for objects, and the approval message informed me the model had "returned no image". That's two layers of lying, and one of them was mine. The handler now keeps the raw text, and when it sees a 429 it says "billing or credits", because that is what a 429 has meant every single time so far.

Friendly nodes send parameters you didn't ask for. When I moved the pictures to the new provider, the built-in node sent a response_format parameter that the newer model rejects. The node leaves it out for exactly one older model name, and there is no field to turn it off. I replaced the node with a plain HTTP request and a body I can read in full. A friendly node saves you ten minutes right up until it doesn't, and then you can't see inside it.

Some failures don't fail. One node hit a quota error and, rather than surfacing it, silently retried in a loop. The execution just sat there. Not green, not red. Sitting. Ten minutes of nothing is worse than a red error in two seconds, because a red error tells you where to look and nothing tells you nothing.

The fix is boring: hard timeouts on every external call, a maximum execution time on the workflow, an error workflow wired in at instance level so a stuck run has to announce itself. Here is how far I got. Four of my fifteen HTTP calls carry a deadline. Three of those read a web page or a feed, at fifteen, fifteen and ten seconds, which tells you exactly which failure had already bitten me. The fourth is the image call, at three minutes, because a hung image model would hold the morning hostage. The other eleven, every publisher among them, have no deadline at all. Anything that can hang needs a deadline imposed on it by someone, and writing that sentence is measurably easier than wiring the node. This is the last gap I'll point at in this article. It is not the last gap.

Tokens expire on a schedule. Meta's long-lived tokens last about sixty days, which is a courteous way of breaking your integration several times a year. That earned its own small workflow: hold the tokens, refresh them on a fortnightly run, ask the API what it believes the expiry to be rather than trusting my own arithmetic, and shout on Telegram once the remaining window drops under three weeks. Two clocks, incidentally, not one. The token expires, and the permission to reach the data expires separately on its own schedule, and I found that out the way you would expect to find it out. The report prints both. Expiry with three weeks' notice is a task. Expiry discovered on a Monday morning is an incident.

07.png

What each failure actually does today. The green rows are where the rule holds. The red rows are where, so far, it is only a sentence.

Out of all that came the rule the system is meant to run on: everything degrades, nothing halts.

The picture side lives up to it. Art director down, a plainer prompt. Image model down, the branded card, and the approval message tells me why. Logo step broken, the picture ships without it. Nothing in that chain can cost me the morning post, and none of it was foresight. Every one of those fallbacks exists because something broke first and I was annoyed enough to fix it properly.

The rest doesn't live up to it yet, and the table is honest about that. A writer failure stops the run and tells nobody. A publisher failure takes down whichever platforms hadn't run yet. One stop is deliberate: if I sent a photo and it can't be fetched, publishing a different picture would be worse than publishing nothing. The other two are just unfinished. I drew this table expecting a column of green, and the red rows are why you should draw yours.

There's a pattern in which rows came out green, and I didn't like it when I noticed. Every green row is in the picture pipeline, which is the part of this system that has failed most often. The red rows belong to the writer and the publishers, which have been quietly reliable for five weeks and have therefore never forced my hand. Reliability is how gaps survive. If you want to know where your system is weakest, look at the parts that have never given you trouble.

One more thing on retries, since this is where people get lazy and I am people. You want a taxonomy. A 429 or a 5xx is worth retrying with backoff. A 401, or a 404 from a model that no longer exists, is terminal, and retrying it only burns the window in which a human could have done something useful. What I have, after the overload errors got frequent enough to irritate me, is retry-on-fail ticked on the writer node at its defaults, three attempts a second apart, classifying nothing. Retry three times and hope is not error handling, it is superstition with a tick box, and it is mine. In its defence it does get the morning post out, which is precisely why it has survived this long, and why bad error handling survives anywhere.

Costs, since everyone asks

The text bill is pennies a month. Flash-class models writing a few short texts every few days cost close to nothing, the art director call is smaller still, and the sixty-post memory is a rounding error on the context. n8n and Postgres run on a box I already pay for. LinkedIn, Meta and Telegram are free at my volume.

Pictures are the one line that isn't pennies: a few cents each. And because the picture is made before the tap, I pay for the ones I decline and for every rewrite. That is the rule costing real money. It's worth it.

What it really cost was my time and the paperwork. Meta's app review and OAuth scopes took longer than the entire n8n build and were, comfortably, the worst software experience of my year. No AI is going to suffer through that on your behalf.

X is built and switched off. Their API pricing and my posting volume are not on speaking terms.

If you're building one

  • Keep a human tap before publish and put the publish credentials on the far side of it.

  • Write your guardrails as prohibitions. Assume the model will find whatever you left out.

  • Validate model output like it came from a stranger, because functionally it did.

  • Let the model paint and let code stamp. Anything added after the human has looked must be something that can't surprise them.

  • Keep your variety counts prime. It's the cheapest anti-repetition trick I know.

  • Give every external call a deadline. Silent hangs are worse than loud failures and it isn't close.

  • Sort your retries into transient and terminal. Stop retrying things that will never work.

  • Put your guards in code, not in the prompt. A prompt is a request. Code is a rule.

  • Draw your fallbacks as a table of what actually happens today, not what you meant to happen. Mine had red rows I didn't know about, and they were in the components that had never broken.

  • And put the input where your least technical colleague already lives. For me that was a Drive folder and a chat. It will not be a dashboard. It is never a dashboard.

    Automate the work around the decision. Keep the decision human. That is the whole argument, and five weeks of production has changed my mind about none of it.

What it did change was where I look for problems. I used to look at the thing that kept breaking. Now I look at the part that has never given me any trouble at all, because that is where the gap is still sitting, quietly, waiting.

This is the sort of thing I build at Mannat AI Labs: unglamorous automation with a human in the loop exactly where it matters and nowhere it doesn't. If you're wrestling with something similar, comments are open. I'll happily tell you which of your error messages are lying.


A note on context: everything here describes independent projects run under Mannat AI Labs. Views and mistakes are entirely my own.