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
| Type | Platforms | How it posts |
|---|---|---|
browser_session | X | Headless 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_token | Telegram, Discord | A direct API call: sendMessage for Telegram's Bot API, an equivalent call for Discord's bot API. No browser involved. |
manual | LinkedIn, Instagram | Nothing 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:
- An idempotency-key check (
post:<id>:publish) so a retried job can't double-post or double-charge. - 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. 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.
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.