At Autoadify we build Workflows: a schedule, a new Shopify/WooCommerce product, or a post that's taking off fires a pipeline that writes the copy, generates the image/video/voiceover/music, and publishes to Instagram, TikTok, YouTube, Facebook, LinkedIn, X, Threads, Bluesky and Reddit. No human in the loop.
"Call an LLM, then call a posting API" is a weekend project. Making it run unattended every day is not. Here's what broke, and what we changed.
The shape
trigger ─▶ content steps ─▶ transform steps ─▶ publish
ai_text add_hashtags 9 networks
ai_image rewrite_tone
ai_edit translate
ai_video / ai_animate
ai_voiceover / ai_music
library_media
Triggers: schedule (daily/weekly/biweekly/monthly, timezone-aware), shopify / woocommerce (product webhooks, HMAC-verified), monitor_post (fires when a post's likes/comments/shares/impressions/reach/clicks grow past a threshold), plus manual run.
Models: every step has its own model picker over a 60+ model catalog — GPT-5.6, Claude Opus 4.8 / Sonnet 5, Gemini 3.1, Grok, DeepSeek V4, Qwen, Kimi for text; Nano Banana, GPT Image 2, Seedream, Recraft, Krea for images; Veo 3.1, Kling 3.0, Seedance 2.0, Runway Gen-4.5, Hailuo, Wan 3.0 for video; ElevenLabs for voice and music.
Each step's output lands in a context map keyed by step ID, and later steps interpolate it: {{trigger.product.name}}, {{trigger.recent_posts}}, the previous step's text, the previous step's media.
1. The video prompt became the Instagram caption
To keep script and caption consistent, one AI Text step writes both:
[VIDEO PROMPT] Slow push-in on a ceramic mug, steam rising, morning light, 35mm
[CAPTION] Mornings, slower. ☕ New in the shop.
The first version published the "most recent text output" as-is. So a live Instagram caption read "[VIDEO PROMPT] Slow push-in on a ceramic mug…".
Fix: a tiny labelled-section parser. Each consumer reads the section meant for it:
// The part of a text output that is meant to be posted.
export function postCopy(text: string): string {
const { preamble, sections } = parseSections(text);
if (sections.length === 0) return text; // not sectioned — leave it alone
for (const label of ["CAPTION", "POST", "COPY"]) {
const caption = sections.find((s) => s.label === label);
if (caption?.body) return caption.body; // a caption section wins outright
}
// otherwise: drop prompt sections, keep the rest
}
The edge case worth a test: a real caption that starts with [NEW] Spring drop… is not a sectioned output. Only return untouched text when there's no caption or prompt label.
2. "Pick a random photo" re-posted the same photo
The library_media step picks from the user's uploads. Random-with-replacement means a 30-photo library repeats within days, and followers notice.
Fix: pick only from media never published by any post. When the pool is empty, the step fails — it doesn't fall back to a used photo. In an unattended system, a loud failure is better than quietly degraded output.
Second problem: uploads are named IMG_4471.jpg. The caption step had nothing to work with. Every upload now gets a vision pass that stores a description ("a ceramic mug on a linen tablecloth, morning light"), and the library step hands that to the next text step.
3. The daily post said the same thing every day
A scheduled "daily tip" with a fixed prompt is a fixed point: the model converges on the same five tips. Scheduled runs now inject {{trigger.date}}, {{trigger.weekday}} and {{trigger.recent_posts}} — the workflow's own last posts — into the first AI Text step. The model sees what it already said, and rotates.
4. The credit estimate lied
The builder shows "this run costs N credits" before you activate. The first version priced a video step at the length the user picked. But some models snap to their own durations (Grok Imagine renders 6s regardless), so the bill differed from the estimate.
Fix: the estimator uses the same creditsForGeneration function the executor charges with, and replicates the executor's model-fallback rules. Invariant: the estimate is never lower than a full successful run. A failed step refunds.
Related: we charge before calling the provider, so a process killed mid-generation would skip the refund catch. A reaper job refunds generations stuck in PENDING for 45+ minutes.
5. Every platform has a silent failure mode
Things that returned success and still produced a broken post:
-
LinkedIn: commentary uses "little text format". An unescaped
(silently truncates the post from that point. API returns 201. - Bluesky: link facets use UTF-8 byte offsets. Use JS string indices and links render as dead text. The 300 limit is graphemes.
- X: refresh tokens are single-use. Two concurrent jobs refreshing the same account → one wins, the other kills the connection. We serialize refresh behind a Postgres advisory lock.
- Threads: a 60-day token that dies permanently if not refreshed in time.
None of these throw. You only find them by reading your own published posts.
6. Safety has to sit on the pipeline, not the editor
We had moderation in the editor UI. Workflows don't use the editor. In fact our four generation entry points (studio, agent, storyboard, workflow executor) shared no common call path, so a control added to one reached none of the others.
Now there's a pre-generation gate (assertGenerationAllowed) in all four, and a pre-publish check on the publish path. Unattended content gets checked twice.
Why not just use n8n?
We get this a lot, and for a lot of teams n8n is the answer — it's fair-code, free to self-host, and connects to everything. If social posting is one branch of a larger automation, use it.
But every section above is something you'd rebuild inside n8n for a social pipeline:
| Problem above | In n8n |
|---|---|
| Publishing to TikTok, Threads, Bluesky | No official core nodes — community node, a paid posting API, or HTTP Request nodes |
| Long-running video | Submit → Wait → poll loop → download → re-upload to a public URL |
[VIDEO PROMPT] vs [CAPTION]
|
A Code node with your own parser |
| Never-repeat media pool | A database table + a query node |
Captions for IMG_4471.jpg
|
An extra vision API call |
recent_posts so daily posts rotate |
Query your own post history, pass it in |
| Credit estimate = actual bill | Separate bills per AI vendor |
LinkedIn (, Bluesky bytes/graphemes, X token rotation |
Discover each one in production |
| Moderation on every path | Add a moderation node to every workflow |
None of that is hard alone. Together it's the product. That's the trade: n8n gives you a general engine and you build the social layer; Autoadify is the social layer.
Takeaways
- Fail loud. An empty pool, a dead token, a timed-out render — fail the run and notify. Never "fall back" to something worse.
- Estimate with the billing code, not a parallel approximation.
- Read your own outputs. The platform said 201. The post was still wrong.
- Put controls where every path passes, not where the UI is.
If you'd rather use this than build it: Autoadify Workflows — start from a template, the step builder, or just describe the workflow in plain English. Happy to answer questions about any of the above in the comments.
Top comments (0)