postctl
Terminal social media and blogging manager. Write posts in Markdown, schedule them, and publish to Twitter/X, LinkedIn, Threads, Mastodon, Bluesky, Facebook, Telegram, Discord, Reddit, Dev.to, Hashnode, and Medium from the command line or a full TUI.
Supported platforms: Twitter/X · LinkedIn · Threads · Mastodon · Bluesky · Facebook · Telegram · Discord · Reddit · Dev.to · Hashnode · Medium
Quick Start
-
Install
git clone https://github.com/aeon022/postctl && cd postctl ./setup.sh -
Authenticate with a platform
postctl auth --platform twitter -
Write a post — create a Markdown file (see Post Format):
--- platform: twitter title: My first post --- Hello from postctl. -
Import the file
postctl import my-post.md -
Publish immediately or schedule
postctl post <ID> postctl schedule <ID> --time 2026-07-10T09:00:00+02:00 -
Open the TUI to manage everything
postctl tui
Cheatsheet
postctl Open TUI (default)
postctl tui Open TUI explicitly
postctl auth --platform PLATFORM Authenticate with a platform
postctl config [--show] [--set K V] View or set config values
postctl config test Test connection to configured platform APIs
postctl rss add URL Add an RSS feed URL
postctl rss list List all configured RSS feeds
postctl rss remove URL Remove a configured RSS feed
postctl rss import Fetch feeds and import articles as drafts
postctl import FILE_OR_DIR Import Markdown post(s)
postctl list [--platform P] [--status S] [--campaign C] [--format human|json]
postctl template --platform PLATFORM Generate a post template
postctl post ID [--dry-run] Publish a post immediately (alias: publish)
postctl publish ID [--dry-run] Publish a post immediately
postctl schedule ID [--time DATETIME] [--queue] Schedule a post (RFC3339) or to the queue
postctl cancel ID Cancel a scheduled post
postctl delete ID Delete a post locally and remotely
postctl campaign list List all campaigns
postctl campaign post NAME [--dry-run] Publish all posts in a campaign
postctl generate URL AI-generate a post from a URL
postctl repurpose ID --platform TARGET [--tone TONE] Repurpose a post with custom tone
postctl git-hook install [--dir DIR] Install a post-commit git hook
postctl git-hook uninstall Uninstall the git hook
postctl analytics [--platform P] [--format human|json]
postctl daemon [--dry-run] Run the background scheduler
postctl mcp Start the MCP server (stdio)
postctl version Print version
Post Markdown Format
Frontmatter Fields
| Field | Required | Values / Format | Description |
|---|---|---|---|
platform |
Yes | twitter, linkedin, threads, mastodon, bluesky |
Target platform |
title |
No | String | Internal label (not published) |
campaign |
No | String slug | Groups posts into a campaign |
schedule |
No | RFC3339 or "queue" |
Scheduled publish time or Smart Queue |
Body Format
Write the post body in plain Markdown below the closing --- of the frontmatter block.
- LinkedIn, Threads, Mastodon, Bluesky: Single body. No separators.
- Twitter/X threads: Separate individual tweets with a line containing only
---. Each segment becomes one tweet in the thread.
Twitter Thread Example
---
platform: twitter
title: Launch announcement
campaign: product-launch
schedule: 2026-07-10T09:00:00+02:00
---
This is the first tweet. Max 280 characters for Twitter/X.
---
Second tweet in the thread.
---
Third tweet. Threads are Twitter-only.
CLI Reference
Authentication and Configuration
| Command | Description |
|---|---|
postctl auth --platform PLATFORM |
Authenticate with the given platform (OAuth flow) |
postctl config --show |
Print the current configuration |
postctl config --set KEY VALUE |
Set a configuration value |
postctl config test |
Run connectivity diagnostics for all configured platform APIs |
RSS Feed Importer
| Command | Description |
|---|---|
postctl rss add URL |
Add a new RSS feed URL to configuration |
postctl rss list |
List all configured RSS feeds |
postctl rss remove URL |
Remove a configured RSS feed |
postctl rss import |
Fetch RSS feeds and import new articles as drafts |
Content Management
| Command | Description |
|---|---|
postctl import FILE_OR_DIR |
Import one Markdown file or a directory of files |
postctl list |
List posts; filter with --platform, --status, --campaign; format with --format human|json |
postctl template --platform PLATFORM |
Print a Markdown template for the given platform |
postctl generate URL |
AI-generate a draft post from the article at URL |
postctl repurpose ID --platform TARGET [--tone TONE] |
Repurpose an existing post with an optional custom tone |
Publishing
| Command | Description |
|---|---|
postctl post ID |
Publish post immediately (alias: publish) |
postctl publish ID |
Publish post immediately |
postctl post ID --dry-run |
Simulate publishing without sending |
postctl schedule ID --time DATETIME |
Set or update the scheduled publish time |
postctl schedule ID --queue |
Schedule a post to the next available queue slot |
postctl cancel ID |
Cancel a scheduled post (resets status to draft) |
postctl delete ID |
Delete a post from the local database (and remote platform if published) |
postctl campaign list |
List all campaigns with post counts |
postctl campaign post NAME |
Publish all posts in a campaign |
postctl campaign post NAME --dry-run |
Dry-run campaign publish |
postctl git-hook install [--dir DIR] |
Install local git post-commit hook for auto-import |
postctl git-hook uninstall |
Remove local git post-commit hook |
postctl daemon |
Start the background scheduler daemon |
postctl daemon --dry-run |
Run daemon in dry-run mode |
Analytics
| Command | Description |
|---|---|
postctl analytics |
Show analytics across all platforms |
postctl analytics --platform PLATFORM |
Filter to one platform |
postctl analytics --format json |
Output as JSON |
MCP Server
| Command | Description |
|---|---|
postctl mcp |
Start the MCP server on stdio for use by AI agents |
TUI Guide
Launch with postctl or postctl tui.
Views
| View | Description |
|---|---|
| Posts list | Main view; shows all posts with status badges (draft / scheduled / posted / failed) |
| Detail | Full post content and metadata |
| Editor | Write or edit post body and frontmatter fields |
| Schedule | Set or adjust the scheduled time for a post |
| Analytics | Platform-level metrics overview |
| History | Log of past publish events |
| Settings | App configuration |
| Readme | In-app documentation |
Switch between views using tabs or the keybindings below.
Keybindings
Posts List
| Key | Action |
|---|---|
j / k |
Navigate up and down |
Space |
Toggle post selection for mass/bulk actions |
Enter |
Open detail view |
n |
New post |
e |
Edit selected post |
d |
Delete selected post(s) locally and remotely (if published) |
p |
Publish selected post(s) immediately |
s |
Schedule selected (or highlighted) post(s) to queue slots |
Esc |
Clear bulk selections (or clear campaign filter) |
Tab |
Switch tabs |
q |
Quit |
Detail View
| Key | Action |
|---|---|
Esc |
Back to list |
e |
Edit post |
p |
Publish post immediately |
d |
Delete post locally and remotely |
r |
Repurpose post |
Editor
| Key | Action |
|---|---|
Ctrl+S |
Save |
Esc |
Cancel |
Tab |
Move between fields |
MCP — AI Integration
postctl ships a built-in MCP server that exposes all core operations to AI agents. This lets tools like Claude Desktop create, schedule, and publish posts on your behalf.
Claude Desktop Configuration
Add the following to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"postctl": {
"command": "postctl",
"args": ["mcp"]
}
}
}
Restart Claude Desktop after saving. The postctl binary must be on your PATH.
MCP Tools
| Tool | Parameters | Description |
|---|---|---|
list_posts |
platform, status, campaign (all optional) |
List posts with optional filters |
get_post |
id |
Retrieve full post content and metadata by ID |
create_post |
platform, body, title, campaign, schedule |
Create a draft or scheduled post |
publish_post |
id, dry_run |
Publish a post immediately |
schedule_post |
id, schedule (RFC3339) |
Set or update the scheduled publish time |
list_campaigns |
— | List all campaigns with total count and per-status breakdown |
get_campaign |
name, status (optional filter) |
Get all posts in a campaign with full content |
For Twitter threads, separate tweets with \n---\n in the body field when calling create_post.
Schedule values must be RFC3339, e.g. 2026-07-10T09:00:00+02:00.
AI Workflow Examples
Plan a campaign from an article
“Read the article at https://example.com/blog/launch, then create a five-post campaign called
launch-weekwith one post per day starting Monday. Use twitter for three posts and linkedin for two.”
Claude calls create_post for each post with appropriate bodies, the campaign name, and staggered schedule values derived from the article content.
Review and publish scheduled posts
“Show me everything scheduled for this week and publish any posts that look ready.”
Claude calls list_posts with status: scheduled, presents the results for your review, then calls publish_post for each approved post — or all of them at once if you confirm.
Repurpose a blog post across platforms
“Take post abc123 and create adapted versions for LinkedIn and Threads.”
Claude calls get_post to retrieve the original, then calls create_post twice — once for linkedin and once for threads — adapting tone and length for each platform automatically.
Platform Notes
| Platform | Character Limit | Threads | Images |
|---|---|---|---|
| Twitter/X | 280 per tweet | Yes, separate with --- |
Supported |
| ~3,000 recommended | No | Supported | |
| Threads | 500 | No | At least one recommended (Meta requirement) |
| Mastodon | 500 (instance default) | No | Supported |
| Bluesky | 300 | No | Supported |
| ~63,206 | No | Supported | |
| Telegram | 4,096 (1,024 for captions) | No | Supported |
| Discord | 2,000 | No | Supported |
| 40,000 | No | Not Supported | |
| Dev.to | ~100,000 | No | Not Supported |
| Hashnode | ~100,000 | No | Not Supported |
| Medium | ~100,000 | No | Not Supported |
Twitter threads have no hard post count limit, but keep threads focused. Other platforms do not support thread-style multi-part posts — use a single body for those platforms.
[!WARNING] API Rate Limits & Bulk Publishing: Publishing multiple posts simultaneously or in quick succession can lead to API rate limits or permanent account bans (especially on federated networks like Mastodon). Always space out posts over time (e.g., at least 15-30 minutes delay between consecutive publishing events).
Architecture
Markdown files
|
postctl import
|
v
SQLite (~/.local/share/postctl/postctl.db)
|
+---> TUI (Bubbletea) ---> Platform APIs (Twitter, LinkedIn, Threads, Mastodon, Bluesky)
|
+---> MCP server (stdio) ---> AI agents (Claude Desktop, etc.)
|
+---> postctl daemon ---> scheduled publish via platform APIs
Requirements: macOS or Linux · Go 1.21+ · API credentials for each platform you use
Profiles — separate accounts for Work / Private / per-project
By default postctl uses a single config and database. To keep entirely separate sets of credentials, posts, and scheduling — e.g. a private and a work Twitter account, or one set per client project — pass --profile <name> (or set POSTCTL_PROFILE) on any command:
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
A profile is created automatically the first time you use its name — there’s no separate “create” step. Each profile gets its own config file (~/.config/postctl/profiles/<name>/config.yaml) and its own database (~/.local/share/postctl/profiles/<name>/postctl.db), completely independent of the default profile and of each other — no shared credentials file, no mixed post history.
postctl profile list # see every profile that's been used, and which is active
postctl profile # show just the currently active profile
Running with no --profile always uses the original default (~/.config/postctl/config.yaml) — existing setups are unaffected. Each profile can also be synced across devices independently via its own data_dir (see below) — e.g. sync “work” to a work Dropbox and “privat” to your personal iCloud Drive.
Sharing your data directory across devices
By default postctl’s database lives at ~/.local/share/postctl/postctl.db, local to this machine. To share it across devices, set data_dir (in ~/.config/postctl/config.yaml) to a folder you already sync yourself — iCloud Drive, Dropbox, Syncthing, etc:
data_dir: "~/Library/Mobile Documents/com~apple~CloudDocs/postctl"
Once set, postctl automatically switches its SQLite journal mode from WAL to rollback-journal — WAL splits the database across multiple files that a folder-sync client can’t update atomically together, so this switch keeps the directory down to a single consistent file whenever postctl isn’t actively writing. A same-machine lock also prevents two postctl processes from opening the database at once (run postctl doctor to see the current mode and path). This only protects against the same-machine and stale-snapshot failure modes, not two machines editing at the exact same instant; an undownloaded iCloud file is reported explicitly rather than as a bare error.
(This is separate from postctl config export/import, which packages your config and database into one encrypted file for a manual one-time transfer — the setting above is for keeping them continuously in sync instead.)
Pricing
The core of postctl is free and open-source (MIT) — unlimited posts and drafts on up to 2 connected accounts. A Pro lifetime license removes the account limit and supports development; buy it on Polar.sh and activate with:
postctl license activate <key>
🎉 Launch special: use code POSTCTL2026 for 37% off — through October 31, 2026.
License
See LICENSE.
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
-
Installieren
git clone https://github.com/aeon022/postctl && cd postctl ./setup.sh -
Bei einer Plattform anmelden
postctl auth --platform twitter -
Beitrag schreiben — erstelle eine Markdown-Datei (siehe Beitragsformat):
--- platform: twitter title: Mein erster Beitrag --- Hallo von postctl. -
Datei importieren
postctl import my-post.md -
Sofort veröffentlichen oder einplanen
postctl post <ID> postctl schedule <ID> --time 2026-07-10T09:00:00+02:00 -
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-weekmit 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 |
| ~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 |
| ~63.206 | Nein | Unterstützt | |
| Telegram | 4.096 (1.024 für Bildunterschriften) | Nein | Unterstützt |
| Discord | 2.000 | Nein | Unterstützt |
| 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. Eine Pro-Lifetime-Lizenz hebt das Konto-Limit auf und unterstützt die Weiterentwicklung; kaufen auf Polar.sh und aktivieren mit:
postctl license activate <key>
🎉 Launch-Special: mit Code POSTCTL2026 gibt’s 37 % Rabatt — gültig bis 31. Oktober 2026.
Lizenz
Siehe LICENSE.
Installation & Setup Guide
Get started with postctl by building the binary and initializing your local developer workspace.
System Requirements
- Operating System: macOS, Linux, or Windows (WSL recommended).
- Go Toolchain: Go 1.22 or higher installed on your system.
- SQLite: Local SQLite client library (CGO-free driver is compiled directly inside).
One-Step Automatic Setup
The easiest way to initialize the application, databases, and dependencies is to run the interactive setup script:
chmod +x setup.sh
./setup.shThe setup script will:
- Verify your local Go environment.
- Download required packages (including Bubble Tea, Cobra, and SQLite).
- Build the production binary
postctl. - Create the configuration directory at
~/.config/postctl/. - Generate a default
config.yamland empty SQLite database.
Manual Compilation
If you prefer to compile the application manually, run:
# Download dependencies
go mod download
# Build binary with embedded assets
go build -o postctl main.go
# Run help command to verify
./postctl --helpConfiguration Directory
The application creates and expects files in the user config directory: ~/.config/postctl/
config.yaml: Holds all your global default preferences, AI configurations, and platform API credentials.postctl.db: The local SQLite database housing campaigns, post statuses, histories, and encrypted credentials.
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.shDas Setup-Skript führt folgende Schritte aus:
- Überprüfung deiner lokalen Go-Umgebung.
- Herunterladen der benötigten Pakete (einschließlich Bubble Tea, Cobra und SQLite).
- Kompilieren des Produktions-Binaries
postctl. - Erstellen des Konfigurationsverzeichnisses unter
~/.config/postctl/. - Generieren einer Standard-
config.yamlund 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 --helpKonfigurationsverzeichnis
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 External Editor Flow
Inside the interactive Terminal UI, you can spawn your terminal-native text editor (like Neovim or Vim) to edit post text, adjust templates, or modify metadata on the fly.
How to trigger the Editor
- Start the TUI dashboard:
./postctl tui - Navigate to a post or campaign and press
eto open the edit form. - While focused on the body or any text input field, press
ctrl+v. - The TUI process suspends, and your terminal launches your configured editor (reads
$EDITOR, falling back tonvimorvim).
The Template File Structure
Vim opens a temporary file formatted as Markdown. It contains three distinct blocks:
1. Interactive Helper Block (HTML Comments)
<!--
postctl Editor Help (TWITTER)
==================================
[Character Ruler (Max 280 characters per tweet)]
000 030 060 090 120 150 180 210 240 270 280!
|--------|--------|--------|--------|--------|--------|--------|--------|--------|-|
Current Thread Status:
Tweet 1: 220 chars (60 remaining) [✓]
Tweet 2: 110 chars (170 remaining) [✓]
NOTE: This helper block will be stripped out automatically upon save.
Write your post content below this comment:
-->This block updates dynamically according to your target platform limits (280 for Twitter, 300 for Bluesky, 500 for Mastodon). It is stripped out automatically when saving.
2. YAML Frontmatter Block
---
platform: twitter
campaign: launch-2026
schedule: 2026-06-25 15:00:00
images: ["logo.png"]
---Bidirectional Syncing: You can edit these values inside Vim. Changing the campaign name, target platform, schedule timestamp, or image list updates the TUI form fields immediately upon return.
3. Content Body
The post body begins after the closing --- frontmatter delimiter. Write your post in standard Markdown.
For multi-post threads, separate each section using a line containing only ---.
📝 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.
Editor aufrufen
- TUI-Dashboard starten:
./postctl tui - Zu einem Beitrag oder einer Kampagne navigieren und
edrücken, um das Bearbeitungsformular zu öffnen. - Im Fokus auf den Body oder ein beliebiges Textfeld
ctrl+vdrücken. - Der TUI-Prozess pausiert, und dein Terminal startet deinen konfigurierten Editor (liest
$EDITOR, fällt sonst aufnvimodervimzurü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.
🖼️ Image Path Resolution & Platform Uploads
Manage and attach media assets seamlessly using local file references.
How Image Paths Are Resolved
When you reference a file name or path in your post frontmatter (e.g. images: ["dashboard-screenshot.png"]), postctl executes a fallback search in the following sequence:
- Absolute Path Check: Is it a literal path on your system? (e.g.
/Users/user/Pictures/screenshot.png) - Relative to Markdown Document: If you imported a file from
/code/posts/announcement.md, it checks/code/posts/dashboard-screenshot.png. - Current Working Directory (CWD): Checks the folder where you launched the
postctlcommand. - Global Default Image Directory: Checks the directory specified by
defaults.image_dirin your~/.config/postctl/config.yamlfile.
Platform-Specific Upload Behavior
Each platform API handles media uploads differently, which is automatically abstracted by the driver layers:
- LinkedIn (Native Hosting): Images are uploaded as raw binary assets to LinkedIn. The API registers the upload, performs a
PUT, and links the resulting URN (e.g.,urn:li:digitalmediaAsset:...) to your post. - Twitter / X (Native Hosting): Images are uploaded to the Twitter v1.1 Media Upload endpoint to obtain a
media_id, which is attached to the v2 tweet. - Mastodon / Bluesky (Native): Media files are uploaded to platform attachment endpoints (or blob records for Bluesky AT Protocol) and linked to the status.
- Threads (Meta HTTPS Requirement): The Threads API does not allow binary local uploads. Instead, it expects a public HTTPS URL (e.g.
https://yourdomain.com/assets/screenshot.png) from which the Meta crawler fetches the image. If publishing to Threads, host your images on an external cloud (S3, R2, Imgur) and reference that HTTPS URL in the frontmatter.
🖼️ 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:
- Absolutpfad-Prüfung: Ist es ein literaler Pfad auf deinem System? (z. B.
/Users/user/Pictures/screenshot.png) - Relativ zum Markdown-Dokument: Wenn du eine Datei aus
/code/posts/announcement.mdimportiert hast, wird/code/posts/dashboard-screenshot.pnggeprüft. - Aktuelles Arbeitsverzeichnis (CWD): Prüft den Ordner, aus dem heraus du den
postctl-Befehl gestartet hast. - Globales Standard-Bildverzeichnis: Prüft das in
defaults.image_dirin deiner~/.config/postctl/config.yamlangegebene 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
PUTaus 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_idzu 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.
Es stehen dir zwei Optionen zur Verfügung:
- Option A: Cookie-basierte Authentifizierung (Kostenlos, aber inoffiziell) – Nutzt deine bestehende Browsersitzung. Kostenlos, aber fehleranfällig und mit Risiko einer Kontosperrung.
- Option B: Offizielle API (Kostenpflichtig & Empfohlen) – Nutzt die offizielle Twitter API (erfordert ein bezahltes Abonnement ab ca. $100/Monat oder prepaid API-Credits). Der sichere, stabile Weg.
Option A: Cookie-basierte Authentifizierung (Kostenlos, aber inoffiziell)
Diese Methode simuliert eine echte Browser-Sitzung, indem sie deine Anmelde-Cookies verwendet. Sie ist vollkommen kostenlos und erfordert keine Einrichtung im Twitter Developer Portal.
[!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).
postctlversucht, 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_tokenundct0) ein.
Schritt 1: Komplette Browser-Cookies auslesen
Der einfachste Weg ist, den gesamten Cookie-Header einer beliebigen Anfrage zu kopieren:
- Ö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).
- Öffne die Entwicklertools (F12 oder Cmd + Option + I).
- Wechsle auf den Reiter Network (Netzwerk).
- Lade die Seite einmal neu (F5 oder Cmd + R).
- Klicke in der Liste der Netzwerkanfragen auf eine beliebige Anfrage zu
x.com(z. B.homeoder einen GraphQL-Request). - Suche im rechten Bereich unter Request Headers (Anfrage-Header) nach der Zeile
cookie:. - Kopiere den gesamten langen Wert (er fängt meist mit
guest_id=...oderkdt=...an und enthält alle Cookies). - 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).
🛠️ Fehlerbehebung bei Cookie-Fehlern / Automatisierungswarnungen
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:
- Automatischer Browser-Start:
postctlstartet im Hintergrund unsichtbar Google Chrome (mittelschromedp), 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”. - Voraussetzung: Google Chrome muss auf deinem System installiert sein (wird auf macOS standardmäßig in
/Applicationsgesucht). - 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.
Option B: 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
- Gehe auf das Twitter Developer Portal und melde dich an.
- Erstelle ein neues Projekt und eine neue App in deinem Portal-Dashboard.
- 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/callbackein. - Website URL: Trage deine eigene Website oder
https://github.com/aeon022/postctlein.
- 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.
Twitter/X API Setup Guide for postctl
This document describes step-by-step how to set up authentication for Twitter/X in postctl.
Two options are available:
- Option A: Cookie-based Authentication (Free but Unofficial) – Uses your existing browser session. Free, but prone to errors and carries a risk of account suspension.
- Option B: Official API (Paid & Recommended) – Uses the official Twitter API (requires a paid subscription starting at ~$100/month or prepaid API credits). The secure, stable way.
Option A: Cookie-based Authentication (Free but Unofficial)
This method simulates a real browser session by using your login cookies. It is completely free and requires no setup in the Twitter Developer Portal.
[!WARNING] Unofficial Bypass Method (Risk of Account Suspension): Cookie-based authentication simulates a web session. This method is error-prone, violates X/Twitter’s Terms of Service (ToS), and can lead to the suspension of your account. The only official and secure way to post is using the paid API (Option B).
postctlattempts to minimize risk by imitating headers and inserting artificial delays (5 seconds between posts), but offers no guarantees.- X strictly requires the
twidcookie (your User ID). Therefore, always enter the entire cookie string or both cookies (auth_tokenandct0).
Step 1: Extract Full Browser Cookies
The easiest way is to copy the entire Cookie header of any request:
- Open your web browser, go to x.com, and make sure you are logged in. (We recommend logging out and back in once to refresh the session).
- Open Developer Tools (F12 or Cmd + Option + I).
- Switch to the Network tab.
- Refresh the page (F5 or Cmd + R).
- Click on any request to
x.comin the network request list (e.g.,homeor a GraphQL request). - In the right pane under Request Headers, look for the
cookie:line. - Copy the entire long value (it usually starts with
guest_id=...orkdt=...and contains all cookies). - Additionally, locate the value for
ct0(your CSRF token, ~160 chars long) in the cookies and copy it as well.
Step 2: Set up in postctl
We recommend setting it up via a one-liner in your terminal. Replace the placeholders with your copied values:
./postctl config setup twitter --cookie "YOUR_ENTIRE_COPIED_COOKIE_STRING" --ct0 "YOUR_ONLY_CT0_VALUE"
(Note: If you start the interactive setup via ./postctl config setup twitter and choose option 2, you can also paste the entire long cookie string when prompted for the auth_token).
🛠️ Troubleshooting Cookie Errors / Automation Warnings
If a GraphQL error like empty tweet ID returned... or error 226 (This request looks like it might be automated...) occurs when publishing a tweet, postctl automatically falls back to a headless browser flow:
- Automatic Browser Start:
postctlsilently launches Google Chrome in the background (usingchromedp), loads your cookies (auth_token&ct0), navigates to the X web interface, populates the composer (including threads and media uploads), and clicks “Post”. - Prerequisite: Google Chrome must be installed on your system (searched by default in
/Applicationson macOS). - Expired Session Cookies: If the headless browser also fails, your cookies have likely expired. Simply repeat Step 1 and enter the new cookies.
Option B: Official API (Paid & Recommended)
If you own an official developer account and are willing to pay the monthly API costs, you can use the standard OAuth authentication.
[!IMPORTANT] Cost Warning (as of 2026): Twitter/X no longer offers free write access (Free Tier) for newly created developer accounts. To post via the official interface, a paid API access (e.g., Basic Tier for ~$100/month or prepaid credits) is required in the Developer Portal.
Step 1: Configure App in the Developer Portal
- Go to the Twitter Developer Portal and log in.
- Create a new Project and a new App in your portal dashboard.
- In the App Settings, navigate to User authentication settings and click Set up:
- App Type: Choose Web App, Automated App or Bot.
- App Permissions: Choose Read and Write (critical for posting rights).
- Type of App: Choose Native App or Single Page App (for OAuth 2.0 PKCE).
- Callback URI / Redirect URL: Enter exactly
http://localhost:8753/callback. - Website URL: Enter your own website or
https://github.com/aeon022/postctl.
- Save the settings and copy the displayed Client ID and Client Secret to a secure place.
Step 2: Store Credentials
# Set Client ID
./postctl config set twitter.client_id "YOUR_CLIENT_ID"
# Set Client Secret
./postctl config set twitter.client_secret "YOUR_CLIENT_SECRET"
Step 3: Perform Authentication
./postctl auth twitter
A browser window will open asking you to authorize the app. After a successful login, postctl will store your encrypted access and refresh tokens.
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
- Gehe zum LinkedIn Developer Portal.
- Melde dich mit deinem persönlichen LinkedIn-Konto an.
- 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.
- App Name: Gib deiner App einen Namen (z. B.
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:
- Gehe in deiner App zum Reiter Products.
- Suche nach Share on LinkedIn und klicke auf Request access (dies wird in der Regel sofort genehmigt).
- 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
- Gehe zum Reiter Auth.
- Scrolle zum Bereich OAuth 2.0 settings:
- Füge unter Authorized Redirect URLs folgenden Callback hinzu:
http://localhost:8753/callback - Klicke auf Update.
- Füge unter Authorized Redirect URLs folgenden Callback hinzu:
- Ü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
- Bleibe im Reiter Auth.
- 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
- Es öffnet sich automatisch ein Browserfenster, das dich auffordert, deiner App den Zugriff auf dein LinkedIn-Profil zu erlauben.
- Nach Klick auf Zulassen/Allow wirst du zum lokalen Webserver weitergeleitet.
- 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. - Du bist nun bereit, Beiträge auf LinkedIn zu veröffentlichen!
LinkedIn API Setup Guide for postctl
This document describes step-by-step how to create a LinkedIn Developer App, migrate it to the modern OpenID Connect (OIDC), and set up your credentials for postctl.
1. Create a Developer App on LinkedIn
- Go to the LinkedIn Developer Portal.
- Log in with your personal LinkedIn account.
- Click on Create App:
- App Name: Give your app a name (e.g.,
postctl-publisher). - LinkedIn Page: Associate the app with your LinkedIn Company Page or create a temporary one (mandatory field).
- App Logo: Upload the generated logo from
postctl/icons(or any square image). - Legal Terms: Accept the terms and click Create app.
- App Name: Give your app a name (e.g.,
2. Add Products (Very Important!)
By default, a new LinkedIn app does not have permissions for posting updates. Since August 2023, LinkedIn replaced the legacy permissions (r_liteprofile). You must activate the following products:
- In your app, go to the Products tab.
- Search for Share on LinkedIn and click Request access (usually approved instantly).
- Search for Sign In with LinkedIn using OpenID Connect (do not choose the outdated Sign In with LinkedIn) and activate it to unlock the modern OIDC login flow.
3. Configure Authentication Settings
- Go to the Auth tab.
- Scroll to the OAuth 2.0 settings section:
- Under Authorized Redirect URLs, add the following callback:
http://localhost:8753/callback - Click Update.
- Under Authorized Redirect URLs, add the following callback:
- In the OAuth 2.0 scopes section, verify that you see the following scopes:
openid,profile,w_member_social,email.
4. Copy Client ID & Client Secret
- Stay in the Auth tab.
- In the Application credentials section, copy the Client ID and the Client Secret.
5. Configure in postctl
Use the CLI to save the credentials in your config.yaml (use the local binary ./postctl):
# Set Client ID
./postctl config set linkedin.client_id "YOUR_CLIENT_ID"
# Set Client Secret
./postctl config set linkedin.client_secret "YOUR_CLIENT_SECRET"
Verify the settings:
./postctl config show
6. Establish Connection (Auth Flow)
Start the authentication flow:
./postctl auth linkedin
- A browser window will automatically open, asking you to authorize your app to access your LinkedIn profile.
- After clicking Allow, you will be redirected to the local web server.
- The CLI intercepts the token via the OIDC flow, determines your user ID (via
/v2/userinfo), encrypts the access token, and saves it in the local SQLite database. - You are now ready to publish posts on LinkedIn!
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
- Gehe zum Meta Developer Portal und melde dich an.
- 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
- Scrolle im Dashboard deiner erstellten App nach unten zu Produkt hinzufügen (Add Products to Your App).
- Suche nach Threads API und klicke auf Einrichten (Set Up).
- 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.
- 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.
- 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(oderhttps://example.com/uninstallals Platzhalter) - Callback-URL löschen (Delete Callback URL):
https://localhost:8753/delete(oderhttps://example.com/deleteals Platzhalter)
- Callback-URLs umleiten (Valid OAuth Redirect URIs):
- 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
- Klicke in der linken Navigationsleiste auf App-Rollen (App Roles / das Personen-Symbol) ➔ Rollen (Roles).
- Scrolle ganz nach unten zum Bereich Threads-Tester (Threads Testers).
- Klicke auf Threads-Tester hinzufügen (Add Threads Testers).
- 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:
- Logge dich im Desktop-Browser auf Instagram.com mit dem entsprechenden Account ein.
- Gehe auf dein Profil ➔ Klicke auf das Zahnrad-Symbol (Einstellungen).
- Navigiere in der linken Leiste zu Apps und Websites (Apps and Websites).
- Klicke oben auf den Reiter Tester-Einladungen (Tester Invites).
- Dort wird dir die Einladung deiner App (z. B.
postctl-threads-app) angezeigt. Klicke auf Akzeptieren (Accept).
5. App ID & App Secret abrufen
- Gehe in der linken Navigationsleiste auf App-Einstellungen (App Settings) ➔ Standard (Basic).
- Kopiere die App-ID (App ID).
- 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
- Dein Standardbrowser öffnet sich und leitet dich zum Threads-Anmeldedialog weiter.
- Melde dich mit deinem Threads-Account an und erlaube den Zugriff auf deine Profildaten und Medien (
threads_basicundthreads_content_publish). - Nach dem Login leitet dich Meta zurück zu
https://localhost:8753/callback. - Umgang mit der SSL-Warnung im Browser:
- Da der lokale Server von
postctlzur Verschlüsselung ein temporäres, selbstsigniertes SSL-Zertifikat nutzt, wird dein Browser die Warnung „Dies ist keine sichere Verbindung“ (oderNET::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)“.
- Da der lokale Server von
- Sobald du darauf klickst, empfängt
postctldas Zugriffstoken, tauscht es im Hintergrund in ein 60 Tage gültiges Long-Lived Token um und speichert es verschlüsselt in der SQLite-Datenbank. - Im Terminal erscheint die Erfolgsmeldung! Du bist nun bereit, Beiträge auf Threads zu posten.
Threads (Meta) API Setup Guide for postctl
This document describes in detail how to obtain access to the official Threads API, configure your app in the Meta Developer Portal, and set up authentication for postctl.
1. Create a Meta Developer App
- Go to the Meta Developer Portal and log in.
- Click on My Apps in the top right corner and then Create App:
- Choose Other as the app type and click Next.
- Choose Consumer (or any type that allows access to the Threads/Instagram API).
- Enter a name for your application (e.g.,
postctl-threads-app). - Click Create App (you may be prompted to enter your Facebook password).
2. Add Threads API
- In your app dashboard, scroll down to Add Products to Your App.
- Find Threads API and click Set Up.
- Your app is now in In Development mode – this is perfect and important for local development.
3. Configure Callback URLs (Important: Mandatory Fields & HTTPS!)
Meta strictly enforces secure HTTPS connections for the Threads API. Furthermore, the dashboard will refuse to save changes if deinstallation or deletion callback URLs are missing.
- In the left navigation bar, under Threads API, click Threads Settings (or go to Use Cases ➔ Access Threads API ➔ Customize / Settings).
- Configure the following three fields:
- Valid OAuth Redirect URIs:
https://localhost:8753/callback(Important: https instead of http!) - Uninstall Callback URL:
https://localhost:8753/uninstall(orhttps://example.com/uninstallas a placeholder) - Delete Callback URL:
https://localhost:8753/delete(orhttps://example.com/deleteas a placeholder)
- Valid OAuth Redirect URIs:
- Click Save in the bottom right.
4. App Roles & Tester Invitation (Important!)
Since your app is in development mode, it can only be authenticated by users registered as testers.
Step A: Add Tester in the Meta Dashboard
- Click App Roles (the people icon) ➔ Roles in the left navigation bar.
- Scroll to the bottom to the Threads Testers section.
- Click Add Threads Testers.
- Enter the exact Instagram/Threads username of the account you want to post from, and click Confirm.
Step B: Accept Invitation on Instagram
The invitation must be confirmed manually from your Threads account:
- Log in to Instagram.com in a desktop browser using the corresponding account.
- Go to your profile ➔ Click the Gear icon (Settings).
- Navigate to Apps and Websites in the left menu.
- Click on the Tester Invites tab at the top.
- You will see the invite from your app (e.g.,
postctl-threads-app). Click Accept.
5. Retrieve App ID & App Secret
- In the left navigation bar, go to App Settings ➔ Basic.
- Copy the App ID.
- Copy the App Secret (requires clicking Show and entering your password).
6. Configure in postctl
Save your Threads credentials in your terminal (use ./postctl for the local binary):
# Set App ID
./postctl config set threads.app_id "YOUR_APP_ID"
# Set App Secret
./postctl config set threads.app_secret "YOUR_APP_SECRET"
Verify your configuration:
./postctl config show
7. Establish Connection (Auth Flow)
Run the authentication flow:
./postctl auth threads
- Your default browser will open and redirect you to the Threads login dialog.
- Log in with your Threads account and grant permission to access your profile data and media (
threads_basicandthreads_content_publish). - After logging in, Meta redirects you back to
https://localhost:8753/callback. - Handling the SSL Warning in the Browser:
- Because
postctl’s local server uses a temporary, self-signed SSL certificate for encryption, your browser will display a warning “Your connection is not private” (orNET::ERR_CERT_AUTHORITY_INVALID). - This is completely normal and safe, as all communication takes place locally on your Mac.
- Click “Advanced” (or Show Details) in the browser window and select “Proceed to localhost (unsafe)”.
- Because
- Once clicked,
postctlreceives the access token, exchanges it in the background for a long-lived token valid for 60 days, and stores it encrypted in the SQLite database. - A success message will appear in your terminal! You are now ready to post updates to Threads.
🦋 Bluesky & Mastodon Setup Guide
Learn how to connect postctl to Bluesky (via the AT Protocol) and Mastodon instances.
1. Bluesky Integration
Bluesky uses the decentralized AT Protocol. Instead of complex OAuth, it uses secure App Passwords.
Step A: Create an App Password
- Log in to your Bluesky account in your browser.
- Go to Settings ➔ App Passwords.
- Click Create App Password, give it a name (e.g.
postctl-cli), and copy the generated password (usually looks likexxxx-xxxx-xxxx-xxxx).
Step B: Configure postctl
Configure your handle and app password:
# Configure handle
./postctl config set bluesky.handle "yourname.bsky.social"
# Configure app password
./postctl config set bluesky.app_password "xxxx-xxxx-xxxx-xxxx"Step C: Authenticate
./postctl auth blueskyThe CLI will test the connection, fetch your profile, and save the session tokens in your SQLite database. Bluesky posts support up to 300 characters.
2. Mastodon Integration
Mastodon supports federated instances and uses client credentials registration per instance.
Step A: Configure Instance URL
By default, postctl connects to https://mastodon.social. If you are on a different instance, configure it first:
./postctl config set mastodon.instance_url "https://fosstodon.org"Step B: Authenticate
Run the login workflow:
./postctl auth mastodon- The CLI checks if a client application exists on that instance. If not, it registers a new app named
postctlon your instance automatically. - A browser window will open, prompting you to authorize the app.
- Log in and click Authorize.
- The tokens are retrieved and securely stored in your local DB. Mastodon posts support up to 500 characters.
🦋 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
- Melde dich in deinem Browser bei deinem Bluesky-Konto an.
- Gehe zu Settings ➔ App Passwords.
- Klicke auf Create App Password, gib ihm einen Namen (z. B.
postctl-cli) und kopiere das generierte Passwort (sieht meist aus wiexxxx-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 blueskyDas 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- Das CLI prüft, ob auf dieser Instanz bereits eine Client-Anwendung existiert. Falls nicht, registriert es automatisch eine neue App namens
postctlauf deiner Instanz. - Ein Browserfenster öffnet sich und bittet dich, die App zu autorisieren.
- Melde dich an und klicke auf Authorize.
- Die Tokens werden abgerufen und sicher in deiner lokalen DB gespeichert. Mastodon-Beiträge unterstützen bis zu 500 Zeichen.
🤖 Facebook Setup Guide
Configure automated publication to Facebook business/creator Pages using Meta Graph API tokens.
Facebook Integration
Facebook posts are pushed directly to Business/Creator Pages using Meta Graph API tokens.
Step A: Create App on Meta Portal
- Go to the Meta Developer Portal.
- Create an app of type Business or Other.
- Configure the OAuth Redirect URL:
http://localhost:8753/callback - Go to Settings ➔ Basic and copy your App ID and App Secret.
- Identify the target Facebook Page ID (found in your Facebook Page "About" info).
Step B: Save to Configuration
./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"Step C: Run Authentication
./postctl auth facebookAccept the prompt to authorize managing and publishing pages. The access token is exchanged for a permanent page access token and saved securely.
🤖 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
- Gehe zum Meta Developer Portal.
- Erstelle eine App vom Typ Business oder Other.
- Konfiguriere die OAuth-Redirect-URL:
http://localhost:8753/callback - Gehe zu Settings ➔ Basic und kopiere deine App ID und dein App Secret.
- 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 facebookBestä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: "Change the LinkedIn DE version, rest is fine"
↓
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
- All commands must work non-interactively — no prompts, no “are you sure?”, no interactive menus. Flags control everything.
--format jsonon all commands — structured output that AI can parse. Default is human-readable,--format jsonreturns machine-readable.--dry-runon all mutation commands — AI can preview without side effects. Human approves, then AI runs without--dry-run.- Exit codes — 0 = success, 1 = validation error, 2 = API error, 3 = auth error. AI reads exit codes, not just output.
- Idempotent imports — running
postctl importtwice doesn’t duplicate posts. AI can re-import after edits without cleanup. - Partial failure recovery — if tweet 3/5 fails,
postctl post <id> --resumecontinues from where it stopped. AI doesn’t need to track state manually. - No browser required — OAuth flow uses localhost callback, but once authenticated, everything is CLI-only. AI never needs to open a browser.
- 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 Nheaders) - 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:
- Upload all images → get media IDs
- Post Tweet 1 → get tweet ID
- Post Tweet 2 as reply to Tweet 1 → get tweet ID
- Post Tweet 3 as reply to Tweet 2 → …
- Post Reply tweet as reply to last tweet
- 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 daemonfor 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 Nsplits into individual tweets## Replyis 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):
- Register:
POST /v2/assets?action=registerUpload→ get upload URL + asset URN - 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+)
Note: everything in this section is a forward-looking idea, not a commitment or a currently sold product. postctl’s actual pricing today is the one-time missionctl Bundle license (Polar.sh) described on the pricing page — not the subscription tiers sketched out below.
postctl generate
AI generates posts from a URL or a Markdown article.
- Input: URL, Markdown file, or free text
- Output: thread draft + LinkedIn post + Threads post as Markdown
- Uses the Claude API, OpenAI API, or Ollama (local)
- User reviews and edits before posting
postctl repurpose
Takes an existing post and converts it for other platforms.
- Dev.to article → Twitter thread + LinkedIn + Threads
- Twitter thread → LinkedIn long-form post
- Automatically adapts length, tone, and hashtags
postctl analytics
Fetches engagement data from the APIs after posting.
- Likes, retweets, impressions (Twitter)
- Reactions, comments (LinkedIn)
- Determines best posting times
- Terminal dashboard with sparklines
postctl template
Pre-built post structures.
postctl template launch— product launch announcementpostctl template feature— feature update threadpostctl template thought— thought leadership post- Generates a Markdown file with placeholders
Ecosystem Vision
postctl is part of a content loop:
Orbiter (Create) → postctl (Distribute) → Analytics (Learn) → Orbiter (Improve)
- Write content in Orbiter (blog posts, pages)
- Export/generate posts as Markdown
- postctl distributes to Twitter, LinkedIn, Threads
- Analytics shows what’s working
- Insights feed into the next content cycle
Long-term: orbiter export --to-postctl as an integration.
Project Setup
Repository: ~/Developing/Projects/postctl
Module: github.com/aeon022/postctl
License: MIT
postctl — Spezifikation
Überblick
postctl ist ein in Go geschriebenes TUI-CLI-Tool, das Social-Media-Beiträge und Blogartikel über Twitter/X, LinkedIn, Threads, Mastodon, Bluesky, Facebook, Telegram, Discord, Reddit, Dev.to, Hashnode und Medium hinweg verwaltet. Beiträge werden als Markdown-Dateien verfasst, in eine lokale SQLite-Datenbank importiert, in einer Terminal-UI vorgeschaut und über Plattform-APIs veröffentlicht — sofort oder nach Zeitplan.
User Stories
Als Solo-Entwickler / Indie Hacker:
- Ich will Beiträge als Markdown-Dateien in meinem Repo schreiben
- Ich will vorschauen, wie ein Twitter-Thread aussehen wird, bevor ich poste
- Ich will Beiträge für optimales Timing über Zeitzonen hinweg einplanen
- Ich will denselben Inhalt mit einem Befehl auf mehreren Plattformen posten
- Ich will sehen, was ich wann gepostet habe
- Ich will eine Zeichenzahl-Prüfung vor dem Posten (280 Zeichen pro Tweet)
Als KI-Assistent (Claude, GPT, Antigravity):
- Ich will korrekt formatierte Markdown-Beiträge erstellen
- Ich will das Posten über CLI-Befehle auslösen
- Ich will Beitragsstatus und -verlauf prüfen können
- Ich will den kompletten Workflow durchgängig ohne menschliches Eingreifen ausführen (außer Freigabe)
- Ich will strukturierte JSON-Ausgabe für alle Befehle, um Ergebnisse zu parsen
- Ich will einen Dry-Run-Modus, um alles zu prüfen, bevor es verbindlich wird
Prinzip: KI als Operator
postctl ist darauf ausgelegt, dass KI es bedient — nicht nur Text generiert.
Die meisten Social-Media-Tools behandeln KI als Copywriting-Feature — “KI schreibt deine Bildunterschrift”. postctl behandelt KI als Operator des gesamten Tools. Der Mensch legt die Strategie fest und gibt frei; die KI führt aus.
Workflow: KI bedient, Mensch gibt frei
Mensch: "Poste den neuen Orbiter-v0.3.66-Release"
↓
KI (Claude/GPT):
1. Schreibt Beiträge als Markdown-Dateien (alle Plattformen, DE+EN)
2. postctl import ./posts/
3. postctl list --format json → zeigt Entwürfe
4. "Hier sind 8 fertige Beiträge. Prüfen?"
↓
Mensch: "LinkedIn DE ändern, sonst passt es"
↓
KI:
5. Bearbeitet die Datei, importiert neu
6. postctl campaign post orbiter-v0366 --dry-run
7. "Dry Run erfolgreich. Posten?"
↓
Mensch: "go"
↓
KI:
8. postctl campaign post orbiter-v0366
9. "Gepostet. 4/4 Twitter, 2/2 LinkedIn, 2/2 Threads. IDs: ..."
Designanforderungen für den KI-Betrieb
- Alle Befehle müssen nicht-interaktiv funktionieren — keine Prompts, kein “bist du sicher?”, keine interaktiven Menüs. Flags steuern alles.
--format jsonbei allen Befehlen — strukturierte Ausgabe, die KI parsen kann. Standard ist menschenlesbar,--format jsonliefert maschinenlesbar.--dry-runbei allen verändernden Befehlen — KI kann ohne Nebenwirkungen vorschauen. Mensch gibt frei, dann führt KI ohne--dry-runaus.- Exit-Codes — 0 = Erfolg, 1 = Validierungsfehler, 2 = API-Fehler, 3 = Auth-Fehler. KI liest Exit-Codes, nicht nur die Ausgabe.
- Idempotente Importe —
postctl importzweimal auszuführen dupliziert keine Beiträge. KI kann nach Bearbeitungen ohne Aufräumen neu importieren. - Wiederherstellung nach Teilfehlern — schlägt Tweet 3/5 fehl, setzt
postctl post <id> --resumedort fort, wo es aufgehört hat. KI muss den Zustand nicht manuell verfolgen. - Kein Browser nötig — der OAuth-Flow nutzt einen Localhost-Callback, aber nach der Authentifizierung läuft alles rein über die CLI. KI muss nie einen Browser öffnen.
- Batch-Operationen —
postctl campaign post <name>postet alle Beiträge einer Kampagne. KI muss nicht über einzelne Beiträge loopen.
Beispiel für JSON-Ausgabe
$ 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"]
}
Kern-Workflows
1. Import-Workflow
Markdown-Dateien → postctl import → SQLite-DB
↓
Beiträge mit Status "draft"
Eingabe: Verzeichnis mit .md-Dateien mit YAML-Frontmatter
Verarbeitung:
- Frontmatter parsen (platform, type, language, campaign, schedule, images)
- Body in Tweets zerlegen (Trennung bei
## Tweet N-Überschriften) - Reply-Abschnitt erkennen (
## Reply) - Validieren: Zeichenzahl pro Tweet (≤280), Bildpfade existieren
- Deterministische ID aus Dateiname + Plattform erzeugen
- In SQLite einfügen/aktualisieren (Upsert auf ID)
Ausgabe: Beiträge in der DB mit Status draft oder scheduled (falls schedule:-Frontmatter vorhanden)
2. Vorschau-Workflow
postctl (ohne Argumente) → TUI
↓
Dashboard → Beitragsliste → Detailansicht
↓
Tweet-für-Tweet-Vorschau
mit Zeichenzahl + Bildindikatoren
3. Post-Workflow
postctl post <id> → Aus DB laden → Validieren → API-Aufruf → Status aktualisieren
↓
Zuerst Bilder hochladen
Dann Text mit Media-IDs posten
Bei Threads: sequenziell posten,
als Antwort auf vorherige Tweet-ID
Ablauf beim Thread-Posten:
- Alle Bilder hochladen → Media-IDs erhalten
- Tweet 1 posten → Tweet-ID erhalten
- Tweet 2 als Antwort auf Tweet 1 posten → Tweet-ID erhalten
- Tweet 3 als Antwort auf Tweet 2 posten → …
- Reply-Tweet als Antwort auf den letzten Tweet posten
- DB aktualisieren: status = “posted”, platform_id = erste Tweet-ID
Fehlerbehandlung:
- Schlägt Tweet N fehl: Beitrag als “partial” markieren, letzte erfolgreiche Tweet-ID speichern
- Wiederholung: ab dem fehlgeschlagenen Tweet fortsetzen (erfolgreiche nicht erneut posten)
- Bei Rate-Limit: warten und mit exponentiellem Backoff wiederholen
4. Zeitplan-Workflow
postctl schedule <id> "2026-06-23 09:00"
↓
DB aktualisieren: status = "scheduled", scheduled_at = Zeitstempel
↓
Scheduler-Daemon greift zur richtigen Zeit
↓
Wie der Post-Workflow
Scheduler:
- Läuft als Hintergrund-Goroutine, während die TUI offen ist
- Läuft auch als
postctl daemonim Headless-Modus - Prüft alle 30 Sekunden auf fällige Beiträge
- Postet in Reihenfolge von scheduled_at
5. Auth-Workflow
postctl auth twitter
↓
Browser öffnen → Twitter-OAuth-Zustimmungsseite
↓
Lokaler HTTP-Server auf :8753 fängt den Callback ab
↓
Code gegen Token tauschen
↓
Verschlüsseltes Token in SQLite speichern
Markdown-Formatspezifikation
Frontmatter-Felder
| Feld | Pflicht | Typ | Werte | Standard |
|---|---|---|---|---|
platform |
ja | String | twitter, linkedin, threads, all |
— |
type |
ja | String | thread, single, article |
— |
language |
nein | String | ISO 639-1 (en, de) |
en |
campaign |
nein | String | freier Slug | — |
schedule |
nein | Datum/Zeit | ISO 8601 lokal | — |
images |
nein | Liste | relative Dateipfade | — |
tags |
nein | Liste | Hashtags ohne # | — |
Body-Format
Einzelbeitrag (LinkedIn, Threads):
---
platform: linkedin
type: single
---
Der gesamte Beitragstext kommt hierher.
Mehrere Absätze werden unterstützt.
Thread (Twitter):
---
platform: twitter
type: thread
images:
- screenshots/01-dashboard.png
---
## Tweet 1
Inhalt des ersten Tweets. Keine Links hier, wegen algorithmischer Reichweite.
## Tweet 2
Zweiter Tweet. Bild anhängen: screenshots/01-dashboard.png
## Tweet 3
Inhalt des dritten Tweets.
## Reply
Links und Hashtags kommen in den Selbst-Reply.
github.com/aeon022/orbiter
#opensource #webdev
Regeln:
## Tweet Ntrennt in einzelne Tweets## Replywird als Antwort auf den letzten Tweet gepostet- Bildzuordnung: erstes Bild in der
images:-Liste geht an Tweet 2, zweites an Tweet 3, usw. Alternativ inline mit<!-- image: filename.png --> - Zeichenzahl: ≤280 pro Tweet (URLs zählen als 23 Zeichen, nach Twitters t.co)
- Leere Tweets werden übersprungen
Plattform-API-Details
Twitter/X v2
Auth: OAuth 2.0 mit 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=...
Tweet posten:
POST https://api.twitter.com/2/tweets
Authorization: Bearer <token>
Content-Type: application/json
{"text": "...", "media": {"media_ids": ["..."]}, "reply": {"in_reply_to_tweet_id": "..."}}
Medien hochladen (v1.1 — weiterhin erforderlich):
POST https://upload.twitter.com/1.1/media/upload.json
Content-Type: multipart/form-data
media_data=<base64>
LinkedIn v2
Beitrag:
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"}
}
Bild-Upload (2 Schritte):
- Registrieren:
POST /v2/assets?action=registerUpload→ liefert Upload-URL + Asset-URN - Hochladen:
PUT <upload-url>mit binären Bilddaten
Threads (Meta Graph API)
Container erstellen:
POST https://graph.threads.net/v1.0/<user_id>/threads
?media_type=TEXT
&text=...
&access_token=...
Veröffentlichen:
POST https://graph.threads.net/v1.0/<user_id>/threads_publish
?creation_id=<container_id>
&access_token=...
Fehlerbehandlungs-Strategie
| Fehler | Aktion |
|---|---|
| Rate-Limit (429) | retry-after-Header abwarten, dann wiederholen |
| Auth abgelaufen (401) | Token-Refresh versuchen, bei Fehlschlag erneute Anmeldung anfordern |
| Netzwerkfehler | 3x wiederholen mit exponentiellem Backoff (1s, 4s, 16s) |
| Teilweiser Thread | Als “partial” markieren, Fortschritt speichern, Fortsetzung erlauben |
| Ungültiger Inhalt | Validierungsfehler vor dem API-Aufruf, Anzeige in der TUI |
| Bild zu groß | Mit Go-Bildbibliothek vor dem Upload verkleinern |
Nicht-Ziele (v1)
- Kein Web-Dashboard
- Keine Mehrbenutzer-/Team-Funktionen
- Keine eingebaute Bildgenerierung
- Kein automatisches Cross-Posting (explizit pro Plattform)
Erfolgsmetriken
- 20 Markdown-Beiträge in <1 Sekunde importieren
- Einen 6-Tweet-Thread mit 2 Bildern in <10 Sekunden posten
- TUI rendert mit 60fps im Standard-Terminal
- Einzelnes Binary, <20MB, keine Laufzeit-Abhängigkeiten
- Läuft unter macOS, Linux, Windows
Zukünftige Features (v2+)
Hinweis: Alles in diesem Abschnitt ist eine zukunftsgerichtete Idee, keine Zusage und kein aktuell verkauftes Produkt. postctls tatsächliches Preismodell ist heute die einmalige missionctl-Bundle-Lizenz (Polar.sh), beschrieben auf der Preise-Seite — nicht die Abo-Stufen, die unten skizziert sind.
postctl generate
KI generiert Beiträge aus einer URL oder einem Markdown-Artikel.
- Eingabe: URL, Markdown-Datei oder freier Text
- Ausgabe: Thread-Entwurf + LinkedIn-Beitrag + Threads-Beitrag als Markdown
- Nutzt die Claude-API, OpenAI-API oder Ollama (lokal)
- Nutzer prüft und bearbeitet vor dem Posten
postctl repurpose
Nimmt einen bestehenden Beitrag und konvertiert ihn für andere Plattformen.
- Dev.to-Artikel → Twitter-Thread + LinkedIn + Threads
- Twitter-Thread → LinkedIn-Langbeitrag
- Passt Länge, Ton und Hashtags automatisch an
postctl analytics
Holt Engagement-Daten von den APIs nach dem Posten.
- Likes, Retweets, Impressions (Twitter)
- Reaktionen, Kommentare (LinkedIn)
- Ermittelt beste Posting-Zeiten
- Terminal-Dashboard mit Sparklines
postctl template
Vorgefertigte Beitragsstrukturen.
postctl template launch— Produkt-Launch-Ankündigungpostctl template feature— Feature-Update-Threadpostctl template thought— Thought-Leadership-Beitrag- Erzeugt eine Markdown-Datei mit Platzhaltern
Ökosystem-Vision
postctl ist Teil eines Content-Loops:
Orbiter (Erstellen) → postctl (Verteilen) → Analytics (Lernen) → Orbiter (Verbessern)
- Content in Orbiter schreiben (Blogbeiträge, Seiten)
- Beiträge als Markdown exportieren/generieren
- postctl verteilt auf Twitter, LinkedIn, Threads
- Analytics zeigt, was funktioniert
- Erkenntnisse fließen in den 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 Pointfmt.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 initerstellt ein Modulgo getinstalliert Dependenciespackage 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, kannnilsein (= 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.ReadFileliest eine ganze Dateifilepath.Walkiteriert über Verzeichnissestrings.SplitN(s, "---", 3)splittet Frontmatterregexp.MustCompilefür Pattern Matchingt.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:
StoreInterface definieren - SQL:
database/sqlStandard-Paket - Prepared Statements: SQL Injection verhindern
- Migrations: Schema-Versionierung
defer: Resource Cleanup (defer db.Close())
🎓 Gelernt
interface{}definiert Verhalten, nicht Strukturdeferführt Code am Funktions-Ende aus (wiefinally)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.Cmdfü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:
PlatformInterface 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.Contextfür Timeouts und Cancellationjson.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
selectwartet auf mehrere Channels gleichzeitigsignal.Notifyfä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
- Go Tour — interaktives Tutorial
- Go by Example — Snippets für jedes Konzept
- Effective Go — Idiomatisches Go
- Bubbletea Docs — TUI Framework
- Charm Examples — TUI Beispiele
Go Tutorial — postctl
This tutorial guides you through learning Go using the postctl project. Each phase builds a feature and teaches new Go concepts.
Course Concept
This tutorial is written in parallel with the project and can be published as a paid course:
- Format: Video series (screen recording + voiceover) + written tutorial
- Title: “Build a Real CLI Tool in Go — From Zero to Production”
- Target Audience: Developers who want to learn Go by building a real project
- Teaser: Phase 1 + 2 free as blog posts on Dev.to (traffic + leads)
- Full Version: Phase 3-7 as a paid course ($49-79)
- Platform: Self-hosted site (Lemon Squeezy) or Udemy
- Languages: German (primary) + English
Each phase ends with a Challenge — a task the learner solves themselves before seeing the solution.
Prerequisites
# Install Go
brew install go
# Verify version (1.22+)
go version
# Editor: VS Code + Go Extension (gopls)
code --install-extension golang.go
Phase 1: Hello Go — Project Setup
What we build
Basic structure with Cobra CLI: postctl version, postctl help
Go Concepts
- Go Modules: Dependency Management (
go.mod) - Packages: Code organization in folders
func main(): Entry Pointfmt.Println: Output- Strings: String formatting with
fmt.Sprintf
Steps
# 1. Create project
mkdir postctl && cd postctl
go mod init github.com/aeon022/postctl
# 2. Install Cobra
go get github.com/spf13/cobra@latest
# 3. Create folders
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 — set at build time
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 — called by main()
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 version %s\n", Version)
},
})
}
Challenge 1
Add a command postctl hello <name> that prints Hello <name>! Welcome to Go.. (Tip: Check cobra.ExactArgs(1)).
Phase 2: File Reader — Markdown Parser
What we build
A parser that reads a Markdown file, extracts YAML Frontmatter (metadata like platform, schedule, images) and the content body.
Go Concepts
- Structs: Custom data types for Post/Tweet
- File I/O:
os.ReadFile,os.Open - Error Handling:
errorinterface andif err != nil - Strings/Slices:
strings.Split,strings.HasPrefix - Pointers: Passing by reference vs value
Code Example: Metadata Structs
package models
import "time"
type Post struct {
ID string
Platform string // twitter, linkedin, threads
Type string // single, thread
Campaign string
ScheduledAt *time.Time
Body string
Images []string
Status string // draft, scheduled, posted, failed
Error string
}
Challenge 2
Write a function ParseFrontmatter(content string) (models.Post, error) that splits the YAML block (between ---) and parses key-value pairs manually without using external libraries.
Phase 3: Local Database — SQLite Integration
What we build
Save imported posts to a local SQLite database. Update post status (draft, scheduled, posted) and save publishing history.
Go Concepts
- Database Connection:
database/sqlpackage - Driver: Go SQLite driver (CGO-free via
modernc.org/sqlite) - SQL Operations:
CREATE TABLE,INSERT,UPDATE,SELECT - Time Parsing:
time.Parseand time zone handling
Challenge 3
Create a database table posts and write a method SavePost(post models.Post) error that inserts a new post or updates an existing one if the ID already exists (UPSERT).
Phase 4: API Clients — Publishing to Platforms
What we build
HTTP client implementations for Twitter/X, LinkedIn (OIDC), and Threads API.
Go Concepts
- Interfaces: Define a
Platforminterface that all clients implement:type Platform interface { Auth(ctx context.Context) error Post(ctx context.Context, post *models.Post) (string, error) IsAuthenticated(ctx context.Context) bool } - HTTP Requests:
net/httppackage, custom headers, JSON payloads - OAuth 2.0 Flow: Token exchange and local web callback server
Challenge 4
Implement the LinkedIn client using the openid and w_member_social scopes to publish a simple status message.
Phase 5: Browser Automation — Headless Fallback
What we build
A headless browser fallback for X/Twitter when API calls fail, utilizing chromedp to set login cookies, navigate the web interface, type tweets, and post them.
Go Concepts
- Context: Timeout and cancellation propagation
- Goroutines & Channels: Network response interception to capture GQL tweet IDs
- DevTools Protocol: Driving Chrome via CDP
Challenge 5
Set Chrome flags to bypass automated browser checks (e.g., hiding navigator.webdriver via AutomationControlled).
Phase 6: Terminal User Interface (TUI) — Bubble Tea
What we build
An interactive terminal dashboard with tabs (Dashboard, Posts, Schedule, Settings) showing campaigns, posts, and scheduler status.
Go Concepts
- Elm Architecture: Model-Update-View pattern in CLI
- Bubble Tea Framework:
tea.Model,tea.Msg,tea.Cmd - Terminal Rendering:
lipglossfor styling, borders, and layouts
Challenge 6
Build a custom text input field that opens an external Vim/Neovim editor, suspends the TUI, and restores the terminal state upon editor exit.
Phase 7: Background Scheduler — Daemon
What we build
A background daemon (postctl daemon) that runs in the background, checks the database every 10 seconds, and publishes due posts automatically.
Go Concepts
- Ticker:
time.NewTickerfor periodic tasks - Signals: Catching OS signals (
SIGINT,SIGTERM) for graceful shutdown - File Locks: Prevent starting multiple scheduler processes concurrently
Challenge 7
Implement a locking mechanism using a pidfile (postctl.pid) to ensure only one daemon runs at a time.