The publishing model

Why X posting is browser automation instead of the official API, and how the three connection types differ.

ConnectionType.oauth_api exists in the schema as a placeholder and has no implementation. Everything that posts to X today does it by replaying a saved session (a Playwright storageState) through headless Chromium, restoring the cookies, typing into X's own compose box, and clicking tweet.

Why not the official API

X's pay-per-post API pricing makes the official route expensive for a platform footing the bill across many tenants and accounts. Browser-session automation has no per-post platform fee. The cost it carries instead is risk: X can detect and suspend an account it identifies as automating a session this way. That trade-off is made explicit to the operator, not hidden. See Channels for the acknowledgment flow this requires.

The three connection types

TypePlatformsHow it posts
browser_sessionXHeadless Playwright restores the account's storageState, types into the Draft.js compose box with page.keyboard.insertText() (not .fill(), since Draft.js needs real input/beforeinput events), and clicks tweet.
bot_tokenTelegram, DiscordA direct API call: sendMessage for Telegram's Bot API, an equivalent call for Discord's bot API. No browser involved.
manualLinkedIn, InstagramNothing automates posting at all. PublishingService.publish() takes an early-return branch for these accounts: length-check the draft, flip its status to ready_to_copy, and stop, skipping idempotency, the adapter registry, cost recording, and metrics entirely, because none of those apply to a post nobody's system actually sent.

What every real publish goes through

For browser_session and bot_token accounts, PublishingService.publish(postId) runs, in order:

  1. An idempotency-key check (post:<id>:publish) so a retried job can't double-post or double-charge.
  2. A platform-aware length guard (X: 280 weighted characters via twitter-text; Telegram: 4096 plain characters) that fails fast before touching the browser or bot API at all.
  3. AdapterRegistry.forAccount(account).post(): the actual send.

If the session has expired, the account's status flips to needs_reauth rather than failing silently on every retry.

Editing after publish

SocialPostAdapter declares an optional edit(account, platformPostId, newBody). Telegram (editMessageText) and Discord (a PATCH to the message endpoint) implement it; the X adapter doesn't, since X has no free-tier edit API. That absence is the capability check: PATCH /posts/:id/body calls adapters.forAccount(account).edit and checks typeof edit === 'function' rather than maintaining a second, separately-updated list of which platforms support editing. A posted post on a platform without edit() is rejected with a message pointing at delete-and-repost instead.

Metrics only follow real sends

Reading engagement back (the pipeline behind Analytics) only fires for accounts that were really posted through by Pegacraft's own automation. It's X-only today, because the metrics reader depends on the same Playwright storageState the browser adapter already uses; there is no equivalent read-back path for a manual-posting account, since nothing here ever saw the post go live.

On this page