"Turn my blog post into posts for X, LinkedIn, Threads, Bluesky and Mastodon" is one of the first things people build once they have n8n and an LLM key. It looks like a one-prompt job: paste the post, list the platforms, ask for JSON, done.
I built that version, then a second one where code does everything code can do, and ran both on the same 8 blog posts, 3 times each. That's 24 runs and 120 social posts per version, every one scored by a separate checker that the workflows never see.
Short version:
- The one-prompt version produced 118 bad posts out of 120. 93 of them didn't include the link to the blog post, which is the one thing a promo post has to do.
- The validated version got 77 posts clean enough to mark "ready", sent the other 43 to a human, and none of the 77 failed the independent checker.
- Then I read the "ready" posts myself. In the first round, 10 of 21 got the blog post wrong. One said churn increased from 6.1% to 4.3%. Code can check the format. It can't check whether the post is true.
That last point is why this article is about where the human goes, not about removing them.
The setup
- n8n 1.123.84 on Node 24, self-hosted, a fresh user folder just for this test.
- The model: Qwen 1.8B running locally in Ollama, temperature 0.7, JSON mode. Small on purpose. It's the kind of model people run when they don't want to pay per post, and small models make the failures easy to see. A bigger model will fail less often. I didn't test one, so I won't put numbers on it.
- The input: 8 made-up blog posts of about 200 words each, written to look like real small-business and dev posts: a bakery cutting food waste with a spreadsheet column, moving a side project from Kubernetes to one VPS, a cold email experiment, an interruption log, a meetup run on ₹15,000 a month, SaaS churn after removing an annual discount, a Python workshop for teachers, and replacing a status page vendor with a 40-line script. Each one has a title, a URL and a body, and most have specific numbers in them, because numbers are what models get wrong.
-
Each workflow is a Webhook that takes
{title, url, body}and returns 5 posts.
The rules I scored
| Platform | Max length | Max hashtags |
|---|---|---|
| X | 280 (links count as 23) | 2 |
| 1300 | 3 | |
| Threads | 500 | 1 |
| Bluesky | 300 | 2 |
| Mastodon | 500 (links count as 23) | 3 |
And for every post:
- it contains the exact blog URL, and no other link or
[link]placeholder - it's within the length and hashtag limits for its platform
- it doesn't open with the same first 5 words as another platform's post (otherwise it's one post pasted 5 times)
- every number in it appears somewhere in the blog post
The checker is a separate Python script. It doesn't trust the workflow's own "ready" label, which matters later.
Version A: one prompt
Webhook → Config → Build Prompt → LLM (HTTP Request) → Parse → Respond
The system prompt is what most tutorials end up with:
You are a social media manager. Turn the blog post into 5 social posts, one for each platform:
- x: max 280 characters, max 2 hashtags
- linkedin: max 1300 characters, max 3 hashtags
- threads: max 500 characters, max 1 hashtag
- bluesky: max 300 characters, max 2 hashtags
- mastodon: max 500 characters, max 3 hashtags
Every post must include the link to the blog post. Each post must start with a different hook.
Only use facts from the blog post, do not invent numbers.
Return only JSON: {"posts": [{"platform": "x", "text": "..."}, ...]}
The user message is the title, the link and the body. Parse is a JSON.parse.
Results
| Version A | |
|---|---|
| Runs where all 5 posts passed | 0 / 24 |
| Posts with at least one problem | 118 / 120 |
| No link to the blog post | 93 |
| Same opening as another platform | 54 |
| Too many hashtags | 10 |
| Too long | 3 |
| A number not in the blog post | 1 |
| JSON that failed to parse | 0 / 24 |
JSON mode did its job: every run parsed. Everything else broke. Some examples:
- The Kubernetes post on X: "How I moved my side project from Kubernetes back to one VPS. #SideProject #DevOps". No link, and it's the title with hashtags on it.
- The bakery post came back as five versions of "Cut your bakery's food waste by 38%!", the same sentence on every platform with small changes.
- The cold email post on X was 462 characters, a straight copy of two paragraphs.
- The churn post on LinkedIn said Ledgerly "saw a 48% drop in churn". The number 48 isn't anywhere in the post.
The model was told every rule. It just can't keep 5 platforms × 4 rules in its head while also writing. And nothing after the LLM node checks anything, so all 120 posts would have gone straight to your scheduler.
Version B: the model writes, code does the rest
The idea is the same one from the earlier posts in this series: let the model do only the part that needs a model, and do everything else in Code nodes where you can test it.
Webhook → Config → Prep → Build Prompt → LLM → Validate → Needs retry? → (Build Retry Prompt → LLM Retry) → Final Check → Respond
1. Prep: the rules live in code
const RULES = {
x: { max: 280, tags: 2, shortLinks: true },
linkedin: { max: 1300, tags: 3, shortLinks: false },
threads: { max: 500, tags: 1, shortLinks: false },
bluesky: { max: 300, tags: 2, shortLinks: false },
mastodon: { max: 500, tags: 3, shortLinks: true },
};
const url = String(b.url || '').trim();
if (!/^https?:\/\/\S+$/.test(url)) throw new Error('Blog post needs a valid url');
// Text budget per platform = limit - link - room for hashtags.
const budget = {};
for (const [p, r] of Object.entries(RULES))
budget[p] = r.max - (r.shortLinks ? 23 : url.length) - 2 - r.tags * 16;
The model never sees "280 characters". It sees the space that's actually left after the link and hashtags.
2. Build Prompt: one call per platform, each with its own angle
Instead of one call that writes all 5 posts and is told to use "different hooks", version B makes 5 small calls, and each one is told how to open:
const ANGLE = {
x: 'Open with the single most surprising result from the post.',
linkedin: 'Open with the problem the author had, then give the lesson in 3 to 5 short sentences.',
threads: 'Open with a question to the reader.',
bluesky: 'Open with a short, blunt opinion the author holds.',
mastodon: 'Open with what the author actually did, described plainly.',
};
Each prompt asks for {"text": "...", "hashtags": [...]} with no links and no hashtags in the text. Build Prompt returns 5 items, so the single LLM (HTTP Request) node runs 5 times.
3. Validate: assemble in code, then check all 5 together
The model never writes the link. Code strips anything that looks like one (including [link] and "link in bio"), then adds the real URL and the right number of hashtags:
function assemble(pl, item) {
const r = P.RULES[pl];
let t = String(item.text)
.replace(/https?:\/\/\S+/g, '')
.replace(/\[(link|url)[^\]]*\]|\{(link|url)\}|<(link|url)>|\(link\)|link in bio|insert link/gi, '');
t = t.replace(/(\s*#[^\s#]+)+\s*$/u, ''); // drop a trailing hashtag block
// ...turn extra inline hashtags into plain words, tidy whitespace...
const tags = [...new Set(item.hashtags.map(h => String(h).replace(/[^\p{L}\p{N}_]/gu, '')))]
.filter(h => h.length > 1 && h.length < 25).slice(0, free);
return t + '\n\n' + P.url + (tags.length ? '\n' + tags.map(h => '#' + h).join(' ') : '');
}
Then a check() function runs on the finished posts: length per platform, too short, ends mid-sentence, same first 5 words as another platform, opens with the blog title, and any number that isn't in the blog post (written-out numbers like "three" count too).
4. Retry once, with the reason
Platforms that failed go back to the model once, with the exact problem and the openings already taken:
const why = `Your previous answer was rejected: ${v.problems[pl].join('; ')}.`;
const avoid = `\nDo not start with any of these openings: ${openings.map(o => '"' + o + '"').join(', ')}.`;
// system prompt + why + ' Fix it. If unsure, use no numbers and keep it short.' + avoid
5. Final Check: ready or needs_review
If a post is still too long, code cuts it at the last full sentence that fits. Then everything is checked again, and every post gets a status:
const out = PLATFORMS.map(pl => ({
platform: pl,
text: posts[pl] || '',
status: posts[pl] && !problems[pl].length ? 'ready' : 'needs_review',
problems: problems[pl],
}));
Only ready posts should go to a scheduler without a person.
Results
| Version A | Version B | |
|---|---|---|
| Posts marked ready | 120 (no checks) | 77 |
| Posts sent to a human | 0 | 43 |
| "Ready" posts that failed the independent checker | 118 | 0 |
| Posts with at least one problem (all posts) | 118 / 120 | 31 / 120 |
| Runs where all 5 posts passed | 0 / 24 | 12 / 24 |
| No link | 93 | 0 |
| Too long / too many hashtags | 13 | 0 |
| Same opening as another platform | 54 | 29 |
| Number not in the blog post | 1 | 4 |
| Average time per run | 24 s | 41 s |
The 31 problem posts in version B are all inside the 43 that went to a human. Nothing broken was marked ready.
The first pass was rough, though. All 24 runs needed a retry. Before the retry and trim, 76 of 120 posts had a problem: 39 too long, 39 duplicate openings, 34 opening with the blog title (the model loves doing that), 21 cut off mid-sentence and 5 with a number that wasn't in the post. The retry and the sentence trim are doing a lot of the work here.
It also varies a lot by platform. LinkedIn, Bluesky and Threads came out ready 17-18 times out of 24. X came out ready only 8 times. After the link and room for 2 hashtags, X leaves about 220 characters of text, which is tight for a 1.8B model, and it kept opening with the title.
The part the checks can't catch
77 ready posts with zero format problems sounds like you can turn the scheduler on. So I read every ready post from round 1 next to its blog post. There were 21.
10 of them got the blog post wrong.
- The churn post on LinkedIn: "Our annual signups fell from 41% to 22%, but monthly churn **increased* from 6.1% to 4.3%."* It went down. Every number is in the blog post, so the number check passed.
- The same post on Mastodon called 41% a "churn rate". 41% was the share of new customers who picked annual.
- The same post on Bluesky: "It's a no-brainer for subscription businesses to ban annual discounts." The author was careful to say they couldn't separate the two effects.
- The cold email post on Bluesky: "Cold email is a waste of time." The post is about cold email that worked.
- The cold email post on LinkedIn flipped the post's main line: it said personal cold email "only works if it can be sent to 1,000 people without changing a word".
- The bakery post on Bluesky said cutting the "left at close" column took 38% off their waste. They added it.
- The meetup post on X said they save on food. Food is about ₹11,000 of their ₹15,000.
One more (the status page post on Threads) was garbled grammar. The other 10 were fine.
That's a 1.8B model at temperature 0.7, so expect better from a larger model. But the type of mistake is the important bit. Format checks tell you a post is well-formed. They don't tell you it's true. The number check catches invented numbers and misses real numbers in the wrong sentence.
It also copied more than I wanted. The prompt says "write in your own words", and 38 of version B's 120 posts still had a run of 12 or more words copied straight from the blog post (21 of the 77 ready ones). Version A did it 23 times. That's not a big deal when it's your own blog, but it's something to know if you were hoping for fresh wording.
What I'd actually ship
- Keep the rules in code, not the prompt. Limits, hashtags and the link are deterministic. A model is the wrong tool for counting characters.
- Never let the model write the link. Strip anything link-shaped from its output and add the real URL yourself. This one change took "no link" from 93 to 0.
- One small call per platform with a named angle beats one big call that asks for "different hooks".
- Check everything after the LLM, retry once with the reason, then stop. Anything still broken goes to a person, not into a second and third retry.
- Put a human approval step in front of the scheduler anyway. In n8n that can be a Slack message or a Google Sheets row per post with an approve column, and a second workflow that only schedules approved rows. Reading 5 short posts takes a minute, and the workflow has already done the formatting. That's the minute where you catch "churn increased".
- Don't trust the label you gave yourself. Score your workflow with a separate script on a fixed set of inputs, and read a sample by hand. My checker said 0 problems in the ready posts. My own reading said 10 of 21.
The free workflows
My free n8n AI workflows are in arkintof/n8n-ai-agent-workflows. This one isn't in the repo yet, so the important code is all above. If you build it yourself, the parts that matter are the RULES and budget in Prep, the assemble() function that owns the link and hashtags, and the ready / needs_review split at the end. Point it at any OpenAI-compatible API. I only tested it with the local 1.8B model.
How are you checking AI-written posts before they go out? If you've found something that catches meaning mistakes like "increased from 6.1% to 4.3%" without a human reading every post, I'd really like to see it.
Written with AI assistance; workflow built and tested by me.
Top comments (2)
The hand-read sample changes the meaning of ready quite a bit: it currently means the format checks passed, while a scheduler would read it as permission to publish. Would you split that into format_valid and editorially_approved, with scheduling requiring the latter for the exact text revision? I'd also report the 10/21 meaning errors as that first-round human-reviewed sample, separately from the 0/77 format failures. Otherwise two correct counts can suggest a quality guarantee that neither check actually provides.
For the "increased from 6.1% to 4.3%" class there's a cheap deterministic check: find "from X to Y" pairs, then compare the direction word in the same sentence (increased/rose/grew vs fell/dropped/cut) with the sign of Y minus X. It catches that exact example without a human. For the "41% churn rate" type, a second small-model pass that extracts (number, what it measures) pairs from both the post and the source, then diffs them in code, tends to be more reliable than asking a model "is this accurate?", because you compare structured pairs instead of trusting a yes/no.