Introduction

postctl

Social-Media- und Blog-Manager fürs Terminal. Schreibe Beiträge in Markdown, plane sie ein und veröffentliche sie auf Twitter/X, LinkedIn, Threads, Mastodon, Bluesky, Facebook, Telegram, Discord, Reddit, Dev.to, Hashnode und Medium — per Kommandozeile oder in einer vollständigen TUI.

Unterstützte Plattformen: Twitter/X · LinkedIn · Threads · Mastodon · Bluesky · Facebook · Telegram · Discord · Reddit · Dev.to · Hashnode · Medium


Schnellstart

  1. Installieren

    git clone https://github.com/aeon022/postctl && cd postctl
    ./setup.sh
  2. Bei einer Plattform anmelden

    postctl auth --platform twitter
  3. Beitrag schreiben — erstelle eine Markdown-Datei (siehe Beitragsformat):

    ---
    platform: twitter
    title: Mein erster Beitrag
    ---
    
    Hallo von postctl.
  4. Datei importieren

    postctl import my-post.md
  5. Sofort veröffentlichen oder einplanen

    postctl post <ID>
    postctl schedule <ID> --time 2026-07-10T09:00:00+02:00
  6. TUI öffnen, um alles zu verwalten

    postctl tui

Cheatsheet

postctl                                  TUI öffnen (Standard)
postctl tui                              TUI explizit öffnen

postctl auth --platform PLATFORM         Bei einer Plattform anmelden
postctl config [--show] [--set K V]      Konfiguration anzeigen oder setzen
postctl config test                      Verbindung zu konfigurierten Plattform-APIs testen
postctl rss add URL                      RSS-Feed-URL hinzufügen
postctl rss list                         Alle konfigurierten RSS-Feeds auflisten
postctl rss remove URL                   Konfigurierten RSS-Feed entfernen
postctl rss import                       Feeds abrufen und Artikel als Entwürfe importieren

postctl import FILE_OR_DIR               Markdown-Beitrag(e) importieren
postctl list [--platform P] [--status S] [--campaign C] [--format human|json]
postctl template --platform PLATFORM     Beitragsvorlage erzeugen

postctl post ID [--dry-run]              Beitrag sofort veröffentlichen (Alias: publish)
postctl publish ID [--dry-run]           Beitrag sofort veröffentlichen
postctl schedule ID [--time DATETIME] [--queue] Beitrag einplanen (RFC3339) oder in die Warteschlange
postctl cancel ID                        Geplanten Beitrag abbrechen
postctl delete ID                        Beitrag lokal und remote löschen
postctl campaign list                    Alle Kampagnen auflisten
postctl campaign post NAME [--dry-run]   Alle Beiträge einer Kampagne veröffentlichen

postctl generate URL                     Beitrag per KI aus einer URL generieren
postctl repurpose ID --platform TARGET [--tone TONE] Beitrag mit anderem Ton umformulieren

postctl git-hook install [--dir DIR]     Post-Commit-Git-Hook installieren
postctl git-hook uninstall               Git-Hook deinstallieren

postctl analytics [--platform P] [--format human|json]
postctl daemon [--dry-run]               Hintergrund-Scheduler ausführen
postctl mcp                              MCP-Server starten (stdio)
postctl version                          Version ausgeben

Beitragsformat (Markdown)

Frontmatter-Felder

Feld Pflicht Werte / Format Beschreibung
platform Ja twitter, linkedin, threads, mastodon, bluesky Zielplattform
title Nein String Interner Titel (wird nicht veröffentlicht)
campaign Nein String-Slug Gruppiert Beiträge in einer Kampagne
schedule Nein RFC3339 oder "queue" Geplante Veröffentlichungszeit oder Smart Queue

Body-Format

Schreibe den Beitragstext in reinem Markdown unterhalb des abschließenden --- des Frontmatter-Blocks.

  • LinkedIn, Threads, Mastodon, Bluesky: Ein zusammenhängender Text, keine Trenner.
  • Twitter/X-Threads: Einzelne Tweets mit einer Zeile trennen, die nur --- enthält. Jeder Abschnitt wird ein Tweet im Thread.

Beispiel: Twitter-Thread

---
platform: twitter
title: Launch-Ankündigung
campaign: product-launch
schedule: 2026-07-10T09:00:00+02:00
---

Das ist der erste Tweet. Maximal 280 Zeichen für Twitter/X.

---

Zweiter Tweet im Thread.

---

Dritter Tweet. Threads gibt es nur bei Twitter.

CLI-Referenz

Authentifizierung und Konfiguration

Befehl Beschreibung
postctl auth --platform PLATFORM Bei der angegebenen Plattform anmelden (OAuth-Flow)
postctl config --show Aktuelle Konfiguration ausgeben
postctl config --set KEY VALUE Konfigurationswert setzen
postctl config test Verbindungsdiagnose für alle konfigurierten Plattform-APIs

RSS-Feed-Importer

Befehl Beschreibung
postctl rss add URL Neue RSS-Feed-URL zur Konfiguration hinzufügen
postctl rss list Alle konfigurierten RSS-Feeds auflisten
postctl rss remove URL Konfigurierten RSS-Feed entfernen
postctl rss import RSS-Feeds abrufen und neue Artikel als Entwürfe importieren

Content-Verwaltung

Befehl Beschreibung
postctl import FILE_OR_DIR Eine Markdown-Datei oder ein Verzeichnis voller Dateien importieren
postctl list Beiträge auflisten; filtern mit --platform, --status, --campaign; Format mit --format human|json
postctl template --platform PLATFORM Markdown-Vorlage für die angegebene Plattform ausgeben
postctl generate URL KI-generierten Entwurf aus dem Artikel unter URL erzeugen
postctl repurpose ID --platform TARGET [--tone TONE] Bestehenden Beitrag mit optionalem, angepasstem Ton umformulieren

Veröffentlichung

Befehl Beschreibung
postctl post ID Beitrag sofort veröffentlichen (Alias: publish)
postctl publish ID Beitrag sofort veröffentlichen
postctl post ID --dry-run Veröffentlichung simulieren, ohne zu senden
postctl schedule ID --time DATETIME Geplante Veröffentlichungszeit setzen oder ändern
postctl schedule ID --queue Beitrag für den nächsten freien Warteschlangen-Slot einplanen
postctl cancel ID Geplanten Beitrag abbrechen (Status zurück auf Entwurf)
postctl delete ID Beitrag aus der lokalen Datenbank löschen (und remote, falls veröffentlicht)
postctl campaign list Alle Kampagnen mit Beitragsanzahl auflisten
postctl campaign post NAME Alle Beiträge einer Kampagne veröffentlichen
postctl campaign post NAME --dry-run Kampagnen-Veröffentlichung simulieren
postctl git-hook install [--dir DIR] Lokalen Git-Post-Commit-Hook für Auto-Import installieren
postctl git-hook uninstall Lokalen Git-Post-Commit-Hook entfernen
postctl daemon Hintergrund-Scheduler-Daemon starten
postctl daemon --dry-run Daemon im Simulationsmodus ausführen

Analytics

Befehl Beschreibung
postctl analytics Analytics über alle Plattformen anzeigen
postctl analytics --platform PLATFORM Auf eine Plattform filtern
postctl analytics --format json Als JSON ausgeben

MCP-Server

Befehl Beschreibung
postctl mcp MCP-Server auf stdio starten, zur Nutzung durch KI-Agenten

TUI-Anleitung

Starten mit postctl oder postctl tui.

Ansichten

Ansicht Beschreibung
Beitragsliste Hauptansicht; zeigt alle Beiträge mit Status-Badges (Entwurf / geplant / veröffentlicht / fehlgeschlagen)
Detail Vollständiger Beitragsinhalt und Metadaten
Editor Beitragstext und Frontmatter-Felder schreiben oder bearbeiten
Zeitplan Geplante Zeit für einen Beitrag setzen oder anpassen
Analytics Plattformweite Kennzahlen-Übersicht
Verlauf Protokoll vergangener Veröffentlichungen
Einstellungen App-Konfiguration
Readme Dokumentation direkt in der App

Zwischen Ansichten wechseln über Tabs oder die untenstehenden Tastenkürzel.

Tastenkürzel

Beitragsliste

Taste Aktion
j / k Nach oben/unten navigieren
Space Beitragsauswahl für Massenaktionen umschalten
Enter Detailansicht öffnen
n Neuer Beitrag
e Ausgewählten Beitrag bearbeiten
d Ausgewählte(n) Beitrag/Beiträge lokal und remote löschen (falls veröffentlicht)
p Ausgewählte(n) Beitrag/Beiträge sofort veröffentlichen
s Ausgewählte(n)/markierte(n) Beitrag/Beiträge auf Warteschlangen-Slots einplanen
Esc Mehrfachauswahl aufheben (oder Kampagnenfilter zurücksetzen)
Tab Tabs wechseln
q Beenden

Detailansicht

Taste Aktion
Esc Zurück zur Liste
e Beitrag bearbeiten
p Beitrag sofort veröffentlichen
d Beitrag lokal und remote löschen
r Beitrag umformulieren

Editor

Taste Aktion
Ctrl+S Speichern
Esc Abbrechen
Tab Zwischen Feldern wechseln

MCP — KI-Integration

postctl bringt einen eingebauten MCP-Server mit, der alle Kernfunktionen für KI-Agenten bereitstellt. Damit können Tools wie Claude Desktop in deinem Namen Beiträge erstellen, einplanen und veröffentlichen.

Claude-Desktop-Konfiguration

Füge Folgendes zu ~/Library/Application Support/Claude/claude_desktop_config.json hinzu:

{
  "mcpServers": {
    "postctl": {
      "command": "postctl",
      "args": ["mcp"]
    }
  }
}

Starte Claude Desktop nach dem Speichern neu. Das postctl-Binary muss in deinem PATH liegen.

MCP-Tools

Tool Parameter Beschreibung
list_posts platform, status, campaign (alle optional) Beiträge mit optionalen Filtern auflisten
get_post id Vollständigen Beitragsinhalt und Metadaten per ID abrufen
create_post platform, body, title, campaign, schedule Entwurf oder geplanten Beitrag erstellen
publish_post id, dry_run Beitrag sofort veröffentlichen
schedule_post id, schedule (RFC3339) Geplante Veröffentlichungszeit setzen oder ändern
list_campaigns — Alle Kampagnen mit Gesamtanzahl und Status-Aufschlüsselung auflisten
get_campaign name, status (optionaler Filter) Alle Beiträge einer Kampagne mit vollständigem Inhalt abrufen

Für Twitter-Threads einzelne Tweets im body-Feld beim Aufruf von create_post mit \n---\n trennen.

Zeitplan-Werte müssen RFC3339 sein, z. B. 2026-07-10T09:00:00+02:00.

Beispiele für KI-Workflows

Kampagne aus einem Artikel planen

“Lies den Artikel unter https://example.com/blog/launch und erstelle dann eine fünfteilige Kampagne namens launch-week mit je einem Beitrag pro Tag ab Montag. Nutze Twitter für drei Beiträge und LinkedIn für zwei.”

Claude ruft create_post für jeden Beitrag mit passendem Inhalt, dem Kampagnennamen und gestaffelten schedule-Werten auf, abgeleitet aus dem Artikelinhalt.

Geplante Beiträge prüfen und veröffentlichen

“Zeig mir alles, was diese Woche geplant ist, und veröffentliche alle Beiträge, die bereit aussehen.”

Claude ruft list_posts mit status: scheduled auf, präsentiert die Ergebnisse zur Prüfung und ruft dann publish_post für jeden freigegebenen Beitrag auf — oder alle auf einmal, wenn du bestätigst.

Blogbeitrag über Plattformen hinweg umformulieren

“Nimm Beitrag abc123 und erstelle angepasste Versionen für LinkedIn und Threads.”

Claude ruft get_post auf, um das Original zu holen, und dann zweimal create_post — einmal für linkedin und einmal für threads — und passt dabei Ton und Länge automatisch pro Plattform an.


Plattform-Hinweise

Plattform Zeichenlimit Threads Bilder
Twitter/X 280 pro Tweet Ja, getrennt mit --- Unterstützt
LinkedIn ~3.000 empfohlen Nein Unterstützt
Threads 500 Nein Mindestens eines empfohlen (Meta-Vorgabe)
Mastodon 500 (Instanz-Standard) Nein Unterstützt
Bluesky 300 Nein Unterstützt
Facebook ~63.206 Nein Unterstützt
Telegram 4.096 (1.024 für Bildunterschriften) Nein Unterstützt
Discord 2.000 Nein Unterstützt
Reddit 40.000 Nein Nicht unterstützt
Dev.to ~100.000 Nein Nicht unterstützt
Hashnode ~100.000 Nein Nicht unterstützt
Medium ~100.000 Nein Nicht unterstützt

Twitter-Threads haben kein hartes Limit für die Anzahl der Tweets, aber halte Threads fokussiert. Andere Plattformen unterstützen keine mehrteiligen Thread-Beiträge — nutze dort einen einzelnen zusammenhängenden Text.

[!WARNING] API-Rate-Limits & Massenveröffentlichung: Mehrere Beiträge gleichzeitig oder in schneller Folge zu veröffentlichen kann zu API-Rate-Limits oder dauerhaften Kontosperrungen führen (besonders bei föderierten Netzwerken wie Mastodon). Verteile Beiträge immer über einen Zeitraum (z. B. mindestens 15–30 Minuten Abstand zwischen aufeinanderfolgenden Veröffentlichungen).


Architektur

Markdown-Dateien
      |
   postctl import
      |
      v
SQLite  (~/.local/share/postctl/postctl.db)
      |
      +---> TUI (Bubbletea)    ---> Plattform-APIs  (Twitter, LinkedIn, Threads, Mastodon, Bluesky)
      |
      +---> MCP-Server (stdio) ---> KI-Agenten  (Claude Desktop, etc.)
      |
      +---> postctl daemon     ---> geplante Veröffentlichung über Plattform-APIs

Voraussetzungen: macOS oder Linux · Go 1.21+ · API-Zugangsdaten für jede genutzte Plattform


Profile — getrennte Konten für Arbeit / Privat / pro Projekt

Standardmäßig nutzt postctl eine einzige Konfiguration und Datenbank. Um komplett getrennte Sätze an Zugangsdaten, Beiträgen und Zeitplänen zu führen — z. B. ein privates und ein geschäftliches Twitter-Konto, oder einen Satz pro Kundenprojekt — übergib --profile <name> (oder setze POSTCTL_PROFILE) bei jedem Befehl:

postctl --profile work config set twitter.client_id "..."
postctl --profile work config set twitter.client_secret "..."
postctl --profile work tui

postctl --profile privat config set twitter.client_id "..."
postctl --profile privat tui

Ein Profil wird automatisch beim ersten Gebrauch seines Namens angelegt — es gibt keinen separaten “Erstellen”-Schritt. Jedes Profil bekommt seine eigene Konfigurationsdatei (~/.config/postctl/profiles/<name>/config.yaml) und seine eigene Datenbank (~/.local/share/postctl/profiles/<name>/postctl.db), völlig unabhängig vom Standardprofil und voneinander — keine geteilte Zugangsdatendatei, kein vermischter Beitragsverlauf.

postctl profile list      # alle bisher genutzten Profile anzeigen, inkl. aktivem
postctl profile           # nur das aktuell aktive Profil anzeigen

Ohne --profile wird immer das ursprüngliche Standardprofil genutzt (~/.config/postctl/config.yaml) — bestehende Setups bleiben unberührt. Jedes Profil kann auch unabhängig über sein eigenes data_dir (siehe unten) geräteübergreifend synchronisiert werden — z. B. “work” mit einer geschäftlichen Dropbox, “privat” mit deiner persönlichen iCloud Drive.


Datenverzeichnis geräteübergreifend teilen

Standardmäßig liegt die Datenbank von postctl unter ~/.local/share/postctl/postctl.db, lokal auf diesem Rechner. Um dieselben Daten auf mehreren Geräten zu nutzen, setze data_dir (in ~/.config/postctl/config.yaml) auf einen Ordner, den du bereits selbst synchronisierst — iCloud Drive, Dropbox, Syncthing, etc.:

data_dir: "~/Library/Mobile Documents/com~apple~CloudDocs/postctl"

Sobald gesetzt, wechselt postctl seinen SQLite-Journal-Modus automatisch von WAL auf Rollback-Journal — WAL teilt den Zustand auf mehrere Dateien auf, die ein Sync-Client nicht garantiert atomar zusammen aktualisiert. Dieser Wechsel hält das Verzeichnis auf eine einzige konsistente Datei begrenzt, sobald postctl nicht gerade aktiv schreibt. Eine Sperre auf demselben Rechner verhindert außerdem, dass zwei postctl-Prozesse die Datenbank gleichzeitig öffnen (führe postctl doctor aus, um den aktuellen Modus und Pfad zu sehen). Das schützt nur vor Problemen auf demselben Rechner und veralteten Snapshots, nicht davor, dass zwei Geräte im exakt selben Moment schreiben; eine noch nicht heruntergeladene iCloud-Datei wird explizit gemeldet statt als bloßer Fehler.

(Das ist getrennt von postctl config export/import, was deine Konfiguration und Datenbank in eine einzige verschlüsselte Datei für eine manuelle einmalige Übertragung verpackt — die Einstellung oben ist dafür da, sie fortlaufend synchron zu halten.)


Preise

Der Kern von postctl ist kostenlos und Open Source (MIT) — unbegrenzte Beiträge und Entwürfe für bis zu 2 verbundene Konten. postctl Pro (Abo: €4,90/Monat oder €39/Jahr, jederzeit kündbar) hebt das Konto-Limit auf und finanziert die Entwicklung; abonniere auf Polar.sh und aktiviere mit:

postctl license activate <key>

🎉 Launch-Special: mit Code postctl2026 gibt’s 37 % Rabatt auf das erste Jahr beim Jahresabo — gültig bis 31. Dezember 2026.

So funktioniert das Abo: postctl prüft die Lizenz etwa einmal täglich bei Polar (eine zuvor gültige Lizenz funktioniert offline bis zu 21 Tage weiter). Endet ein Abo, wird nichts gelöscht: Der kostenlose Kern arbeitet mit deinen ersten zwei verbundenen Netzwerken weiter, und Posts für weitere Netzwerke bleiben geplant, bis Pro wieder aktiv ist. Als „Lifetime“ vor dem 18.10.2026 gekaufte Lizenzen bleiben gültig.

Lizenz

Siehe LICENSE.

Installations- & Einrichtungsanleitung

Beginne mit postctl, indem du das Binary kompilierst und deinen lokalen Entwickler-Arbeitsbereich initialisierst.

Systemanforderungen

  • Betriebssystem: macOS, Linux oder Windows (WSL empfohlen).
  • Go-Toolchain: Go 1.22 oder höher auf deinem System installiert.
  • SQLite: Lokale SQLite-Clientbibliothek (der CGO-freie Treiber ist direkt einkompiliert).

Automatische Einrichtung in einem Schritt

Der einfachste Weg, die Anwendung, Datenbanken und Abhängigkeiten zu initialisieren, ist die Ausführung des interaktiven Setup-Skripts:

chmod +x setup.sh
./setup.sh

Das Setup-Skript führt folgende Schritte aus:

  1. Überprüfung deiner lokalen Go-Umgebung.
  2. Herunterladen der benötigten Pakete (einschließlich Bubble Tea, Cobra und SQLite).
  3. Kompilieren des Produktions-Binaries postctl.
  4. Erstellen des Konfigurationsverzeichnisses unter ~/.config/postctl/.
  5. Generieren einer Standard-config.yaml und einer leeren SQLite-Datenbank.

Manuelle Kompilierung

Wenn du die Anwendung lieber manuell kompilieren möchtest, führe folgende Befehle aus:

# Abhängigkeiten herunterladen
go mod download

# Binary mit eingebetteten Assets bauen
go build -o postctl main.go

# Hilfe-Befehl zur Überprüfung ausführen
./postctl --help

Konfigurationsverzeichnis

Die Anwendung erstellt und erwartet Dateien im Benutzerkonfigurationsverzeichnis: ~/.config/postctl/

  • config.yaml: Enthält alle deine globalen Standardeinstellungen, KI-Konfigurationen und Plattform-API-Zugangsdaten.
  • postctl.db: Die lokale SQLite-Datenbank, die Kampagnen, Post-Status, Verläufe und verschlüsselte Zugangsdaten speichert.

📝 Vim / Neovim externer Editor-Flow

In der interaktiven Terminal-UI kannst du deinen terminal-nativen Texteditor (wie Neovim oder Vim) starten, um Beitragstext zu bearbeiten, Vorlagen anzupassen oder Metadaten spontan zu ändern.

Wusstest du schon? Änderungen an Metadaten im YAML-Block in Vim werden automatisch mit deinen TUI-Feldern synchronisiert.

Editor aufrufen

  1. TUI-Dashboard starten: ./postctl tui
  2. Zu einem Beitrag oder einer Kampagne navigieren und e drücken, um das Bearbeitungsformular zu öffnen.
  3. Im Fokus auf den Body oder ein beliebiges Textfeld ctrl+v drücken.
  4. Der TUI-Prozess pausiert, und dein Terminal startet deinen konfigurierten Editor (liest $EDITOR, fällt sonst auf nvim oder vim zurück).

Aufbau der Vorlagendatei

Vim öffnet eine temporäre Datei im Markdown-Format. Sie enthält drei getrennte Blöcke:

1. Interaktiver Hilfe-Block (HTML-Kommentare)

<!--
 postctl Editor Help (TWITTER)
 ==================================
 [Zeichen-Lineal (max. 280 Zeichen pro Tweet)]
 000      030      060      090      120      150      180      210      240      270 280!
 |--------|--------|--------|--------|--------|--------|--------|--------|--------|-|

 Aktueller Thread-Status:
   Tweet 1: 220 Zeichen (60 übrig) [✓]
   Tweet 2: 110 Zeichen (170 übrig) [✓]

 HINWEIS: Dieser Hilfe-Block wird beim Speichern automatisch entfernt.
 Schreibe deinen Beitragstext unterhalb dieses Kommentars:
-->

Dieser Block aktualisiert sich dynamisch nach den Limits deiner Zielplattform (280 für Twitter, 300 für Bluesky, 500 für Mastodon). Er wird beim Speichern automatisch entfernt.

2. YAML-Frontmatter-Block

---
platform: twitter
campaign: launch-2026
schedule: 2026-06-25 15:00:00
images: ["logo.png"]
---

Bidirektionale Synchronisierung: Du kannst diese Werte direkt in Vim bearbeiten. Änderungen an Kampagnenname, Zielplattform, Zeitstempel oder Bilderliste aktualisieren beim Zurückkehren sofort die TUI-Formularfelder.

3. Inhaltskörper

Der Beitragstext beginnt nach dem abschließenden ----Frontmatter-Trenner. Schreibe deinen Beitrag in Standard-Markdown.

Für mehrteilige Beiträge trenne jeden Abschnitt mit einer Zeile, die nur --- enthält.

🖼️ Bildpfad-Auflösung & Plattform-Uploads

Verwalte und binde Medien-Assets nahtlos über lokale Dateireferenzen ein.

Wie Bildpfade aufgelöst werden

Wenn du in deinem Beitrags-Frontmatter einen Dateinamen oder Pfad angibst (z. B. images: ["dashboard-screenshot.png"]), führt postctl eine Fallback-Suche in folgender Reihenfolge durch:

  1. Absolutpfad-Prüfung: Ist es ein literaler Pfad auf deinem System? (z. B. /Users/user/Pictures/screenshot.png)
  2. Relativ zum Markdown-Dokument: Wenn du eine Datei aus /code/posts/announcement.md importiert hast, wird /code/posts/dashboard-screenshot.png geprüft.
  3. Aktuelles Arbeitsverzeichnis (CWD): Prüft den Ordner, aus dem heraus du den postctl-Befehl gestartet hast.
  4. Globales Standard-Bildverzeichnis: Prüft das in defaults.image_dir in deiner ~/.config/postctl/config.yaml angegebene Verzeichnis.

Plattformspezifisches Upload-Verhalten

Jede Plattform-API handhabt Medien-Uploads unterschiedlich, was durch die Treiber-Schichten automatisch abstrahiert wird:

  • LinkedIn (natives Hosting): Bilder werden als rohe Binär-Assets zu LinkedIn hochgeladen. Die API registriert den Upload, führt ein PUT aus und verknüpft die resultierende URN (z. B. urn:li:digitalmediaAsset:...) mit deinem Beitrag.
  • Twitter / X (natives Hosting): Bilder werden an den Twitter-v1.1-Media-Upload-Endpunkt hochgeladen, um eine media_id zu erhalten, die dem v2-Tweet angehängt wird.
  • Mastodon / Bluesky (nativ): Mediendateien werden zu Plattform-Attachment-Endpunkten hochgeladen (oder als Blob-Records bei Bluesky AT Protocol) und mit dem Status verknüpft.
  • Threads (Meta-HTTPS-Anforderung): Die Threads-API erlaubt keine binären lokalen Uploads. Stattdessen erwartet sie eine öffentliche HTTPS-URL (z. B. https://yourdomain.com/assets/screenshot.png), von der der Meta-Crawler das Bild abruft. Beim Veröffentlichen auf Threads hoste deine Bilder in einer externen Cloud (S3, R2, Imgur) und referenziere diese HTTPS-URL im Frontmatter.

Twitter/X API Setup Guide für postctl

Dieses Dokument beschreibt Schritt für Schritt, wie du die Authentifizierung für Twitter/X in postctl einrichtest.

Für Twitter/X gibt es einen unterstützten und einen inoffiziellen Weg:

  • Option A: Offizielle API (kostenpflichtig, empfohlen) – Nutzt die offizielle X-API (bezahlter Zugang oder prepaid API-Credits). Der unterstützte, stabile Weg; die Kosten trägt, wer die API nutzt.
  • Option B: Cookie-basiert (inoffiziell, nicht unterstützt, nur auf eigenes Risiko) – Standardmäßig ausgeschaltet. Verstößt gegen die X-Nutzungsbedingungen und kann zur Sperrung deines Kontos führen.

Option A: Offizielle API (Kostenpflichtig & Empfohlen)

Wenn du ein offizielles Entwickler-Konto besitzt und die monatlichen API-Kosten tragen möchtest, kannst du die Standard-OAuth-Authentifizierung nutzen.

[!IMPORTANT] Kostenhinweis (Stand 2026): Twitter/X bietet für neu erstellte Developer-Accounts keinen kostenlosen Schreibzugriff (Free Tier) mehr an. Um über die offizielle Schnittstelle zu posten, ist ein kostenpflichtiger API-Zugang (z. B. Basic Tier für ca. $100/Monat oder prepaid Credits) im Developer Portal erforderlich.

Schritt 1: App im Developer Portal konfigurieren

  1. Gehe auf das Twitter Developer Portal und melde dich an.
  2. Erstelle ein neues Projekt und eine neue App in deinem Portal-Dashboard.
  3. Navigiere in den App Settings zu User authentication settings und klicke auf Set up:
    • App Type: Wähle Web App, Automated App or Bot.
    • App Permissions: Wähle Read and Write (wichtig für Schreibrechte).
    • Type of App: Wähle Native App oder Single Page App (für OAuth 2.0 PKCE).
    • Callback URI / Redirect URL: Trage exakt http://localhost:8753/callback ein.
    • Website URL: Trage deine eigene Website oder https://github.com/aeon022/postctl ein.
  4. Speichere die Einstellungen und kopiere die angezeigte Client ID und das Client Secret an einen sicheren Ort.

Schritt 2: Schlüssel hinterlegen

# Client ID eintragen
./postctl config set twitter.client_id "DEINE_CLIENT_ID"

# Client Secret eintragen
./postctl config set twitter.client_secret "DEIN_CLIENT_SECRET"

Schritt 3: Authentifizierung durchführen

./postctl auth twitter

Es öffnet sich ein Browserfenster, in dem du der App den Zugriff erlaubst. Nach erfolgreichem Login speichert postctl dein verschlüsseltes Access- und Refresh-Token.


Diese Methode simuliert eine echte Browser-Sitzung, indem sie deine Anmelde-Cookies verwendet. Sie erfordert keine Einrichtung im Twitter Developer Portal, ist aber nicht unterstützt und standardmäßig deaktiviert. Aktivierung nur bewusst und auf eigenes Risiko:

postctl config set twitter.unofficial_cookie_mode true

Ohne diese Einstellung verweigert postctl den Cookie-Modus. Bei der ersten Nutzung erscheint eine Warnung im Log. Es gibt keine Garantie und keinen Support für diesen Weg; eine Sperrung deines Kontos geht zu deinen Lasten.

[!WARNING] Inoffizielle Umgehungsmethode (Risiko von Kontosperrung): Die Cookie-basierte Authentifizierung simuliert eine Web-Sitzung. Diese Methode ist fehleranfällig, verstößt gegen die Nutzungsbedingungen (ToS) von X/Twitter und kann zur Sperrung deines Kontos führen. Der einzig offizielle und sichere Weg zum Posten ist die Verwendung der kostenpflichtigen API (Option B).

  • postctl versucht, durch Header-Imitation und künstliche Pausen (5 Sekunden zwischen Beiträgen) das Risiko zu minimieren, bietet aber keine Garantie.
  • X verlangt zwingend auch das twid-Cookie (deine User-ID). Trage daher stets den kompletten Cookie-String oder beide Cookies (auth_token und ct0) ein.

Schritt 1: Komplette Browser-Cookies auslesen

Der einfachste Weg ist, den gesamten Cookie-Header einer beliebigen Anfrage zu kopieren:

  1. Öffne deinen Webbrowser, gehe auf x.com und stelle sicher, dass du eingeloggt bist. (Am besten einmal aus- und wieder einloggen, um die Sitzung frisch zu starten).
  2. Öffne die Entwicklertools (F12 oder Cmd + Option + I).
  3. Wechsle auf den Reiter Network (Netzwerk).
  4. Lade die Seite einmal neu (F5 oder Cmd + R).
  5. Klicke in der Liste der Netzwerkanfragen auf eine beliebige Anfrage zu x.com (z. B. home oder einen GraphQL-Request).
  6. Suche im rechten Bereich unter Request Headers (Anfrage-Header) nach der Zeile cookie:.
  7. Kopiere den gesamten langen Wert (er fängt meist mit guest_id=... oder kdt=... an und enthält alle Cookies).
  8. Suche zusätzlich in den Cookies den Wert für ct0 (dein ca. 160-stelliger CSRF-Token) heraus und kopiere ihn ebenfalls.

Schritt 2: In postctl einrichten

Wir empfehlen die Schnelleinrichtung per Einzeiler im Terminal. Ersetze die Platzhalter durch deine kopierten Werte:

./postctl config setup twitter --cookie "HIER_DER_GESAMTE_KOPIERTE_COOKIE_STRING" --ct0 "HIER_NUR_DER_CT0_WERT"

(Hinweis: Falls du das interaktive Setup über ./postctl config setup twitter startest und Option 2 wählst, kannst du bei der Abfrage nach dem auth_token ebenfalls den gesamten langen Cookie-String einfügen).

🛡️ Schutz vor Kontosperrung

Besonders im Cookie-Modus gilt X als sensibel gegenüber automatisiertem Verhalten. Der Scheduler/Daemon erzwingt deshalb einen Mindestabstand von 60 Minuten zwischen zwei veröffentlichten X-Posts (ein früher fälliger Post wartet automatisch). Mehrere überfällige Posts werden zusätzlich in 20-Minuten-Schritten verteilt, Tweets innerhalb eines Threads mit 3 Sekunden Pause gepostet. Log-Hinweise tragen das Kürzel [SAFETY]. Plane trotzdem nicht mehr als wenige Posts pro Tag.

Sollte beim Veröffentlichen eines Tweets ein GraphQL-Fehler wie empty tweet ID returned... oder der Fehler 226 (This request looks like it might be automated...) auftreten, greift postctl automatisch auf einen Headless-Browser-Fallback zurück:

  1. Automatischer Browser-Start: postctl startet im Hintergrund unsichtbar Google Chrome (mittels chromedp), lädt deine Cookies (auth_token & ct0), navigiert zur Web-Oberfläche von X, befüllt den Composer (inklusive Threads und Medien-Uploads) und klickt auf “Posten”.
  2. Voraussetzung: Google Chrome muss auf deinem System installiert sein (wird auf macOS standardmäßig in /Applications gesucht).
  3. Abgelaufene Session-Cookies: Wenn auch der Headless-Browser scheitert, sind in der Regel deine Cookies abgelaufen. Wiederhole einfach Schritt 1 und trage die neuen Cookies ein.

LinkedIn API Setup Guide für postctl

Dieses Dokument beschreibt Schritt für Schritt, wie du eine LinkedIn-Entwickler-App erstellst, diese auf das moderne OpenID Connect (OIDC) migrierst und deine Zugangsdaten für postctl einrichtest.


1. Entwickler-App auf LinkedIn erstellen

  1. Gehe zum LinkedIn Developer Portal.
  2. Melde dich mit deinem persönlichen LinkedIn-Konto an.
  3. Klicke auf Create App:
    • App Name: Gib deiner App einen Namen (z. B. postctl-publisher).
    • LinkedIn Page: Verknüpfe die App mit deiner LinkedIn-Unternehmensseite oder erstelle eine temporäre Seite (Pflichtfeld).
    • App Logo: Lade das generierte Logo aus postctl/icons (oder ein anderes quadratisches Bild) hoch.
    • Legal Terms: Stimme den Bedingungen zu und klicke auf Create app.

2. Produkte hinzufügen (Sehr wichtig!)

Standardmäßig hat eine neue LinkedIn-App keine Berechtigungen für Beitrags-Postings. Seit August 2023 hat LinkedIn die alten Berechtigungen (r_liteprofile) abgelöst. Du musst die folgenden Produkte aktivieren:

  1. Gehe in deiner App zum Reiter Products.
  2. Suche nach Share on LinkedIn und klicke auf Request access (dies wird in der Regel sofort genehmigt).
  3. Suche nach Sign In with LinkedIn using OpenID Connect (nicht das veraltete Sign In with LinkedIn) und aktiviere dieses, um den modernen OIDC-Login freizuschalten.

3. Authentifizierungs-Einstellungen konfigurieren

  1. Gehe zum Reiter Auth.
  2. Scrolle zum Bereich OAuth 2.0 settings:
    • Füge unter Authorized Redirect URLs folgenden Callback hinzu: http://localhost:8753/callback
    • Klicke auf Update.
  3. Überprüfe im Bereich OAuth 2.0 scopes, ob dir nun folgende Scopes angezeigt werden:
    • openid, profile, w_member_social, email.

4. Client ID & Client Secret kopieren

  1. Bleibe im Reiter Auth.
  2. Kopiere im Bereich Application credentials die Client ID und das Client Secret.

5. In postctl konfigurieren

Nutze das CLI, um die Zugangsdaten in deiner config.yaml zu speichern (nutze das lokale Binary ./postctl):

# Client ID konfigurieren
./postctl config set linkedin.client_id "DEINE_CLIENT_ID"

# Client Secret konfigurieren
./postctl config set linkedin.client_secret "DEIN_CLIENT_SECRET"

Überprüfe die Einstellungen:

./postctl config show

6. Verbindung herstellen (Auth Flow)

Starte den Authentifizierungs-Flow:

./postctl auth linkedin
  1. Es öffnet sich automatisch ein Browserfenster, das dich auffordert, deiner App den Zugriff auf dein LinkedIn-Profil zu erlauben.
  2. Nach Klick auf Zulassen/Allow wirst du zum lokalen Webserver weitergeleitet.
  3. Das CLI fängt das Token über den OIDC-Flow ab, ermittelt die Benutzer-ID (über /v2/userinfo), verschlüsselt das Access Token und speichert es in der lokalen SQLite-Datenbank.
  4. Du bist nun bereit, Beiträge auf LinkedIn zu veröffentlichen!

Threads (Meta) API Setup Guide für postctl

Dieses Dokument beschreibt detailliert, wie du Zugriff auf die offizielle Threads API erhältst, deine App im Meta Developer Portal konfigurierst und die Authentifizierung für postctl einrichtest.


1. Meta Developer App erstellen

  1. Gehe zum Meta Developer Portal und melde dich an.
  2. Klicke oben rechts auf Meine Apps und dann auf App erstellen (Create App):
    • Wähle als App-Typ Anderes (Other) und klicke auf Weiter.
    • Wähle als App-Typ Verbraucher (Consumer) oder einen Typ, der dir Zugriff auf die Threads/Instagram API ermöglicht.
    • Vergib einen Namen für deine Anwendung (z. B. postctl-threads-app).
    • Klicke auf App erstellen (Ggf. musst du dein Facebook-Passwort eingeben).

2. Threads API hinzufügen

  1. Scrolle im Dashboard deiner erstellten App nach unten zu Produkt hinzufügen (Add Products to Your App).
  2. Suche nach Threads API und klicke auf Einrichten (Set Up).
  3. Die App befindet sich nun im Entwicklungsmodus (In Development) – das ist perfekt und wichtig für die lokale Entwicklung.

3. Callback-URLs konfigurieren (Wichtig: Pflichtfelder & HTTPS!)

Meta erzwingt für die Threads API eine sichere HTTPS-Verbindung. Gleichzeitig weigert sich das Dashboard, Änderungen zu speichern, wenn Deinstallations- oder Lösch-URLs fehlen.

  1. Klicke in der linken Navigationsleiste unter Threads API auf Threads-Einstellungen (Threads Settings) oder gehe zu Anwendungsfälle (Use Cases) ➔ Auf Threads API zugreifen ➔ Anpassen / Einstellungen.
  2. Konfiguriere dort die folgenden drei Felder:
    • Callback-URLs umleiten (Valid OAuth Redirect URIs): https://localhost:8753/callback (Wichtig: https statt http!)
    • Callback-URL deinstallieren (Uninstall Callback URL): https://localhost:8753/uninstall (oder https://example.com/uninstall als Platzhalter)
    • Callback-URL löschen (Delete Callback URL): https://localhost:8753/delete (oder https://example.com/delete als Platzhalter)
  3. Klicke unten rechts auf Speichern (Save).

4. App-Rollen & Tester-Einladung einrichten (Wichtig!)

Da sich deine App im Entwicklungsmodus befindet, kann sie nur von Benutzern authentifiziert werden, die explizit als Tester registriert sind.

Schritt A: Tester im Meta-Dashboard hinzufügen

  1. Klicke in der linken Navigationsleiste auf App-Rollen (App Roles / das Personen-Symbol) ➔ Rollen (Roles).
  2. Scrolle ganz nach unten zum Bereich Threads-Tester (Threads Testers).
  3. Klicke auf Threads-Tester hinzufügen (Add Threads Testers).
  4. Gib den exakten Instagram/Threads-Benutzernamen des Kontos ein, mit dem du später posten willst, und klicke auf Bestätigen.

Schritt B: Einladung in Instagram annehmen

Die Einladung muss von deinem Threads-Konto manuell bestätigt werden:

  1. Logge dich im Desktop-Browser auf Instagram.com mit dem entsprechenden Account ein.
  2. Gehe auf dein Profil ➔ Klicke auf das Zahnrad-Symbol (Einstellungen).
  3. Navigiere in der linken Leiste zu Apps und Websites (Apps and Websites).
  4. Klicke oben auf den Reiter Tester-Einladungen (Tester Invites).
  5. Dort wird dir die Einladung deiner App (z. B. postctl-threads-app) angezeigt. Klicke auf Akzeptieren (Accept).

5. App ID & App Secret abrufen

  1. Gehe in der linken Navigationsleiste auf App-Einstellungen (App Settings) ➔ Standard (Basic).
  2. Kopiere die App-ID (App ID).
  3. Kopiere den App-Geheimschlüssel (App Secret / erfordert Klick auf Anzeigen und Passworteingabe).

6. In postctl konfigurieren

Speichere deine Threads-Zugangsdaten über dein Terminal ab (verwende ./postctl für das lokale Binary):

# App-ID eintragen
./postctl config set threads.app_id "DEINE_APP_ID"

# App Secret eintragen
./postctl config set threads.app_secret "DEIN_APP_SECRET"

Überprüfe deine Konfiguration:

./postctl config show

7. Verbindung herstellen (Auth Flow)

Führe den Auth-Flow aus:

./postctl auth threads
  1. Dein Standardbrowser öffnet sich und leitet dich zum Threads-Anmeldedialog weiter.
  2. Melde dich mit deinem Threads-Account an und erlaube den Zugriff auf deine Profildaten und Medien (threads_basic und threads_content_publish).
  3. Nach dem Login leitet dich Meta zurück zu https://localhost:8753/callback.
  4. Umgang mit der SSL-Warnung im Browser:
    • Da der lokale Server von postctl zur Verschlüsselung ein temporäres, selbstsigniertes SSL-Zertifikat nutzt, wird dein Browser die Warnung „Dies ist keine sichere Verbindung“ (oder NET::ERR_CERT_AUTHORITY_INVALID) anzeigen.
    • Das ist völlig normal und sicher, da die Kommunikation nur lokal auf deinem Mac stattfindet.
    • Klicke im Browser-Fenster auf „Erweitert“ (oder Details anzeigen) und wähle „Weiter zu localhost (unsicher)“.
  5. Sobald du darauf klickst, empfängt postctl das Zugriffstoken, tauscht es im Hintergrund in ein 60 Tage gültiges Long-Lived Token um und speichert es verschlüsselt in der SQLite-Datenbank.
  6. Im Terminal erscheint die Erfolgsmeldung! Du bist nun bereit, Beiträge auf Threads zu posten.

🦋 Bluesky- & Mastodon-Einrichtungsanleitung

Erfahre, wie du postctl mit Bluesky (über das AT Protocol) und Mastodon-Instanzen verbindest.

1. Bluesky-Integration

Bluesky nutzt das dezentrale AT Protocol. Statt komplexem OAuth kommen sichere App-Passwörter zum Einsatz.

Schritt A: App-Passwort erstellen

  1. Melde dich in deinem Browser bei deinem Bluesky-Konto an.
  2. Gehe zu Settings ➔ App Passwords.
  3. Klicke auf Create App Password, gib ihm einen Namen (z. B. postctl-cli) und kopiere das generierte Passwort (sieht meist aus wie xxxx-xxxx-xxxx-xxxx).

Schritt B: postctl konfigurieren

Konfiguriere deinen Handle und dein App-Passwort:

# Handle konfigurieren
./postctl config set bluesky.handle "yourname.bsky.social"

# App-Passwort konfigurieren
./postctl config set bluesky.app_password "xxxx-xxxx-xxxx-xxxx"

Schritt C: Authentifizieren

./postctl auth bluesky

Das CLI testet die Verbindung, ruft dein Profil ab und speichert die Sitzungs-Tokens in deiner SQLite-Datenbank. Bluesky-Beiträge unterstützen bis zu 300 Zeichen.


2. Mastodon-Integration

Mastodon unterstützt föderierte Instanzen und registriert Client-Zugangsdaten pro Instanz.

Schritt A: Instanz-URL konfigurieren

Standardmäßig verbindet sich postctl mit https://mastodon.social. Bist du auf einer anderen Instanz, konfiguriere sie zuerst:

./postctl config set mastodon.instance_url "https://fosstodon.org"

Schritt B: Authentifizieren

Starte den Login-Ablauf:

./postctl auth mastodon
  1. Das CLI prüft, ob auf dieser Instanz bereits eine Client-Anwendung existiert. Falls nicht, registriert es automatisch eine neue App namens postctl auf deiner Instanz.
  2. Ein Browserfenster öffnet sich und bittet dich, die App zu autorisieren.
  3. Melde dich an und klicke auf Authorize.
  4. Die Tokens werden abgerufen und sicher in deiner lokalen DB gespeichert. Mastodon-Beiträge unterstützen bis zu 500 Zeichen.

🤖 Facebook-Einrichtungsanleitung

Konfiguriere die automatisierte Veröffentlichung auf Facebook-Business-/Creator-Seiten über Meta-Graph-API-Tokens.

Facebook-Integration

Facebook-Beiträge werden über Meta-Graph-API-Tokens direkt an Business-/Creator-Seiten gesendet.

Schritt A: App im Meta-Portal erstellen

  1. Gehe zum Meta Developer Portal.
  2. Erstelle eine App vom Typ Business oder Other.
  3. Konfiguriere die OAuth-Redirect-URL: http://localhost:8753/callback
  4. Gehe zu Settings ➔ Basic und kopiere deine App ID und dein App Secret.
  5. Ermittle die Ziel-Facebook-Page-ID (zu finden in den "Info"-Angaben deiner Facebook-Seite).

Schritt B: In der Konfiguration speichern

./postctl config set facebook.app_id "YOUR_FB_APP_ID"
./postctl config set facebook.app_secret "YOUR_FB_APP_SECRET"
./postctl config set facebook.page_id "YOUR_FB_PAGE_ID"

Schritt C: Authentifizierung ausführen

./postctl auth facebook

Bestätige die Anfrage, um das Verwalten und Veröffentlichen von Seiten zu autorisieren. Das Zugriffs-Token wird gegen ein dauerhaftes Seiten-Zugriffs-Token eingetauscht und sicher gespeichert.

postctl — Specification

Overview

postctl is a TUI CLI tool written in Go that manages social media postings and blogs across Twitter/X, LinkedIn, Threads, Mastodon, Bluesky, Facebook, Telegram, Discord, Reddit, Dev.to, Hashnode, and Medium. Posts are authored as Markdown files, imported into a local SQLite database, previewed in a terminal UI, and published via platform APIs — immediately or on a schedule.

User Stories

As a solo developer / indie hacker:

  • I want to write posts as Markdown files in my repo
  • I want to preview how a Twitter thread will look before posting
  • I want to schedule posts for optimal timing across time zones
  • I want to post the same content to multiple platforms with one command
  • I want to see what I’ve posted and when
  • I want character count validation before posting (280 chars per tweet)

As an AI assistant (Claude, GPT, Antigravity):

  • I want to create properly formatted Markdown posts
  • I want to trigger posting via CLI commands
  • I want to check post status and history
  • I want to run the full workflow end-to-end without human intervention (except approval)
  • I want structured JSON output for all commands so I can parse results
  • I want dry-run mode to preview everything before committing

AI-as-Operator Principle

postctl is designed for AI to operate, not just to generate text.

Most social media tools treat AI as a copywriting feature — “AI writes your caption.” postctl treats AI as the operator of the entire tool. The human sets the strategy and approves; the AI executes.

Workflow: AI operates, human approves

Human: "Post the new Orbiter v0.3.66 release"
   ↓
AI (Claude/GPT):
   1. Writes posts as Markdown files (all platforms, EN+DE)
   2. postctl import ./posts/
   3. postctl list --format json          → shows draft posts
   4. "Here are 8 posts ready. Review?"
   ↓
Human: "LinkedIn DE ändern, sonst passt"
   ↓
AI:
   5. Edits the file, re-imports
   6. postctl campaign post orbiter-v0366 --dry-run
   7. "Dry run passed. Post?"
   ↓
Human: "go"
   ↓
AI:
   8. postctl campaign post orbiter-v0366
   9. "Posted. 4/4 Twitter, 2/2 LinkedIn, 2/2 Threads. IDs: ..."

Design requirements for AI operation

  1. All commands must work non-interactively — no prompts, no “are you sure?”, no interactive menus. Flags control everything.
  2. --format json on all commands — structured output that AI can parse. Default is human-readable, --format json returns machine-readable.
  3. --dry-run on all mutation commands — AI can preview without side effects. Human approves, then AI runs without --dry-run.
  4. Exit codes — 0 = success, 1 = validation error, 2 = API error, 3 = auth error. AI reads exit codes, not just output.
  5. Idempotent imports — running postctl import twice doesn’t duplicate posts. AI can re-import after edits without cleanup.
  6. Partial failure recovery — if tweet 3/5 fails, postctl post <id> --resume continues from where it stopped. AI doesn’t need to track state manually.
  7. No browser required — OAuth flow uses localhost callback, but once authenticated, everything is CLI-only. AI never needs to open a browser.
  8. Batch operations — postctl campaign post <name> posts all posts in a campaign. AI doesn’t need to loop over individual posts.

JSON output example

$ postctl list --format json
{
  "posts": [
    {
      "id": "orbiter-v0366-twitter-en",
      "platform": "twitter",
      "type": "thread",
      "status": "draft",
      "tweets": 5,
      "images": 2,
      "chars": [245, 220, 180, 260, 190],
      "valid": true
    }
  ],
  "total": 8,
  "by_status": {"draft": 8, "posted": 0, "scheduled": 0}
}
$ postctl post orbiter-v0366-twitter-en --format json
{
  "ok": true,
  "platform": "twitter",
  "tweets_posted": 5,
  "thread_id": "1234567890",
  "urls": ["https://x.com/gerwinweiher/status/1234567890"]
}

Core Workflows

1. Import Workflow

Markdown files → postctl import → SQLite DB
                                    ↓
                              Posts with status "draft"

Input: Directory of .md files with YAML frontmatter Processing:

  • Parse frontmatter (platform, type, language, campaign, schedule, images)
  • Parse body into tweets (split on ## Tweet N headers)
  • Detect reply section (## Reply)
  • Validate: character count per tweet (≤280), image paths exist
  • Generate deterministic ID from filename + platform
  • Insert/update in SQLite (upsert on ID)

Output: Posts in DB with status draft or scheduled (if schedule: frontmatter present)

2. Preview Workflow

postctl (no args) → TUI
                      ↓
              Dashboard → Post list → Detail view
                                        ↓
                                   Tweet-by-tweet preview
                                   with char count + image indicators

3. Post Workflow

postctl post <id> → Load from DB → Validate → API Call → Update status
                                                ↓
                                          Upload images first
                                          Then post text with media IDs
                                          For threads: post sequentially,
                                          reply to previous tweet ID

Thread posting sequence:

  1. Upload all images → get media IDs
  2. Post Tweet 1 → get tweet ID
  3. Post Tweet 2 as reply to Tweet 1 → get tweet ID
  4. Post Tweet 3 as reply to Tweet 2 → …
  5. Post Reply tweet as reply to last tweet
  6. Update DB: status = “posted”, platform_id = first tweet ID

Error handling:

  • If tweet N fails: mark post as “partial”, store last successful tweet ID
  • Retry: resume from the failed tweet (don’t re-post successful ones)
  • Rate limit hit: wait and retry with exponential backoff

4. Schedule Workflow

postctl schedule <id> "2026-06-23 09:00"
       ↓
  Update DB: status = "scheduled", scheduled_at = datetime
       ↓
  Scheduler daemon picks it up at the right time
       ↓
  Same as Post Workflow

Scheduler:

  • Runs as background goroutine when TUI is open
  • Also runs as postctl daemon for headless mode
  • Checks every 30 seconds for due posts
  • Posts in order of scheduled_at

5. Auth Workflow

postctl auth twitter
       ↓
  Open browser → Twitter OAuth consent page
       ↓
  Local HTTP server on :8753 catches callback
       ↓
  Exchange code for token
       ↓
  Store encrypted token in SQLite

Markdown Format Spec

Frontmatter Fields

Field Required Type Values Default
platform yes string twitter, linkedin, threads, all —
type yes string thread, single, article —
language no string ISO 639-1 (en, de) en
campaign no string freeform slug —
schedule no datetime ISO 8601 local —
images no list relative file paths —
tags no list hashtags without # —

Body Format

Single post (LinkedIn, Threads):

---
platform: linkedin
type: single
---

The entire post body goes here.
Multiple paragraphs supported.

Thread (Twitter):

---
platform: twitter
type: thread
images:
  - screenshots/01-dashboard.png
---

## Tweet 1

First tweet content. No links here for algorithmic reach.

## Tweet 2

Second tweet. Attach image: screenshots/01-dashboard.png

## Tweet 3

Third tweet content.

## Reply

Links and hashtags go in the self-reply.
github.com/aeon022/orbiter

#opensource #webdev

Rules:

  • ## Tweet N splits into individual tweets
  • ## Reply is posted as a reply to the last tweet
  • Image assignment: first image in images: list goes to Tweet 2, second to Tweet 3, etc. Or use <!-- image: filename.png --> inline
  • Character count: ≤280 per tweet (URLs count as 23 chars per Twitter’s t.co)
  • Empty tweets are skipped

Platform API Details

Twitter/X v2

Auth: OAuth 2.0 with PKCE

GET https://twitter.com/i/oauth2/authorize
  ?client_id=...
  &redirect_uri=http://localhost:8753/callback
  &scope=tweet.read+tweet.write+users.read+offline.access
  &response_type=code
  &code_challenge=...
  &code_challenge_method=S256
  &state=...

Post tweet:

POST https://api.twitter.com/2/tweets
Authorization: Bearer <token>
Content-Type: application/json

{"text": "...", "media": {"media_ids": ["..."]}, "reply": {"in_reply_to_tweet_id": "..."}}

Upload media (v1.1 — still required):

POST https://upload.twitter.com/1.1/media/upload.json
Content-Type: multipart/form-data

media_data=<base64>

LinkedIn v2

Post:

POST https://api.linkedin.com/v2/ugcPosts
Authorization: Bearer <token>

{
  "author": "urn:li:person:<id>",
  "lifecycleState": "PUBLISHED",
  "specificContent": {
    "com.linkedin.ugc.ShareContent": {
      "shareCommentary": {"text": "..."},
      "shareMediaCategory": "IMAGE",
      "media": [{"status": "READY", "media": "<asset-urn>"}]
    }
  },
  "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}
}

Image upload (2-step):

  1. Register: POST /v2/assets?action=registerUpload → get upload URL + asset URN
  2. Upload: PUT <upload-url> with binary image data

Threads (Meta Graph API)

Create container:

POST https://graph.threads.net/v1.0/<user_id>/threads
  ?media_type=TEXT
  &text=...
  &access_token=...

Publish:

POST https://graph.threads.net/v1.0/<user_id>/threads_publish
  ?creation_id=<container_id>
  &access_token=...

Error Handling Strategy

Error Action
Rate limit (429) Wait retry-after header, then retry
Auth expired (401) Attempt token refresh, if fails prompt re-auth
Network error Retry 3x with exponential backoff (1s, 4s, 16s)
Partial thread Mark as “partial”, store progress, allow resume
Invalid content Validation error before API call, show in TUI
Image too large Resize with Go image library before upload

Non-Goals (v1)

  • No web dashboard
  • No multi-user / team features
  • No built-in image generation
  • No automatic cross-posting (explicit per platform)

Success Metrics

  • Import 20 Markdown posts in <1 second
  • Post a 6-tweet thread with 2 images in <10 seconds
  • TUI renders at 60fps on standard terminal
  • Single binary, <20MB, no runtime dependencies
  • Works on macOS, Linux, Windows

Future Features (v2+)

postctl generate

AI generiert Posts aus einer URL oder einem Markdown-Artikel.

  • Input: URL, Markdown-Datei, oder freier Text
  • Output: Thread-Draft + LinkedIn-Post + Threads-Post als Markdown
  • Nutzt Claude API, OpenAI API, oder Ollama (lokal)
  • User reviewt und editiert vor dem Posten

postctl repurpose

Nimmt einen bestehenden Post und konvertiert ihn für andere Plattformen.

  • Dev.to Artikel → Twitter Thread + LinkedIn + Threads
  • Twitter Thread → LinkedIn Langpost
  • Passt Länge, Ton, Hashtags automatisch an

postctl analytics

Engagement-Daten von den APIs nach dem Posten holen.

  • Likes, Retweets, Impressions (Twitter)
  • Reactions, Comments (LinkedIn)
  • Beste Posting-Zeiten ermitteln
  • Terminal-Dashboard mit Sparklines

postctl template

Vorgefertigte Post-Strukturen.

  • postctl template launch — Product Launch Announcement
  • postctl template feature — Feature Update Thread
  • postctl template thought — Thought Leadership Post
  • Generiert Markdown-Datei mit Platzhaltern

Monetarisierung

postctl (Open Source, gratis)

Das CLI-Tool ist MIT-lizenziert. Baut Reputation und Community.

postctl pro ($9/Monat oder $79/Jahr)

Premium-Features als bezahlte Erweiterung:

  • Analytics-Dashboard — Engagement pro Post, beste Posting-Zeiten, Wachstum über Zeit
  • AI-Optimierung — Claude/GPT rewritet Posts für maximale Reichweite (A/B-Vorschläge)
  • Team-Mode — mehrere Leute posten unter einem Account, Approval-Workflow
  • Priority Support — GitHub Issues mit SLA
  • Implementierung: License Key in ~/.config/postctl/config.yaml, Feature-Gates im Code

postctl cloud (Future — $19/Monat)

Gehostete Version für Nicht-Entwickler.

  • Web-UI statt Terminal
  • Managed Scheduling (kein eigener Rechner muss laufen)
  • Status: niedrige Priorität, nur wenn Nachfrage da ist

Go Tutorial als Kurs ($49-79 einmalig)

Das Tutorial das wir parallel schreiben wird zum bezahlten Produkt:

  • “Build a real CLI tool in Go” — Video-Serie
  • postctl als Projekt-Basis, 7 Phasen
  • Plattform: eigene Seite, Udemy, oder Lemon Squeezy
  • Gratis-Teaser: erste 2 Phasen als Blog-Posts auf Dev.to

Ökosystem-Vision

postctl ist Teil eines Content-Loops:

Orbiter (Create) → postctl (Distribute) → Analytics (Learn) → Orbiter (Improve)
  1. Content in Orbiter schreiben (Blog Posts, Pages)
  2. Posts als Markdown exportieren / generieren
  3. postctl verteilt auf Twitter, LinkedIn, Threads
  4. Analytics zeigt was funktioniert
  5. Insights fließen in nächsten Content-Zyklus

Langfristig: orbiter export --to-postctl als Integration.


Projekt-Setup

Repository: ~/Developing/Projects/postctl
Module:     github.com/aeon022/postctl
License:    MIT

Go Tutorial — postctl

Dieses Tutorial führt dich durch Go anhand des postctl-Projekts. Jede Phase baut ein Feature und lehrt neue Go-Konzepte.

Kurs-Konzept

Dieses Tutorial wird parallel zum Projekt geschrieben und kann als bezahlter Kurs veröffentlicht werden:

  • Format: Video-Serie (Screen Recording + Voiceover) + geschriebenes Tutorial
  • Titel: “Build a real CLI tool in Go — from zero to production”
  • Zielgruppe: Entwickler die Go lernen wollen anhand eines echten Projekts
  • Teaser: Phase 1 + 2 gratis als Blog-Posts auf Dev.to (Traffic + Leads)
  • Vollversion: Phase 3-7 als bezahlter Kurs ($49-79)
  • Plattform: eigene Seite (Lemon Squeezy) oder Udemy
  • Sprachen: Deutsch (primär) + Englisch

Jede Phase endet mit einer Challenge — einer Aufgabe die der Lerner selbst löst bevor er die Lösung sieht.


Voraussetzungen

# Go installieren
brew install go

# Version prüfen (1.22+)
go version

# Editor: VS Code + Go Extension (gopls)
code --install-extension golang.go

Phase 1: Hello Go — Projekt Setup

Was wir bauen

Grundgerüst mit Cobra CLI: postctl version, postctl help

Go-Konzepte

  • Go Modules: Dependency Management (go.mod)
  • Packages: Code-Organisation in Ordnern
  • func main(): Entry Point
  • fmt.Println: Output
  • Strings: String Formatting mit fmt.Sprintf

Schritte

# 1. Projekt erstellen
mkdir postctl && cd postctl
go mod init github.com/aeon022/postctl

# 2. Cobra installieren
go get github.com/spf13/cobra@latest

# 3. Datei erstellen
mkdir -p cmd

main.go — Entry Point:

package main

import "github.com/aeon022/postctl/cmd"

func main() {
    cmd.Execute()
}

cmd/root.go — Root Command:

package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

// Version — wird beim Build gesetzt
var Version = "dev"

var rootCmd = &cobra.Command{
    Use:   "postctl",
    Short: "Social media posting from the terminal",
    Long:  "postctl manages social media posts from Markdown files.\nTwitter/X, LinkedIn, and Threads — from one CLI.",
}

// Execute — wird von main() aufgerufen
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    // Version subcommand
    rootCmd.AddCommand(&cobra.Command{
        Use:   "version",
        Short: "Print version",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("postctl %s\n", Version)
        },
    })
}
# Testen
go run . version
# → postctl dev

# Binary bauen
go build -o postctl .
./postctl version

🎓 Gelernt

  • go mod init erstellt ein Modul
  • go get installiert Dependencies
  • package main + func main() = Programm-Start
  • Cobra strukturiert CLI Commands als Baumstruktur
  • &cobra.Command{} = Pointer zu einem Struct

Phase 2: Structs & Models — Datenstruktur

Was wir bauen

Post-Datenmodell: Post, Tweet, Campaign Structs

Go-Konzepte

  • Structs: Custom Types
  • Felder + Tags: JSON/YAML Tags für Serialisierung
  • Methoden: Funktionen auf Structs
  • Slices: Dynamische Arrays
  • Enums via Konstanten: const StatusDraft = "draft"

Code

internal/models/post.go:

package models

import "time"

// Status-Konstanten
const (
    StatusDraft     = "draft"
    StatusScheduled = "scheduled"
    StatusPosted    = "posted"
    StatusFailed    = "failed"
    StatusPartial   = "partial"
)

// Platform-Konstanten
const (
    PlatformTwitter  = "twitter"
    PlatformLinkedIn = "linkedin"
    PlatformThreads  = "threads"
)

// Post repräsentiert einen Social-Media-Post
type Post struct {
    ID          string    `json:"id" yaml:"id"`
    Platform    string    `json:"platform" yaml:"platform"`
    Type        string    `json:"type" yaml:"type"`           // thread, single, article
    Language    string    `json:"language" yaml:"language"`
    Campaign    string    `json:"campaign" yaml:"campaign"`
    Title       string    `json:"title" yaml:"title"`
    Tweets      []Tweet   `json:"tweets" yaml:"tweets"`       // Für Threads
    Body        string    `json:"body" yaml:"body"`            // Für Singles
    Images      []string  `json:"images" yaml:"images"`
    Tags        []string  `json:"tags" yaml:"tags"`
    Status      string    `json:"status" yaml:"status"`
    ScheduledAt *time.Time `json:"scheduled_at" yaml:"schedule"`
    PostedAt    *time.Time `json:"posted_at" yaml:"posted_at"`
    PlatformID  string    `json:"platform_id" yaml:"platform_id"`
    Error       string    `json:"error" yaml:"error"`
    SourceFile  string    `json:"source_file" yaml:"source_file"`
    CreatedAt   time.Time `json:"created_at" yaml:"created_at"`
    UpdatedAt   time.Time `json:"updated_at" yaml:"updated_at"`
}

// Tweet ist ein einzelner Tweet in einem Thread
type Tweet struct {
    Index   int    `json:"index"`
    Content string `json:"content"`
    Image   string `json:"image"`   // Optionaler Bild-Pfad
    IsReply bool   `json:"is_reply"` // Letzter Tweet = Reply mit Links
}

// CharCount gibt die Zeichenanzahl zurück (URLs = 23 Zeichen)
func (t Tweet) CharCount() int {
    // Vereinfacht — Twitter zählt URLs als 23 Zeichen
    return len([]rune(t.Content))
}

// IsValid prüft ob der Tweet innerhalb des Limits ist
func (t Tweet) IsValid() bool {
    return t.CharCount() <= 280
}

// Campaign gruppiert Posts
type Campaign struct {
    Slug     string
    Posts    []Post
    Posted   int
    Drafts   int
    Scheduled int
}
# Kompiliert? 
go build ./internal/models/

🎓 Gelernt

  • Structs sind Go’s “Klassen” (ohne Vererbung)
  • json:"name" Tags steuern JSON-Serialisierung
  • *time.Time = Pointer, kann nil sein (= optional)
  • []Tweet = Slice von Tweets (dynamisches Array)
  • Methoden: func (t Tweet) CharCount() int — Methode auf Tweet
  • []rune(s) konvertiert String zu Unicode-Zeichen (für korrekte Länge)

Phase 3: Markdown Parser — Dateien einlesen

Was wir bauen

postctl import ./posts/ — liest Markdown-Dateien und erstellt Posts

Go-Konzepte

  • File I/O: os.ReadFile, filepath.Walk
  • String-Manipulation: strings.Split, strings.TrimSpace
  • Regular Expressions: regexp.MustCompile
  • Error Handling: if err != nil { return err }
  • YAML Parsing: External Package
  • Unit Tests: func TestParseTweets(t *testing.T)

🎓 Gelernt

  • os.ReadFile liest eine ganze Datei
  • filepath.Walk iteriert über Verzeichnisse
  • strings.SplitN(s, "---", 3) splittet Frontmatter
  • regexp.MustCompile für Pattern Matching
  • t.Run("name", func(t *testing.T) { ... }) für Sub-Tests
  • Error Handling ist explizit — kein try/catch

Phase 4: SQLite Store — Daten speichern

Go-Konzepte

  • Interfaces: Store Interface definieren
  • SQL: database/sql Standard-Paket
  • Prepared Statements: SQL Injection verhindern
  • Migrations: Schema-Versionierung
  • defer: Resource Cleanup (defer db.Close())

🎓 Gelernt

  • interface{} definiert Verhalten, nicht Struktur
  • defer führt Code am Funktions-Ende aus (wie finally)
  • db.QueryRow().Scan(&var) liest Werte direkt in Variablen
  • _ ignoriert Return-Werte die du nicht brauchst

Phase 5: TUI — Terminal Interface

Go-Konzepte

  • Bubbletea Elm-Architektur: Model → Update → View
  • tea.Msg: Message-basierte Kommunikation
  • tea.Cmd: Async Side-Effects
  • Lipgloss: Styling mit Methoden-Chaining
  • Composition: Kleine Komponenten zusammenbauen

Bubbletea Grundprinzip

// Model hält den State
type model struct {
    posts    []models.Post
    cursor   int
    selected string
}

// Init — wird einmal aufgerufen
func (m model) Init() tea.Cmd {
    return nil // Keine initiale Aktion
}

// Update — reagiert auf Events
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 { m.cursor-- }
        case "down", "j":
            if m.cursor < len(m.posts)-1 { m.cursor++ }
        }
    }
    return m, nil
}

// View — rendert den Screen (wird bei jedem Update aufgerufen)
func (m model) View() string {
    s := "Posts:\n\n"
    for i, p := range m.posts {
        cursor := " "
        if i == m.cursor { cursor = ">" }
        s += fmt.Sprintf("%s %s [%s]\n", cursor, p.Title, p.Status)
    }
    s += "\n↑↓ navigate · q quit"
    return s
}

🎓 Gelernt

  • Elm-Architektur: unidirektionaler Datenfluss
  • switch msg := msg.(type) = Type Switch (Go’s Pattern Matching)
  • tea.Cmd für async Operationen (API Calls etc.)
  • Lipgloss: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#8b7cf8"))

Phase 6: APIs — Twitter, LinkedIn, Threads

Go-Konzepte

  • HTTP Client: http.NewRequest, http.Client.Do
  • OAuth 2.0: Authorization Code Flow mit PKCE
  • Goroutines: go func() { ... }()
  • Channels: ch := make(chan Result)
  • Context: ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
  • encoding/json: Request/Response Bodies
  • Interfaces: Platform Interface für alle Plattformen

Platform Interface

type Platform interface {
    Name() string
    Auth(ctx context.Context) error
    Post(ctx context.Context, post *models.Post) (string, error)  // returns platform ID
    UploadImage(ctx context.Context, path string) (string, error) // returns media ID
    IsAuthenticated() bool
}

🎓 Gelernt

  • Interfaces in Go sind implizit — kein implements
  • Goroutines sind leichtgewichtig (~8KB Stack)
  • Channels synchronisieren Goroutines
  • context.Context für Timeouts und Cancellation
  • json.NewDecoder(resp.Body).Decode(&result) parsed HTTP Response

Phase 7: Scheduler & Polish

Go-Konzepte

  • Ticker: time.NewTicker(30 * time.Second)
  • Select: select { case <-ticker.C: ... case <-quit: return }
  • Graceful Shutdown: signal.Notify(quit, os.Interrupt)
  • Build Tags: go build -ldflags "-X cmd.Version=1.0.0"
  • Cross-Compilation: GOOS=linux GOARCH=amd64 go build

🎓 Gelernt

  • select wartet auf mehrere Channels gleichzeitig
  • signal.Notify fängt Ctrl+C ab
  • Go cross-compiled ohne Extra-Tools
  • Ein Binary für alles — keine Runtime-Dependencies

Zusammenfassung: Go vs. JavaScript/TypeScript

Konzept JavaScript Go
Package Manager npm go mod (built-in)
Types TypeScript (optional) Statisch (required)
Error Handling try/catch if err != nil
Async Promise/async-await Goroutines + Channels
Classes class + prototype Structs + Methoden
Interfaces TypeScript interface Implizit (duck typing)
Null null/undefined nil (nur für Pointer, Slices, Maps)
Build webpack/esbuild go build (built-in)
Output node_modules + runtime Single Binary

Ressourcen