Mein Blog postet jetzt automatisch auf Instagram. Hier ist die agentische Pipeline dahinter.

ai ai-agents automation python instagram

Letzten Monat wurde mir die Lücke zwischen dem Schreiben eines technischen Beitrags und dem tatsächlichen Erreichen von Lesern zu groß. Der Beitrag existiert. Das Publikum ist woanders. Diese Lücke manuell zu überbrücken bedeutet Reformatieren, Größenanpassen, Umschreiben für ein anderes Medium, und das macht niemand konsequent.

Also habe ich es automatisiert. Das Ergebnis ist eine Pipeline, die meinen RSS-Feed liest, jeden Beitrag in ein Instagram-Foto-Karussell umwandelt und es nach einem täglichen Zeitplan hochlädt, ohne dass ich es anfassen muss. Das Interessante ist nicht die Automatisierung selbst; es ist, was ich dabei gelernt habe, sie mit KI-Agenten als Co-Entwickler zu bauen, und was ihre Code-Reviews tatsächlich gefunden haben.

Die Form der Pipeline

Das System nimmt einen Input und produziert zwei Outputs. Für eine Blog-Post-URL generiert es einen YouTube Short und ein Instagram-Karussell.

Der Short folgt dem einfacheren Pfad: Ein LLM schreibt ein 60-Sekunden-Skript, Google Cloud TTS vertont es, Pillow rendert minimalistische Text-Slides, und MoviePy montiert sie zu einem 9:16-MP4. Ein Beitrag wird zu einem vertikalen Video.

Das Karussell ist der neuere, schwierigere Output. Das Instagram-Foto-Karussell-Format unterscheidet sich grundlegend von Video: Es belohnt Informationsdichte statt linearer Narration. Acht wischbare Karten, die jede für sich allein stehen, sind nicht dasselbe wie ein 60-Sekunden-Skript, das vorgelesen wird. Dieser Unterschied hat ein anderes Design erzwungen.

Die Pipeline läuft auf einem launchd-Agent, nutzt SQLite als Checkpoint-Datenbank, damit jede Stage nach einem Absturz fortgesetzt werden kann, und verarbeitet täglich einen Beitrag. Jede Stage ist idempotent. Wenn ein TTS-Aufruf fehlschlägt, setzt der nächste Lauf an genau diesem Punkt fort, ohne das Skript neu zu generieren.

Warum das Karussell-Format ein Umdenken erzwungen hat

Der naheliegende Schritt wäre gewesen, den Short einfach als Reel zu exportieren. Gleicher Inhalt, andere Plattform.

Aber Reels, die auf Reichweite optimiert sind, sind im Kern Unterhaltungsinhalt. Technische Beiträge, auf 60 Sekunden komprimiert, werden zu Rauschen. Wer etwas verstehen möchte, will keinen Sprecher, der eine Zusammenfassung über Stock-Footage von Servern vorliest.

Instagram-Karussells funktionieren anders. Der Leser wischt in seinem eigenen Tempo. Jede Karte kann ein Diagramm, eine Vergleichstabelle, einen konkreten Befund oder eine einzelne Zahl tragen, die Gewicht hat. Das Format belohnt Informationshierarchie, nicht Rhythmus.

Das hat das Design in Richtung eines Typisierungssystems für Karten gedrängt. Das Karussell-Deck hat ein definiertes Vokabular:

CARD_TYPES = {
    "cover":       "Titel und Untertitel für den Beitrag",
    "concept":     "Eine Idee, in einfacher Sprache erklärt",
    "comparison":  "Gegenüberstellung: A vs. B",
    "flow":        "Prozess mit nummerierten Schritten",
    "bar":         "Horizontale Balkenvisualisierung aus Artikeldaten",
    "stat":        "Eine Zahl, die zählt",
    "figure":      "Ein direkt aus dem Artikel gescraptes Bild",
    "takeaway":    "Das Eine, das man behalten sollte",
}

Ein dedizierter Deck-LLM-Aufruf erhält den vollständigen Artikeltext und gibt ein strukturiertes JSON-Array mit 6 bis 9 Karten aus. Jede Karte hat einen Typ, einen Titel und typspezifische Inhaltsfelder. Die takeaway-Karte kommt immer zuletzt. Das Modell fasst nicht frei zusammen; es wählt Inhalte aus dem Artikel aus und fügt sie in definierte Container.

Diese Einschränkung ist wichtiger als sie zunächst erscheint.

Die Regel: Keine erfundenen Daten

Der einfachste Fehlerfall einer LLM-gesteuerten Content-Pipeline ist Halluzination. Eine Balkenkarte mit erfundenen Benchmark-Zahlen, eine Stat-Karte mit einer Zahl, die der Artikel nie zitiert hat: Das untergräbt Vertrauen schneller, als gar nichts zu posten.

Die Pipeline setzt zwei harte Regeln durch. Balken- und Stat-Karten werden gegen den Artikeltext validiert, bevor sie gerendert werden. Wenn eine Zahl auf einer Karte erscheint, aber nicht im Quellartikel vorkommt, wird die Karte auf den Typ concept herabgestuft statt gelöscht, damit die Deck-Länge stabil bleibt.

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"  # herabstufen, nicht löschen
        if card["type"] == "figure":
            idx = card.get("figure_index", -1)
            if idx < 0 or idx >= len(available_figures):
                continue  # ungültige Bildreferenzen komplett verwerfen
        clean.append(card)
    return clean

Figure-Karten folgen demselben Prinzip, aber strenger: Sie können nur Bilder referenzieren, die aus dem Artikel selbst gescrapt wurden. Der Scraper holt das Artikel-HTML, findet alle <img>-Tags, filtert alles heraus, das nach UI-Chrome aussieht (Icons, Navigationselemente, Bilder unter einer Mindestgröße in Pixel), lädt den Rest herunter und übergibt sie dem Deck-Prompt als nummerierte Liste. Das Modell kann figure 0, figure 1, figure 2 referenzieren. Es kann keine erfinden.

Das ist eine bedeutsame Architekturentscheidung: Das LLM ist ein Selektor und Formatierer, kein Content-Generator. Der Artikel ist die Quelle der Wahrheit.

Der Stack

OpenRouter verwaltet alle LLM-Aufrufe. Der praktische Grund ist Routing-Flexibilität: Verschiedene Aufgaben in der Pipeline nutzen verschiedene Modelle. Das Narrations-Skript für YouTube (kurz, prägnant, 60 Sekunden) verwendet openai/gpt-4o-mini für Geschwindigkeit und Kosten. Das Karussell-Deck (längeres Reasoning, strukturierter JSON-Output, Inhaltstreue-Anforderungen) kann auf ein leistungsfähigeres Modell zielen, ohne die restliche Infrastruktur zu ändern. Ein Endpoint, ein API-Key, Modellwechsel pro Aufgabe.

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"]

Dasselbe Muster wiederholt sich für jeden LLM-Aufruf in der Pipeline. OpenRouters einheitliche API bedeutet, dass die Modellauswahl eine Konfigurationsfrage ist, keine Architekturfrage. Wenn ein Modell erscheint, das strukturierten JSON-Output besser handhabt, ändert sich eine Zeile.

Google Cloud TTS vertont die YouTube-Narration. Die Stimmauswahl ist kontextbewusst: Beiträge vom eigenen Blog verwenden eine männliche neuronale Stimme; Beiträge von externen URLs in der Queue eine weibliche. Ein kleines Detail, das aber wichtig wird, wenn man täglich Audio generiert und die Ausgabe sich über Monate akkumuliert.

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
    ),
)

Neuronale TTS ist gut genug geworden, dass eine tägliche Content-Pipeline sich darauf verlassen kann, ohne roboterhaft zu klingen. Die Qualitätsgrenze für narrierten technischen Content ist nicht mehr die Stimme; es ist das Skript.

Pillow rendert jede Karussell-Karte als 1080x1350 JPEG. Kein Browser, keine HTML-to-Image-Konvertierung, kein headless Chrome. Das Rendering ist direkt: Fonts laden, Textlayout berechnen, Primitive zeichnen, Bilder einfügen, Datei schreiben. Das ist mehr Code als ein Screenshot-basierter Ansatz, produziert aber deterministischen Output ohne externe Runtime-Abhängigkeiten und läuft in ein paar hundert Millisekunden pro Karte.

Der Renderer dispatcht nach Kartentyp:

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)

Jeder Renderer ist eine in sich geschlossene Funktion. Einen neuen Kartentyp hinzuzufügen bedeutet eine Funktion und einen Eintrag in CARD_TYPES. Brand-Primitive, Fonts, Farben und Abstands-Konstanten leben in einem Modul. Das Karten-Schema ist die stabile Schnittstelle zwischen der LLM-Schicht und der visuellen Schicht.

Instagrapi verwaltet den Instagram-Upload. Der Karussell-Upload nimmt eine Liste von Bildpfaden und eine Caption. Die Musikauswahl läuft eine Prioritätskette durch: zuerst Trending-Audio versuchen, bei Fehlschlag auf einen festen Track zurückfallen, bei erneutem Fehlschlag ohne Musik hochladen. Ein fehlgeschlagener Musik-Lookup sollte einen Upload nicht blockieren.

Wie Agenten das tatsächlich gebaut haben

Hier möchte ich langsamer werden, weil der Entwicklungsprozess mindestens so interessant ist wie das Produkt.

Das Karussell-Feature wurde durch einen subagent-getriebenen Workflow entworfen und implementiert. Der Prozess funktioniert so: eine Spec schreiben, einen Implementierungsplan in diskrete Aufgaben zerlegen, dann jede Aufgabe an einen frischen Implementierer-Subagent delegieren, der keine Kenntnis der anderen Aufgaben hat, gefolgt von einem unabhängigen Reviewer-Subagent, der nur die Implementierung sieht und keinerlei Kontext über meine Absichten hat.

Der Reviewer ist kein Politur-Durchgang. Der Reviewer ist adversarial: Seine Aufgabe ist es, Probleme zu finden, nicht den Stil zu verbessern.

Sieben Aufgaben durchliefen diese Schleife. Die Reviews fanden fünf echte Bugs, bevor ein einziger Test lief:

Cover-Untertitel-Überlappung. Der Cover-Renderer platzierte den Untertitel in einem festen Y-Abstand vom Titel. Lange Titel schoben in die Untertitel-Zone und der Text überlagerte sich. Der Reviewer meldete das. Der Fix: die Bounding-Box-Höhe des Titels messen und die Y-Position des Untertitels dynamisch daraus ableiten.

Leere-Bullet-IndexError. Der Flow-Karten-Renderer iterierte über Step-Bullets und setzte voraus, dass mindestens einer existiert. Der Reviewer simulierte mental, was passiert, wenn das Deck-LLM eine Flow-Karte mit leerer Steps-Liste ausgibt, und meldete den Crash-Pfad. Eine Guard-Abfrage ergänzt.

Hohler Test. Der Test für den Figure-Karten-Renderer prüfte, dass die Funktion ohne Exception durchläuft, assertete aber nichts über den Output. Der Reviewer meldete, dass er aus den falschen Gründen grün war. Assertions auf JPEG-Dateigröße und Existenz wurden ergänzt.

Stage-Key-Kollision. Die Pipeline checkpointet jede Stage in SQLite über einen String-Key. Die Karussell-Stage hatte den Key "instagram" vom alten Reels-Code geerbt. Der Reviewer erkannte, dass wenn beide Pfade je gegen denselben Post liefen, sie sich eine Checkpoint-Zeile teilen würden und die Pipeline einen als bereits erledigt behandeln würde. Der Key wurde in "carousel" umbenannt.

Ungeschützter Artikel-Fetch in der letzten Stage. Die Integrations-Stage holte das Artikel-HTML für den Karussell-Aufbau. Wenn dieses Fetch eine Exception warf, propagierte sie und markierte den Post als error. Ein error-Status sperrt den Post für zukünftige Wiederholungen. Zu diesem Zeitpunkt in der Sequenz war der YouTube-Upload bereits erfolgreich. Der Reviewer erkannte das: Ein transienter Netzwerkfehler beim Artikel-Fetch würde den Post dauerhaft aus der Queue verlieren, obwohl das Video bereits auf YouTube gelandet war. Ein try/except isoliert jetzt die Karussell-Stage vom YouTube-Ergebnis.

Keinen dieser Bugs hätte man durch einen Happy-Path-Test gefunden. Sie sind die Art von Dingen, die man sechs Wochen nach dem Go-live entdeckt, meist zu einem ungünstigen Zeitpunkt.

Das verallgemeinerungswürdige Muster: Ein Reviewer ohne Kenntnis deiner Absichten, dessen einzige Aufgabe das Finden von Problemen ist, unterscheidet sich grundlegend von einem Code-Review, bei dem der Reviewer bereits den Kontext des Autors teilt. Geteilter Kontext ist genau der Grund, warum Code-Reviews die Bugs übersehen, die sie übersehen.

Was täglicher Betrieb lehrt

Transiente Fehler sind permanent ohne ein explizites Retry-Gate. Google TTS gibt gelegentlich einen 504 zurück. Wenn eine Stage-Zeile als error markiert wird, behandelt der “Habe ich das schon gesehen?”-Check der Pipeline jede existierende Zeile als gesehen und überspringt den Post für immer. Ein transienter Netzwerk-Blip verbrennt einen Post-Slot. Der Fix ist ein manueller Retry-Befehl, der die Error-Zeile löscht; die Idempotenz des Hauptloops hängt von dieser Invariante ab, und die sollte man nicht schwächen.

Die SQLite-Checkpoint-Datenbank ist die richtige Wahl für eine Solo-Pipeline, die unbeaufsichtigt läuft. Jede Stage schreibt ihren Output und markiert sich als abgeschlossen, bevor die nächste beginnt. Die Pipeline kann zu jedem Zeitpunkt abgebrochen und von dort fortgesetzt werden, ohne bereits erfolgreich abgeschlossene Arbeit zu wiederholen. Für einen täglichen Cron, den niemand beobachtet, ist das wichtiger als es klingt.

OpenRouters Modell-Routing zeigt seinen Wert über die Zeit. Das Narrations-Skript und das Karussell-Deck können völlig verschiedene Modelle verwenden, ohne eine strukturelle Änderung am Codebase. Wenn ein Modell erscheint, das Long-Context Structured Output besser handhabt, ändert sich ein Config-Key. Diese Flexibilität war nicht die ursprüngliche Motivation für OpenRouter; sie wurde erst offensichtlich, nachdem die Pipeline lange genug lief, um mit verschiedenen Modellen für verschiedene Aufgaben experimentieren zu wollen.

Was man mitnehmen sollte

Die beschriebene Pipeline ist spezifisch für mein Setup. Die Design-Haltung ist das, was sich überträgt.

LLMs sind Selektoren und Formatierer, keine Content-Generatoren. Der Artikeltext ist die Quelle der Wahrheit; das Modell entscheidet, was es hervorhebt und wie es strukturiert wird. Validierung prüft, dass das Modell innerhalb der vom Quelltext gesetzten Grenzen geblieben ist. Das macht den Output vertrauenswürdig auf eine Art, die reine Generierung nicht kann, und macht Fehler diagnostizierbar, weil der Input immer nachverfolgbar ist.

Typisierte Karten-Vokabulare trennen Inhaltsentscheidungen von Rendering-Entscheidungen. Wenn man einmal einen comparison-Typ definiert hat, kann man ihn für Instagram, LinkedIn oder E-Mail unterschiedlich rendern, ohne den LLM-Aufruf anzufassen. Das Karten-Schema wird zur stabilen Schnittstelle zwischen der Intelligenz-Schicht und der visuellen Schicht, und diese Trennung ist es, die erlaubt, beide Seiten unabhängig voneinander weiterzuentwickeln.

Das Unabhängige-Reviewer-Muster in der agentischen Entwicklung findet eine Klasse von Bugs, die Testsuiten übersehen: Interaktionseffekte zwischen unabhängig implementierten Teilen, fehlende Guards auf adversarialen Input-Pfaden, Tests, die grün durchlaufen, aber nichts Sinnvolles assertieren. Ein Reviewer ohne Kontext über deine Absichten findet andere Probleme als einer, der bereits versteht, was du versucht hast.

Für alle, die etwas Ähnliches entwerfen, die Architektur, die funktioniert hat: SQLite für idempotentes Checkpointing, OpenRouter für flexibles Modell-Routing pro Aufgabe, ein typisiertes Output-Schema, das Inhalt von Rendering trennt, und eine Validierungsschicht, die keine Daten weiterleitet, die die Quelle nicht enthielt. Die spezifischen Services sind austauschbar. Die Design-Prinzipien sind es nicht.


Der Pipeline-Quellcode ist nicht öffentlich, aber die hier beschriebene Architektur, der Stack und der Entwicklungsprozess sind real und laufen täglich.