DEV Community

Crzy undefined (undefined)
Crzy undefined (undefined)

Posted on

I Turned 8 Blog Posts into 120 Social Posts with n8n. The Naive Version Forgot the Link 93 Times

"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
LinkedIn 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": "..."}, ...]}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.',
};
Enter fullscreen mode Exit fullscreen mode

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(' ') : '');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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],
}));
Enter fullscreen mode Exit fullscreen mode

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

  1. Keep the rules in code, not the prompt. Limits, hashtags and the link are deterministic. A model is the wrong tool for counting characters.
  2. 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.
  3. One small call per platform with a named angle beats one big call that asks for "different hooks".
  4. 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.
  5. 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".
  6. 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)

Collapse
 
innokentyb profile image
Kent Bodrov •

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.

Collapse
 
fun_fun_19313eced54af3c68 profile image
FUN FUN •

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.