X (Twitter) in log

log runs X accounts without a browser: it calls X's internal API directly with the account's saved session, through the vendored twikit library. This page explains how X fits into log's usual shape (command, engine, library, Twenty), which commands exist, how sends and queues work, and how X's answers are read. Read it if you work on the X commands or want to know how a reply or DM actually goes out.

Status

Steps 1 to 4 of the plan below are built:

  • the engine (python/twitterApi/engine.py: ping, follows, reply, quote, like, dm, each answer read into one event);
  • log twitter session check, follows, reply, quote, like, dm and queue, each an MCP tool, with a …Live version for the sends and queue run.

Step 5, retiring the earlier X controller, is left.

Two things are vendored:

  • vendor/twikit: a fork of twikit (MIT, commit 6a73ab9, 2026-05-17). We maintain it ourselves. Its transport is curl_cffi impersonating Safari, the way fbk's is, not httpx. bun run check:twikitTransport and vendor/twikit/tests/test_transport.py check this.
  • vendor/twitterController: the earlier X controller's scripts and tests as they were, the code being ported.

The idea in one line

The X controller already does on X what log does on Facebook through fbk: it calls the platform's internal API with an account's saved session, with no browser. So X goes in the same way. twikit is the engine in vendor/, and log's commands, MCP tools, hooks and Twenty sit around it, as log twitter <command> ….

What the X controller does

The controller is an earlier skill that sends what a person has already approved, from any X account in Twenty, through twikit:

ScriptWhat it does
send_approved_reply.py --action pingChecks the saved session still works (read-only).
--action reply / quote / dm / likeSends one approved reply, quote, DM or like, recording who approved it.
--action followsRead-only: does each handle follow the account (friendships/show)?
sequence.py build/show/check/approve/run/statusA queue of approved messages, sent one at a time 90-150 s apart. approve stores a fingerprint of every recipient and every line of copy, and any edit voids it.
random_run.py plan/run/nudgeSpreads a queue over a time window at random times, as a state machine (planned → waiting → liking/replying → retrying → finished, halted), stopping on X's first refusal and a nudge that sends the next N now.

Its habits match log's:

  • Finding people and posts is done by a separate search tool, never through twikit.
  • Nothing is sent without a named approver.
  • Cookies come from Twenty socialAccounts and are never printed.
  • Copy is checked against the house style before it goes out: lowercase, no commas, no full stops, no em dashes.

How X answers

AnswerMeaning
226Looks automated.
344Daily limit reached; wait a day.
385The post is gone.
433Replies are turned off.
HTTP 429 with x-rate-limit-resetToo many requests; wait until the reset time.
HTTP 401, code 32The session is dead.
HTTP 200 with an empty create_tweet resultX quietly dropped the reply.

How it fits log's shape

The same three layers Facebook has, platform first:

LayerFacebookX
The libraryvendor/fbk (one surface per family)vendor/twikit (one Client, 114 calls)
The enginepython/directApi/engine.py: reads the session's login state first, then calls one surfacepython/twitterApi/engine.py, run inside the account's one session host (python/twitterApi/host.py, rule 105): one call at a time, sends 4 to 11 s apart and reads 0.8 to 2.5 s apart (rules 104, 110), the session checked first, then one twikit call
Per account~/.logCli/fbk/<account>: governor and token cache~/.logCli/twitter/<account>: governor, engine.lock, the session host (host.json, host.lock), the transaction key (clientTransaction.json, 3 hours); refreshed cookies merged back into Twenty
The gatedirectGate (session, proxy in the home country, active hours)twitterGate: platform TWITTER, not deactivated, auth_token and ct0 saved. No proxy.
The resultsocialAccounts.sessionStateThe same field (loggedIn, loggedOut for 401/32, restricted for 226)
Declared inDIRECT_ROUTES, commandEngineThe same, with the engine kind directApiOnly: X has no browser path

Example: log twitter reply --account <a> --tweet <url> --text "<copy>" --approvedBy <name> --live.

  1. The gate checks the account.
  2. The engine pings the session.
  3. The style check passes.
  4. twikit sends the reply.
  5. X's answer decides the result. A sent id is success. An empty result, a 429, 226, 344, a soft block or the governor is a refusal, and the run stops there until the owner starts it again (rule 94). A 401 marks the session loggedOut in Twenty.

The commands

These cover what the controller does today, by area.

CommandArea/domainFrom the controllerLive version
log twitter session check --account <a>twitter/account/session--action pingnone
log twitter follows --account <a> --handles <h,…>twitter/graph/follows--action followsnone
log twitter dms --account <a> [--waiting] [--conversation <id>]cli/twitter/dmsnew (twikit's dm/inbox_initial_state, dm/inbox_timeline and dm/conversation)none
log twitter followers --account <a> [--limit <n>]cli/twitter/followersnew (twikit's get_user_followers, 100 a page)none
log twitter reply / quotetwitter/post/send--action reply / quotetwitterReplyLive, twitterQuoteLive
log twitter dm --userId <id>twitter/post/send--action dmtwitterDmLive
log twitter liketwitter/post/send--action liketwitterLikeLive
log twitter queue build / show / check / approve / status / plantwitter/queue/plansequence.py, random_run.py plannone
log twitter queue run / nudgetwitter/queue/run (XState)random_run.py run / nudgetwitterQueueRunLive
log twitter queue add / edittwitter/queue/plan (addToQueue, editItem)newnone
the house style checktwitter/copy/stylecheck_stylenone
  • Without --live, a send is a dry run: the target, the text and its style are checked, and X isn't contacted.
  • --live needs --approvedBy, and the engine also refuses a send without both.
  • Queue files keep the controller's format. Their snake_case keys are read and written back in camelCase, and the approval fingerprint is computed the same way the controller's Python does it, so a queue the controller approved stays approved.

What every platform's commands share stays shared: the copy rules (beside facebook/marketplace/copy) and the governor.

How the queue runner works

random_run.py was already a state machine (python-statemachine). It becomes an XState machine in src/twitter/queue/run/machine.ts, run through observability/trace like every long-running process, so log watch and the live log show each step.

  • States: planned, waiting, liking, replying, retrying, finished, halted. retrying is only for a dropped connection with no answer from X.
  • Each send is one engine call, and its answer is the event: sent, dropped (an empty result), throttled (429, with its reset time), dailyLimit (344), gone (385, 433), automated (226), sessionDead (401/32).
  • What the run must do (random times at least minGap apart, approval, one run per queue, additions while it runs, --keepOpen, a refusal stops it and only a network drop is tried again) is the spec, docs/specs/xQueue.md. The context read right before each send and the hold are docs/specs/fullContext.md.
  • The governor paces every call. fbk's RequestGovernor already paces with heavy-tailed gaps, caps and lasting cooldowns, and takes its settings as an object. X's settings are a second configuration of the same class, kept per account like Facebook's.
  • A halt sends an alert to the team chat with --alertChannel.

Twenty

X accounts are socialAccounts rows with platform TWITTER. They need cookies set; they don't need a proxy or home country. After each run, X's refreshed cookies are merged back into the row, as the controller does.

Not yet

The warm-up, X listening (searching and reading posts), a browser path for X, and rea request docs for X. They'll come later, in the same shape.

The plan, in order

  1. The engine: python/twitterApi/engine.py around vendor/twikit (the session from Twenty, the governor, the session ping, the answer read into an event), with log twitter session check as its first command. It changes nothing on X.
  2. follows: the controller's one read.
  3. reply, quote, like and dm, each with its …Live tool and the style check.
  4. The queue (plan, approve, run, nudge) as the XState machine above.
  5. Retire vendor/twitterController once each script has its command, pointing the skill at log twitter ….

Decisions

  • No proxy for X. X accounts call X directly, as the controller does. The rule that a browser never opens without a proxy stays Facebook's.
  • The name. The platform word is twitter (matching Twenty's TWITTER).

The rules behind this are in AGENTS.md.