My Blog Now Posts to Instagram Automatically. Here Is the Agentic Pipeline Behind It.

ai ai-agents automation python instagram

Last month I got tired of the gap between writing a technical post and actually getting people to read it. The post exists. The audience is elsewhere. Bridging that gap manually means reformatting, resizing, rewriting for a different medium, and nobody does it consistently.

So I automated it. The result is a pipeline that reads my RSS feed, converts each post into an Instagram photo carousel, and uploads it on a daily schedule without me touching it. The interesting part is not the automation itself; it is what I learned building it with AI agents as co-developers, and what their code reviews actually caught.

The Shape of the Pipeline

The system takes one input and produces two outputs. Given a blog post URL, it generates a YouTube Short and an Instagram carousel.

The Short follows the simpler path: an LLM writes a 60-second narration script, Google Cloud TTS voices it, Pillow renders minimal text slides, and MoviePy assembles them into a 9:16 MP4. One post becomes one vertical video.

The carousel is the newer, harder output. Instagram’s photo carousel format is fundamentally different from video: it rewards information density over linear narrative. Eight swipeable cards that each stand alone are not the same thing as a 60-second script read aloud. That difference forced a different design.

The pipeline runs on a launchd agent, uses SQLite as a checkpoint database so any stage can resume after a crash, and processes one post per day. Every stage is idempotent. If a TTS call fails, the next run resumes from that exact point without re-generating the script.

The obvious move for automating Instagram would have been to re-export the Short as a Reel. Same content, different platform.

But Reels optimized for reach are fundamentally entertainment content. Technical posts compressed into 60 seconds become noise. The reader who wants to understand something does not want a narrator reading a summary over stock footage of servers.

Instagram carousels work differently. The reader swipes at their own pace. Each card can carry a diagram, a comparison table, a concrete finding, or a single stat that lands with weight. The format rewards information hierarchy, not rhythm.

This pushed the design toward a typed card system. The carousel deck has a defined vocabulary:

CARD_TYPES = {
    "cover":       "Title and subtitle for the post",
    "concept":     "One idea explained in plain language",
    "comparison":  "Side-by-side: A vs B",
    "flow":        "Process with numbered steps",
    "bar":         "Horizontal bar visualization from article data",
    "stat":        "Single number that matters",
    "figure":      "An image scraped directly from the article",
    "takeaway":    "The one thing to remember",
}

A dedicated deck LLM call receives the full article text and emits a structured JSON array of 6 to 9 cards. Each card has a type, a title, and type-specific content fields. The takeaway card always goes last. The model is not summarizing freely; it is selecting and fitting content from the article into defined containers.

That constraint matters more than it might seem.

The No Invented Data Rule

The easiest failure mode for an LLM-driven content pipeline is hallucination. A bar card showing made-up benchmark numbers, a stat card with a figure the article never cited: these erode trust faster than not posting at all.

The pipeline enforces two hard rules. Bar and stat cards are validated against the article text before rendering. If a number appears on a card but not in the source article, the card is demoted to a concept type rather than dropped, so the deck length stays stable.

def _validate_deck(deck: list[dict], article_text: str) -> list[dict]:
    clean = []
    for card in deck:
        if card["type"] in ("bar", "stat"):
            value = str(card.get("value", ""))
            if value and value not in article_text:
                card["type"] = "concept"  # demote, don't drop
        if card["type"] == "figure":
            idx = card.get("figure_index", -1)
            if idx < 0 or idx >= len(available_figures):
                continue  # drop invalid figure references entirely
        clean.append(card)
    return clean

Figure cards follow the same principle but stricter: they can only reference images scraped from the article itself. The scraper fetches the article HTML, finds all <img> tags, filters out anything that looks like UI chrome (icons, navigation elements, images below a minimum pixel dimension), downloads the rest, and passes them to the deck prompt as a numbered list. The model can reference figure 0, figure 1, figure 2. It cannot invent one.

This is a meaningful architectural decision: the LLM is a selector and formatter, not a content generator. The article is the source of truth.

The Stack

OpenRouter handles all LLM calls. The practical reason is routing flexibility: different tasks in the pipeline use different models. The narration script for YouTube (short, punchy, 60 seconds) uses openai/gpt-4o-mini for speed and cost. The carousel deck (longer reasoning, structured JSON output, content fidelity requirements) can target a more capable model without changing any other infrastructure. One endpoint, one API key, swap models per task.

response = openrouter_client.chat.completions.create(
    model="openai/gpt-4o-mini",
    messages=[
        {"role": "system", "content": DECK_SYSTEM_PROMPT},
        {"role": "user", "content": article_text},
    ],
    response_format={"type": "json_object"},
)
deck = json.loads(response.choices[0].message.content)["cards"]

The same pattern repeats for every LLM call in the pipeline. OpenRouter’s unified API means model selection is a configuration concern, not an architectural one. When a new model ships that handles structured JSON output better, one line changes.

Google Cloud TTS voices the YouTube narration. The voice selection is context-aware: posts from my own blog use a male neural voice; posts from external URLs in the queue use a female voice. This is a minor detail, but it matters for consistency when you are generating audio daily and the output accumulates over months.

voice = texttospeech.VoiceSelectionParams(
    language_code="en-US",
    name=TTS_VOICE_NAME if is_own_post else TTS_VOICE_NAME_FEMALE,
)
response = tts_client.synthesize_speech(
    input=texttospeech.SynthesisInput(text=script),
    voice=voice,
    audio_config=texttospeech.AudioConfig(
        audio_encoding=texttospeech.AudioEncoding.MP3
    ),
)

Neural TTS has become good enough that a daily content pipeline can rely on it without sounding robotic. The quality ceiling for narrated technical content is not the voice anymore; it is the script.

Pillow renders every carousel card as a 1080x1350 JPEG. There is no browser, no HTML-to-image conversion, no headless Chrome. The rendering is direct: load fonts, calculate text layout, draw primitives, paste images, write the file. This is more code than a screenshot-based approach, but it produces deterministic output with no external runtime dependencies and runs in a few hundred milliseconds per card.

The renderer dispatches on card type:

def render_card(card: dict, out_path: str, figures: list[Path]) -> None:
    img = Image.new("RGB", (1080, 1350), color=BACKGROUND)
    draw = ImageDraw.Draw(img)

    match card["type"]:
        case "cover":      _render_cover(draw, img, card)
        case "concept":    _render_concept(draw, img, card)
        case "comparison": _render_comparison(draw, img, card)
        case "bar":        _render_bar(draw, img, card)
        case "figure":     _render_figure(draw, img, card, figures)
        case "takeaway":   _render_takeaway(draw, img, card)
        case _:            _render_concept(draw, img, card)

    img.save(out_path, "JPEG", quality=92)

Each renderer is a self-contained function. Adding a new card type is one function and one entry in CARD_TYPES. Brand primitives, fonts, colors, and padding constants live in one module. The card schema is the stable interface between the LLM layer and the visual layer.

Instagrapi handles the Instagram upload. The carousel upload takes a list of image paths and a caption. The music selection runs a priority chain: try trending audio first, fall back to a fixed track if the trending lookup fails, fall back to no music if both fail. A failed music lookup should not block an upload.

How Agents Actually Built This

This is where I want to slow down, because the development process is as interesting as the product.

The carousel feature was designed and implemented through a subagent-driven workflow. The process works like this: write a spec, write an implementation plan broken into discrete tasks, then dispatch each task to a fresh implementer subagent that has no knowledge of the other tasks, followed immediately by an independent reviewer subagent that sees only the implementation and has no context about my intentions.

The reviewer is not a polish pass. The reviewer is adversarial: its job is to find problems, not improve style.

Seven tasks went through this loop. The reviews caught five real bugs before any code ran:

Cover subtitle spacing overlap. The cover card renderer placed the subtitle at a fixed Y offset from the title. Long titles pushed into the subtitle zone and the text overlapped. The reviewer flagged this. The fix was to measure the title bounding box height and derive the subtitle Y position from it dynamically.

Empty-bullet IndexError. The flow card renderer iterated over step bullets and assumed at least one existed. The reviewer mentally traced what happens when the deck LLM emits a flow card with an empty steps list and flagged the crash path. One guard added.

Hollow test. The test for the figure card renderer checked that the function ran without raising an exception but asserted nothing about the output. The reviewer flagged it as passing for the wrong reasons. Assertions on JPEG file size and existence were added.

Stage key collision. The pipeline checkpoints each stage in SQLite using a string key. The carousel stage had inherited the key "instagram" from the old Reels code. The reviewer caught that if both paths ever ran against the same post, they would share a checkpoint row and the pipeline would treat one as already done. The key was renamed to "carousel".

Unguarded article fetch in the final stage. The integration stage fetched the article HTML to build the carousel. If that fetch raised an exception, it propagated and marked the post as error. An error status locks the post from future retries. By that point in the sequence, the YouTube upload had already succeeded. The reviewer caught this: a transient network error on the article fetch would permanently lose the post from the queue even though the video had already landed on YouTube. A try/except now isolates the carousel stage from the YouTube result.

None of these bugs would have been caught by running the code against happy-path inputs. They are the kind of thing you discover six weeks into production, usually at an inconvenient hour.

The pattern worth generalizing: a reviewer that has no knowledge of your intentions and whose only job is to find problems is different from any code review where the reviewer already shares context with the author. Shared context is exactly why code review misses the bugs it misses.

What Running This Daily Teaches You

Transient failures are permanent without an explicit retry gate. Google TTS occasionally returns a 504. When a post’s stage row gets marked error, the pipeline’s “have I seen this?” check treats any existing row as seen and skips the post forever. One transient network blip burns a post slot. The fix is a manual retry command that clears the error row; the main loop’s idempotency depends on that invariant and you do not want to weaken it.

The SQLite checkpoint database is the right choice for a solo pipeline running unattended. Every stage writes its output and marks itself complete before the next stage starts. The pipeline can be killed at any point and resumed from where it stopped without re-running work that already succeeded. For a daily cron that nobody is watching, this matters more than it sounds.

OpenRouter’s model routing reveals its value over time. The narration script and the carousel deck can use completely different models with no structural change to the codebase. When a model ships that handles long-context structured output better, one config key changes. That flexibility was not the original motivation for using OpenRouter; it became obvious only after running the pipeline long enough to want to experiment with different models for different tasks.

What to Take From This

The pipeline I described is specific to my setup. The design posture is what transfers.

LLMs are selectors and formatters, not content generators. The article text is the source of truth; the model decides what to surface and how to structure it. Validation checks that the model stayed within the boundary the source established. This makes the output trustworthy in a way that pure generation does not, and it makes failures diagnosable because the input is always traceable.

Typed card vocabularies separate content decisions from rendering decisions. Once you define a comparison type, you can render it differently for Instagram, LinkedIn, or email without touching the LLM call. The card schema becomes the stable interface between the intelligence layer and the visual layer, and that separation is what lets you evolve either side independently.

The independent reviewer pattern in agentic development catches a class of bugs that test suites miss: interaction effects between independently implemented parts, missing guards on adversarial input paths, tests that pass but assert nothing meaningful. A reviewer with no context about your intentions finds different problems than one who already understands what you were trying to do.

If you are designing something similar, the architecture that has worked: SQLite for idempotent checkpointing, OpenRouter for flexible per-task model routing, a typed output schema that separates content from rendering, and a validation layer that refuses to forward data the source did not contain. The specific services are interchangeable. The design principles are not.


The pipeline source is not public, but the architecture, stack, and development process described here are real and running daily.