Post to X
Publishes a tweet or thread to X/Twitter by driving x.com in a real browser, not the API. X removed the free API in Feb 2026 (pay-per-use: $0.015/post, $0.20 with a link), so this reuses a browser session you log into once by hand and posts through the site itself. It uses the real Chrome binary + strips the automation flags to get past X's "browser not trusted" check.
Login once (manual) → parse text/thread → compose in a headed dry-run → [user OK] → post for real
ToS + risk. Automated posting is against X's ToS and can get an account flagged. This is for low-volume, human-paced self-posting — a release thread, a build-in-public update — not a bulk bot. Always dry-run first, keep delays human, and never post on the user's behalf without an explicit go.
0. One-time setup
The script needs Playwright and Google Chrome installed (Chrome is the trusted binary; Playwright only supplies the driver — no browser download).
cd <skill-dir> && npm install # installs playwright into the skill
Then authenticate once — opens real Chrome; the user logs in by hand (password +
2FA), the session persists to ~/.config/post-to-x/x-profile:
node scripts/post-x.mjs login
If channel: "chrome" errors, Chrome isn't found — install Google Chrome (not
just Chromium). The login window waits up to 5 min for a logged-in state.
1. Prepare the content
Accept either inline text or a file:
- Single tweet —
--text "…". - Thread file —
--file <path>. Tweet boundaries are auto-detected:**N/M**markers per tweet (build-in-publicx-thread.mdstyle), else- tweets separated by a line containing only
---, else - no separators → the whole file is one tweet.
- Images — per tweet, add a line
_image: path_orinside that tweet; or attach to the first tweet with repeatable--image <path>. X takes up to 4 per tweet. Paths are relative to the file. - Auto-stripped:
> …blockquotes (DRAFT banners),# …headings,_(…chars…)_notes.
Keep each tweet ≤ 280 chars — the script prints the count and flags any over.
2. Dry-run (mandatory gate)
Always compose visibly without sending first, so the user sees the real thread in the X composer:
node scripts/post-x.mjs post --file thread.md --headed --dry-run
It types every tweet into the compose modal (adding thread posts via the "+" button), attaches images, then holds 30 s and closes — nothing is posted. Show the user; get an explicit OK.
3. Post for real
Only after the user approves, drop --dry-run:
node scripts/post-x.mjs post --file thread.md # or --text "…"
node scripts/post-x.mjs post --file thread.md --image shot.png --headed
--headed shows the browser; default is headless. The script clicks X's
"Post all" (thread) / "Post" (single) and confirms the composer closed.
4. Validate
- Parse gate —
--parse-onlyprints the parsed tweets + char counts + image paths and exits. Run it whenever the file format is non-obvious. - Dry-run gate — the headed compose above is the real proof it will post correctly; skipping it has shipped a broken thread before (ambiguous selectors, wrong tweet split).
- Session gate — if the composer never loads, the session expired → re-run
login.
5. Report
Tell the user: how many tweets posted (or composed, for a dry-run), any tweet
over 280, images attached, and the profile dir used. If a selector failed, say
which step and that X's UI likely changed (the data-testids in post-x.mjs
are the brittle part).
Notes
- Selectors used:
tweetTextarea_<i>,addButton,fileInput,tweetButton— scoped to the compose[role="dialog"]. Update these inscripts/post-x.mjsif X reworks the composer. - The session lives in
~/.config/post-to-x/x-profile(override--profile). It's outside the repo — never commit it. - X only; LinkedIn/Mastodon/Bluesky have free APIs — use a scheduler (Postiz, Buffer, Typefully) for those instead of scraping.