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.
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.