--- url: /getting-started.md --- # Getting started **emusks** is a reverse-engineered Twitter/X API client for JavaScript. emusks lets you interact with Twitter's internal API endpoints just like any official client would, giving you access to features that aren't available through the official API. ## Why emusks? Twitter's official API is limited, expensive, and restrictive. emusks gives you full access to every feature available on the platform - from posting tweets to managing communities, from searching content to handling direct messages - all through a clean, intuitive JavaScript interface. ## Quick start ```js import Emusks from "emusks"; const client = new Emusks(); await client.login("your_auth_token"); const tweet = await client.tweets.create("hallo from emusks! 🚀"); console.log(`i tweeted to https://x.com/i/status/${tweet.id}`); // like the tweet await client.tweets.like(tweet.id); // get a user const user = await client.users.getByUsername("elonmusk"); console.log(`${user.name} has ${user.stats.followers.count} followers`); // follow them await client.users.follow(user.id); // search for tweets const results = await client.search.tweets("javascript"); // get your home timeline const home = await client.timelines.home(); ``` ## What's next? * **[Installation](/getting-started/installation)** - Install the package and set up your project * **[Authentication](/getting-started/authentication)** - Log in with an auth token or username/password * **[Configuration](/getting-started/configuration)** - Choose clients, set up proxies, and configure options *** ::: tip Are you an LLM? Please read our [llms.txt](/llms.txt) or [llms-full.txt](/llms-full.txt). ::: --- --- url: /getting-started/installation.md --- # Installation Install emusks using your preferred package manager: ::: code-group ```sh [npm] npm install emusks ``` ```sh [yarn] yarn add emusks ``` ```sh [pnpm] pnpm add emusks ``` ```sh [bun] bun add emusks ``` ::: You'll now be able to import `emusks` in your project: ```ts import Emusks from "emusks"; const client = new Emusks(); await client.login("your_auth_token"); const user = await client.users.getByUsername("elonmusk"); console.log(user.name); ``` ## Next Steps Now that you have emusks installed, head over to [Authentication](/getting-started/authentication) to learn how to log in and start making requests. --- --- url: /getting-started/authentication.md --- # Authentication Before using emusks, you must authenticate your client. emusks supports two authentication methods: **auth token** login and **username/password** login. ## Auth token login The simplest way to authenticate is with a Twitter/X auth token. This is a cookie value (`auth_token`) from an active browser session. ```js import Emusks from "emusks"; const client = new Emusks(); await client.login("your_auth_token_here"); ``` You may also provide an object if you'd like to configure proxies or a custom client: ```js await client.login({ auth_token: "your_auth_token_here", // e.g. proxy: "protocol://user:pass@host:port", }); ``` ### Finding your auth token 1. Open [x.com](https://x.com) in your browser and log in 2. Open Developer Tools (`F12` or `Cmd+Shift+I`) 3. Go to **Application** → **Cookies** → `https://x.com` 4. Find the cookie named `auth_token` 5. Copy its value ## Username & password login You can also log in with your account credentials. This method handles the full login flow, including the `ui_metrics` JS instrumentation challenge, two-factor authentication, and email verification challenges. ```js const client = new Emusks(); await client.login({ type: "password", username: "your_username", password: "your_password", }); ``` emusks solves the login JS instrumentation challenge for real: it fetches the live challenge script and executes it in a DOM sandbox, computing the genuine `ui_metrics` value the same way a browser would, instead of replaying a fabricated one. This means logins look legitimate and Twitter no longer routinely forces an email/phone confirmation step. ::: tip An auth token is still the simplest way to log in. Password login is now reliable, but Twitter can always ask for a verification code on a genuinely risky login (new IP, flagged account, 2FA enabled), so wire up `onRequest` if you depend on it. ::: ### 2FA & email verification If your account has two-factor authentication enabled or Twitter requests an email/phone verification, use the `onRequest` callback to provide the required codes: ```js const client = new Emusks(); await client.login({ type: "password", username: "your_username", password: "your_password", onRequest: async (type) => { if (type === "two_factor_code") { // Return your 2FA code (e.g. from an authenticator app) return "123456"; } if (type === "email_code") { // Return the code sent to your email return "654321"; } }, }); ``` `onRequest` is blocking. If Twitter asks for a code, your callback must return it before the login can continue. We recommend making your app support email code verification if you rely on username/password login. Since emusks now solves the JS instrumentation challenge genuinely, email/phone prompts are far less common than they used to be, but Twitter may still request one for a login it considers risky. If you do rely on this, I also recommend setting all data so it can handle as much of the login flow as possible without needing to prompt you: ```js const client = new Emusks(); await client.login({ type: "password", username: "your_username", password: "your_password", email: "your_email@example.com", phone: "+1234567890", onRequest: async (type) => { if (type === "two_factor_code") return "123456"; }, }); ``` ## Reference | Option | Type | Description | | ------------ | ---------- | ------------------------------------------------------------------------------- | | `auth_token` | `string` | Your Twitter/X auth token (use directly as the argument to `login()`) | | `type` | `string` | Set to `"password"` for username/password login | | `username` | `string` | Your Twitter/X username | | `password` | `string` | Your account password | | `email` | `string` | Email for alternate identifier challenges | | `phone` | `string` | Phone number for alternate identifier challenges | | `onRequest` | `function` | Async callback for interactive login challenges (2FA, email verification, etc.) | ## Elevated Access Some sensitive actions, like reading settings, require elevated access. After logging in, call `elevate()` with your password: ```js await client.login("your_auth_token"); // Elevate your session for sensitive operations await client.elevate("your_password"); // Now you can perform privileged actions ``` ::: tip You only need to elevate once per session. The elevated state persists until the session ends. ::: ## Checking your session After logging in, you can verify your session by fetching your own profile: ```js const me = await client.account.viewer(); console.log(`Logged in as @${me.username}`); console.log(`Followers: ${me.stats.followers.count}`); ``` or by checking the output of the `client.login()` method, which returns your user object on successful authentication. ## Next steps Head over to [Configuration](/getting-started/configuration) to learn how to choose which client to emulate, set up proxies, and customize your setup. --- --- url: /getting-started/configuration.md --- # Configuration emusks lets you customize how it connects to Twitter/X. You can choose which client to emulate, which GraphQL endpoint to target, route traffic through a proxy, and access the raw API layers directly. ## Client selection Twitter/X uses different API configurations depending on which app you're using. emusks can emulate any of the official clients, each with its own bearer tokens and HTTP fingerprints. ```js import Emusks from "emusks"; const client = new Emusks(); await client.login({ auth_token: "your_auth_token_here", client: "tweetdeck", // emulate the tweetdeck client (premium-only) }); ``` Please note that the availability of clients constantly changes, and some may get your account suspended. Changing the client usually doesn't require getting a new auth token, but you may need to change it if you sign out or get CAPTCHA'd. The current built-in clients are `android`, `iphone`, `ipad`, `mac`, `old`, `web`, `tweetdeck`, along with a few leaked ones: `advertisers`, `ads_manager_next`, `advertiser_interface`, `corporate_cms`, `ads_broken`, `audience_manager_internal`, and `tweets_manager`. ::: tip Custom clients By default, emusks uses the "web" client, which is the most common and least likely to raise suspicion. However, if you want to emulate a different client or use a custom bearer token, you can do so with the `client` option. To use a custom client, pass an object instead of a string: ```js { bearer: "AAAAAAAAAAAAAAAAAAAAA…", fingerprints: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,35-5-27-16-0-10-13-23-45-65037-17613-18-65281-51-43-11,4588-29-23-24,0", ja4r: "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601", }, }, ``` ::: ## GraphQL endpoint Twitter/X exposes the same GraphQL API on multiple endpoints. emusks lets you pick which one to use: | Name | Base URL | Default transaction IDs | | ----------- | ------------------------------------------------------- | ----------------------- | | `web` | `https://x.com/i/api/graphql//` | **on** | | `main` | `https://api.x.com/graphql//` | off | | `tweetdeck` | `https://pro.x.com/i/api/graphql//` | off | | `web_twitter` | `https://twitter.com/i/api/graphql//` | **on** | | `main_twitter` | `https://api.twitter.com/graphql//` | off | | `tweetdeck_twitter` | `https://pro.twitter.com/i/api/graphql//` | off | The default endpoint is `web`. Set it with the `endpoint` option at login: ```js const client = new Emusks(); await client.login({ auth_token: "your_auth_token_here", endpoint: "main", // "web" | "main" | "tweetdeck" | "web_twitter" | "main_twitter" | "tweetdeck_twitter" }); ``` You can also change it after login by setting the property directly: ```js client.graphqlEndpoint = "tweetdeck"; ``` ### Transaction IDs The `web` endpoint expects an `x-client-transaction-id` header on every GraphQL request. emusks generates these automatically using the [`x-client-transaction-id`](https://www.npmjs.com/package/x-client-transaction-id) package. The `main` and `tweetdeck` endpoints don't require transaction IDs, so they are **off** by default for those endpoints. You can override this with the `transactionIds` option: ```js const client = new Emusks(); await client.login({ auth_token: "your_auth_token_here", endpoint: "main", transactionIds: true, // force transaction IDs on for any endpoint }); ``` Or toggle it after login: ```js client.transactionIds = false; // disable even on web ``` ::: tip When to change endpoints Different endpoints can behave differently under rate-limiting. If you're hitting limits on `web`, switching to `main` may help - and vice versa. The `tweetdeck` endpoint mirrors the TweetDeck (X Pro) web app. ::: ## Proxy support Route all API traffic through an HTTP proxy. This is useful for avoiding rate limits, changing IP addresses, or accessing Twitter from restricted networks. ```js const client = new Emusks(); await client.login({ auth_token: "your_auth_token_here", proxy: "protocol://user:pass@host:port", // (supports http, socks4, socks5, socks5h) }); ``` Rotating proxies are not supported by default and I don't recommend using them. While you could constantly rotate the `proxy` sent to the client yourself, Twitter will most likely notice it and get suspicious of a single session being used from many IPs. ## Handling errors emusks throws errors sent from the Twitter GraphQL API by default. You can catch these errors using a try-catch block, and for long-running apps we recommnd setting a system for you to be notified in case your account gets locked or rate-limited: GraphQL routinely returns a populated `data` payload alongside a non-fatal `errors` array (for example `ListAddMember` succeeds but reports a `com.twitter.strato.serialization.DecodeException` on a response sub-field). emusks only throws when `data` is null or missing. When `data` resolves, the call returns normally and the partial errors are preserved on the response's `errors` field for you to inspect. ``` 83 | method, 84 | ) 85 | ).json(); 86 | 87 | if (res?.errors?.[0]) { 88 | throw new Error(res.errors.map((err) => err.message).join(", ")); ^ error: Authorization: Denied by access control: Missing TwitterUserNotSuspended; To protect our users from spam and other malicious activity, this account is temporarily locked. Please log in to https://twitter.com to unlock your account. ``` ## Next steps You're all set! Explore the helper namespaces to start building: * **[Tweets](/tweets/tweets)** - Create, like, retweet, and manage tweets * **[Users](/users/users)** - Follow, block, mute, and look up users * **[Search](/discovery/search)** - Search tweets, users, and media * **[Account](/account/settings)** - Manage your account settings and security --- --- url: /tweets/tweets.md --- # Tweets Create, delete, like, retweet, pin, schedule, and manage tweets. ## `tweets.create(text, opts?)` Create a new tweet. Uses the GraphQL `CreateTweet` mutation. | Option | Type | Description | | -------------------------- | ---------- | ------------------------------------------------------------- | | `text` | `string` | The tweet text | | `opts.mediaIds` | `string[]` | Array of [media IDs](./media) to attach | | `opts.gif` | `object` | Attach a [GIF](#gifs) | | `opts.replyTo` | `string` | Tweet ID to reply to | | `opts.quoteTweetId` | `string` | Tweet ID to quote | | `opts.sensitive` | `boolean` | Mark media as possibly sensitive | | `opts.conversationControl` | `string` | Who can reply: `"ByInvitation"` or `"Community"` | | `opts.poll` | `object` | Attach a [poll](#polls) | | `opts.cardUri` | `string` | A Twitter Card URI to attach (cannot be combined with `poll`) | | `opts.variables` | `object` | Additional GraphQL variables to merge | ```js const tweet = await client.tweets.create("Hello world!"); // Reply to a tweet const reply = await client.tweets.create("Great point!", { replyTo: "1234567890", }); // Quote tweet const qt = await client.tweets.create("Check this out", { quoteTweetId: "1234567890", }); // With media const media = await client.tweets.create("Photo time", { mediaIds: ["9876543210"], sensitive: false, }); // Restrict replies const limited = await client.tweets.create("Only mentioned can reply", { conversationControl: "ByInvitation", }); // With a poll const poll = await client.tweets.create("Best language?", { poll: { choices: ["JavaScript", "Python", "Rust"], duration_minutes: 1440, }, }); // Upload media and tweet const { media_id } = await client.media.create("./photo.jpg", { alt_text: "A gorgeous sunset", }); const mediaTweet = await client.tweets.create("Golden hour", { mediaIds: [media_id], }); // With a GIF from search const { items } = await client.search.gifs("celebration"); const gifTweet = await client.tweets.create("🎉", { gif: items[0] }); ``` **Returns:** Parsed tweet object. ### Polls Create tweets with polls by passing a `poll` option to `tweets.create()`. #### `opts.poll` | Field | Type | Required | Description | | ------------------ | ---------------------- | -------- | ------------------------------------------------------------------- | | `choices` | `string[] \| object[]` | **Yes** | 2–4 choices - strings for text polls, objects for media polls | | `duration_minutes` | `number` | No | How long the poll stays open (default: **1440** = 24 h, max 7 days) | Each choice can be: * A **string** - for text-only polls (up to 25 characters) * An **object** `{ label, image }` - for media polls, where `image` is a `media_id` from `media.create()` #### Text poll ```js await client.tweets.create("Tabs or spaces?", { poll: { choices: ["Tabs", "Spaces"], }, }); ``` #### Media poll Upload images first, then reference their `media_id` in each choice: ```js const [a, b] = await Promise.all([ client.media.create("./option-a.jpg"), client.media.create("./option-b.jpg"), ]); await client.tweets.create("Which design do you prefer?", { poll: { choices: [ { label: "Design A", image: a.media_id }, { label: "Design B", image: b.media_id }, ], duration_minutes: 2880, // 2 days }, }); ``` #### Custom duration ```js // 7-day poll await client.tweets.create("Best frontend framework in 2026?", { poll: { choices: ["React", "Vue", "Svelte", "Angular"], duration_minutes: 10080, }, }); // Quick 30-minute poll await client.tweets.create("Should I mass-refactor right now?", { poll: { choices: ["Do it", "Don't you dare"], duration_minutes: 30, }, }); ``` #### Duration limits | Min | Max | Default | | --------- | ----------------------- | -------------------- | | 5 minutes | 10 080 minutes (7 days) | 1 440 minutes (24 h) | ### Long tweets You **must** use `tweets.createNote(text, opts?)` to write tweets longer than 260 characters, if your account has Premium. This uses the GraphQL `CreateNoteTweet` mutation. | Option | Type | Description | | -------------------- | ---------- | -------------------------------- | | `text` | `string` | The note tweet text | | `opts.mediaIds` | `string[]` | Array of media IDs to attach | | `opts.replyTo` | `string` | Tweet ID to reply to | | `opts.sensitive` | `boolean` | Mark media as possibly sensitive | | `opts.richtext_tags` | `array` | Rich text formatting tags | | `opts.variables` | `object` | Additional GraphQL variables | ```js const note = await client.tweets.createNote("This is a very long note..."); ``` **Returns:** Parsed tweet object. ## `tweets.createThread(items)` Post a thread (multiple tweets chained as replies). Each item after the first automatically replies to the previous tweet. | Param | Type | Description | | ------- | ---------------------- | ----------------------------------------------------------------- | | `items` | `(string \| object)[]` | Array of 2+ tweets - strings for text-only, objects for full opts | Each object item accepts the same options as `tweets.create()` (`mediaIds`, `poll`, `sensitive`, etc.) plus a `text` field. ```js // Simple text thread const thread = await client.tweets.createThread([ "1/ Here's a mass thread about JavaScript engines", "2/ V8 powers Chrome and Node.js", "3/ SpiderMonkey powers Firefox", "4/ JavaScriptCore powers Safari", ]); // Mixed thread with media and polls const img = await client.media.create("./diagram.png"); const thread = await client.tweets.createThread([ "Let me explain how this works 🧵", { text: "Here's the architecture diagram:", mediaIds: [img.media_id], }, { text: "What do you think?", poll: { choices: ["Love it", "Needs work"] }, }, ]); // Each item in the returned array is a parsed tweet object console.log(thread[0].id); // first tweet console.log(thread[thread.length - 1].id); // last tweet ``` **Returns:** Array of parsed tweet objects, one per tweet in the thread. ## `tweets.delete(tweetId)` Delete a tweet. Uses the GraphQL `DeleteTweet` mutation. ```js await client.tweets.delete("1234567890"); ``` ## `tweets.like(tweetId)` Like a tweet. Uses the GraphQL `FavoriteTweet` mutation. ```js await client.tweets.like("1234567890"); ``` ## `tweets.unlike(tweetId)` Unlike a tweet. Uses the GraphQL `UnfavoriteTweet` mutation. ```js await client.tweets.unlike("1234567890"); ``` ## `tweets.retweet(tweetId)` Retweet a tweet. Uses the GraphQL `CreateRetweet` mutation. ```js await client.tweets.retweet("1234567890"); ``` ## `tweets.unretweet(tweetId)` Undo a retweet. Uses the GraphQL `DeleteRetweet` mutation. ```js await client.tweets.unretweet("1234567890"); ``` ## `tweets.pin(tweetId)` Pin a tweet to your profile. Uses the GraphQL `PinTweet` mutation. ```js await client.tweets.pin("1234567890"); ``` ## `tweets.unpin(tweetId)` Unpin a tweet from your profile. Uses the GraphQL `UnpinTweet` mutation. ```js await client.tweets.unpin("1234567890"); ``` ## `tweets.get(tweetId)` Get a single tweet by ID. Uses the GraphQL `TweetResultByRestId` query. ```js const tweet = await client.tweets.get("1234567890"); console.log(tweet.text); console.log(tweet.user.username); console.log(tweet.stats.likes); ``` **Returns:** Parsed tweet object. ## `tweets.getMany(tweetIds)` Get multiple tweets by their IDs. Uses the GraphQL `TweetResultsByRestIds` query. ```js const tweets = await client.tweets.getMany(["123", "456", "789"]); tweets.forEach((t) => console.log(t.text)); ``` **Returns:** Array of parsed tweet objects. ## `tweets.detail(tweetId, opts?)` Get a tweet with its full conversation thread. Uses the GraphQL `TweetDetail` query. | Option | Type | Description | | ---------------- | -------- | ---------------------------- | | `opts.variables` | `object` | Additional GraphQL variables | ```js const detail = await client.tweets.detail("1234567890"); ``` ## `tweets.editHistory(tweetId)` Get the edit history of a tweet. Uses the GraphQL `TweetEditHistory` query. ```js const history = await client.tweets.editHistory("1234567890"); ``` ## `tweets.retweeters(tweetId, opts?)` Get users who retweeted a tweet. Uses the GraphQL `Retweeters` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const retweeters = await client.tweets.retweeters("1234567890"); ``` ## `tweets.highlight(tweetId)` Highlight a tweet on your profile. Uses the GraphQL `CreateHighlight` mutation. ```js await client.tweets.highlight("1234567890"); ``` ## `tweets.unhighlight(tweetId)` Remove a tweet highlight. Uses the GraphQL `DeleteHighlight` mutation. ```js await client.tweets.unhighlight("1234567890"); ``` ## `tweets.schedule(text, scheduledAt, opts?)` Schedule a tweet for future posting. Uses the GraphQL `CreateScheduledTweet` mutation. | Option | Type | Description | | --------------- | -------------- | --------------------------------- | | `text` | `string` | The tweet text | | `scheduledAt` | `string\|Date` | When to post (ISO string or Date) | | `opts.mediaIds` | `string[]` | Media IDs to attach | | `opts.replyTo` | `string` | Tweet ID to reply to | ```js await client.tweets.schedule("Good morning!", "2025-01-01T09:00:00Z"); await client.tweets.schedule("With media", new Date("2025-06-01"), { mediaIds: ["111222333"], }); ``` ## `tweets.deleteScheduled(scheduledTweetId)` Delete a scheduled tweet. Uses the GraphQL `DeleteScheduledTweet` mutation. ```js await client.tweets.deleteScheduled("9876543210"); ``` ## `tweets.getScheduled()` Fetch all your scheduled tweets. Uses the GraphQL `FetchScheduledTweets` query. ```js const scheduled = await client.tweets.getScheduled(); ``` ## `tweets.moderate(tweetId)` Moderate a tweet (hide it from your replies). Uses the GraphQL `ModerateTweet` mutation. ```js await client.tweets.moderate("1234567890"); ``` ## `tweets.unmoderate(tweetId)` Unmoderate a tweet. Uses the GraphQL `UnmoderateTweet` mutation. ```js await client.tweets.unmoderate("1234567890"); ``` ## `tweets.pinReply(tweetId)` Pin a reply under your tweet. Uses the GraphQL `PinReply` mutation. ```js await client.tweets.pinReply("1234567890"); ``` ## `tweets.unpinReply(tweetId)` Unpin a reply. Uses the GraphQL `UnpinReply` mutation. ```js await client.tweets.unpinReply("1234567890"); ``` ## `tweets.setConversationControl(tweetId, mode)` Change who can reply to your tweet. Uses the GraphQL `ConversationControlChange` mutation. | Mode | Description | | ---------------- | -------------------- | | `"ByInvitation"` | Only mentioned users | | `"Community"` | Only followers | ```js await client.tweets.setConversationControl("1234567890", "ByInvitation"); ``` ## `tweets.removeConversationControl(tweetId)` Remove reply restrictions from a tweet. Uses the GraphQL `ConversationControlDelete` mutation. ```js await client.tweets.removeConversationControl("1234567890"); ``` ## `tweets.unmention(tweetId)` Remove yourself from a conversation. Uses the GraphQL `UnmentionUserFromConversation` mutation. ```js await client.tweets.unmention("1234567890"); ``` ## `tweets.similar(tweetId)` Find similar posts. Uses the GraphQL `SimilarPosts` query. ```js const similar = await client.tweets.similar("1234567890"); ``` ## Tweet schema All methods that return tweets parse them into a consistent format: ```js { id: "1234567890", text: "Hello world!", created_at: "Mon Jan 01 00:00:00 +0000 2025", conversation_id: "1234567890", in_reply_to_status_id: null, in_reply_to_user_id: null, in_reply_to_screen_name: null, user: { /* parsed user object */ }, stats: { retweets: 10, likes: 42, replies: 3, quotes: 1, bookmarks: 5, views: 1000, }, engagement: { retweeted: false, liked: true, bookmarked: false, }, media: [], urls: [], hashtags: [], user_mentions: [], source: "Twitter Web App", lang: "en", quoting: null, // parsed quoted tweet or null edit_control: {}, card: null, misc: { ... }, } ``` ## `tweets.downvote(tweetId)` / `tweets.undoDownvote(tweetId)` Privately downvote a reply (the thumbs-down signal), or undo it. Downvotes only apply to replies. Use the GraphQL `DownvoteTweet` and `UndoDownvoteTweet` mutations. ```js await client.tweets.downvote(replyTweetId); await client.tweets.undoDownvote(replyTweetId); ``` ## `tweets.addContentDisclosure(tweetId, opts?)` / `tweets.removeContentDisclosure(tweetId, opts?)` Add or remove a content disclosure label (e.g. AI-generated / altered media) on your own tweet. Use the GraphQL `AddContentDisclosure` and `DeleteContentDisclosure` mutations. Disclosure details go in `opts.variables`. ```js await client.tweets.addContentDisclosure(tweetId, { variables: { /* disclosure fields */ } }); await client.tweets.removeContentDisclosure(tweetId); ``` --- --- url: /tweets/drafts.md --- # Drafts Create, list, edit, and delete draft tweets. Drafts are private to your account. ## `drafts.list(opts?)` List your draft tweets. Uses the GraphQL `FetchDraftTweets` query. | Option | Type | Description | | ---------------- | --------- | ---------------------------------------- | | `opts.ascending` | `boolean` | Sort order (default `false`, newest first) | ```js const drafts = await client.drafts.list(); // [{ rest_id, tweet_create_request: { status, ... } }, ...] ``` ## `drafts.create(text, opts?)` Save a new draft. Uses the GraphQL `CreateDraftTweet` mutation. | Option | Type | Description | | -------------- | ---------- | --------------------------------------------- | | `text` | `string` | The draft body | | `opts.mediaIds`| `string[]` | Attached media ids (from the media upload flow) | | `opts.replyTo` | `string` | Tweet id this draft replies to | ```js const res = await client.drafts.create("a thought for later"); const draftId = res.data.tweet.rest_id; ``` ## `drafts.edit(draftId, text, opts?)` Replace a draft's content. Same `opts` as `create`. Uses the GraphQL `EditDraftTweet` mutation. ```js await client.drafts.edit(draftId, "a revised thought"); ``` ## `drafts.remove(draftId)` Delete a draft. Uses the GraphQL `DeleteDraftTweet` mutation. ```js await client.drafts.remove(draftId); ``` --- --- url: /tweets/timelines.md --- # Timelines Fetch home timelines and other feed-like content. ## Response format All timeline methods return a parsed object: ```js { tweets: [ /* parsed tweet objects */ ], users: [ /* parsed user objects (when applicable) */ ], nextCursor: "DAABCgAB...", // pass to opts.cursor for the next page previousCursor: "DAABCgAA...", // pass to opts.cursor to go back raw: { /* original Twitter response */ }, } ``` ## `timelines.home(opts?)` Get your home timeline (algorithmic). Uses the GraphQL `HomeTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets, nextCursor } = await client.timelines.home(); console.log(tweets[0].text); // Paginate const page2 = await client.timelines.home({ cursor: nextCursor }); // Fetch more const big = await client.timelines.home({ count: 50 }); ``` ## `timelines.homeLatest(opts?)` Get your home timeline sorted by latest (chronological). Uses the GraphQL `HomeLatestTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.timelines.homeLatest(); ``` ## `timelines.connect(opts?)` Get the "Connect" tab timeline (who to follow suggestions, etc.). Uses the GraphQL `ConnectTabTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { users } = await client.timelines.connect(); ``` ## `timelines.moderated(opts?)` Get the moderated timeline (tweets hidden from your profile). Uses the GraphQL `ModeratedTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.timelines.moderated(); ``` ## `timelines.creatorSubscriptions(opts?)` Get the creator subscriptions timeline. Uses the GraphQL `CreatorSubscriptionsTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.timelines.creatorSubscriptions(); ``` ## Pagination All timeline methods return `nextCursor` and `previousCursor` automatically. Pass them as `opts.cursor`: ```js // First page const page1 = await client.timelines.home({ count: 20 }); console.log(page1.tweets); // Next page const page2 = await client.timelines.home({ cursor: page1.nextCursor }); // Collect multiple pages const allTweets = []; let cursor; for (let i = 0; i < 5; i++) { const page = await client.timelines.home({ count: 20, cursor }); allTweets.push(...page.tweets); cursor = page.nextCursor; if (!cursor) break; } ``` ## `timelines.updatePinned(pinnedTimelineItems)` Set the pinned timelines shown as tabs on your home screen (lists, communities, etc.). Uses the GraphQL `UpdatePinnedTimelines` mutation. Pass the full ordered array of pinned timeline items. ```js await client.timelines.updatePinned([ { /* pinned timeline item */ }, ]); ``` --- --- url: /tweets/bookmarks.md --- # Bookmarks Bookmark tweets, manage bookmark folders, and search your bookmarks. ## `bookmarks.create(tweetId)` Bookmark a tweet. Uses the GraphQL `CreateBookmark` mutation. ```js await client.bookmarks.create("1234567890"); ``` ## `bookmarks.delete(tweetId)` Remove a tweet from your bookmarks. Uses the GraphQL `DeleteBookmark` mutation. ```js await client.bookmarks.delete("1234567890"); ``` ## `bookmarks.deleteAll()` Delete all your bookmarks. Uses the GraphQL `BookmarksAllDelete` mutation. ```js await client.bookmarks.deleteAll(); ``` ::: warning This action is irreversible. All bookmarks will be permanently removed. ::: ## `bookmarks.get(opts?)` Fetch your bookmarked tweets. Uses the GraphQL `Bookmarks` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets, nextCursor } = await client.bookmarks.get(); console.log(tweets[0].text); // Paginate const page2 = await client.bookmarks.get({ cursor: nextCursor }); // Fetch more const more = await client.bookmarks.get({ count: 50 }); ``` ## `bookmarks.search(query, opts?)` Search within your bookmarks. Uses the GraphQL `BookmarkSearchTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.bookmarks.search("javascript"); ``` ## `bookmarks.folders()` Get all your bookmark folders. Uses the GraphQL `BookmarkFoldersSlice` query. ```js const folders = await client.bookmarks.folders(); ``` ## `bookmarks.createFolder(name)` Create a new bookmark folder. Uses the GraphQL `createBookmarkFolder` mutation. ```js await client.bookmarks.createFolder("Read Later"); ``` ## `bookmarks.deleteFolder(folderId)` Delete a bookmark folder. Uses the GraphQL `DeleteBookmarkFolder` mutation. ```js await client.bookmarks.deleteFolder("1234567890"); ``` ## `bookmarks.editFolder(folderId, name)` Rename a bookmark folder. Uses the GraphQL `EditBookmarkFolder` mutation. ```js await client.bookmarks.editFolder("1234567890", "New Folder Name"); ``` ## `bookmarks.addToFolder(tweetId, folderId)` Add a bookmarked tweet to a specific folder. Uses the GraphQL `bookmarkTweetToFolder` mutation. ```js await client.bookmarks.addToFolder("9876543210", "1234567890"); ``` ## `bookmarks.removeFromFolder(tweetId, folderId)` Remove a tweet from a bookmark folder. Uses the GraphQL `RemoveTweetFromBookmarkFolder` mutation. ```js await client.bookmarks.removeFromFolder("9876543210", "1234567890"); ``` ## `bookmarks.folderTimeline(folderId, opts?)` Get bookmarked tweets within a specific folder. Uses the GraphQL `BookmarkFolderTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `folderId` | `string` | The bookmark folder ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets, nextCursor } = await client.bookmarks.folderTimeline("1234567890"); // Paginate const page2 = await client.bookmarks.folderTimeline("1234567890", { count: 40, cursor: nextCursor, }); ``` ## Full example ```js // Bookmark a tweet await client.bookmarks.create("1234567890"); // Create a folder and organize await client.bookmarks.createFolder("Tech"); const folders = await client.bookmarks.folders(); const techFolder = folders; // extract folder ID from response // Add the tweet to the folder await client.bookmarks.addToFolder("1234567890", techFolderId); // Browse folder contents const { tweets } = await client.bookmarks.folderTimeline(techFolderId); // Search bookmarks const { tweets: results } = await client.bookmarks.search("machine learning"); // Clean up await client.bookmarks.removeFromFolder("1234567890", techFolderId); await client.bookmarks.delete("1234567890"); await client.bookmarks.deleteFolder(techFolderId); ``` --- --- url: /tweets/media.md --- # Media Upload images, GIFs, and videos, then attach them to tweets. ## `media.create(source, opts?)` Upload media to Twitter. Returns a `media_id` you can pass to [`tweets.create()`](./tweets#tweets-create-text-opts). | Param | Type | Description | | ---------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- | | `source` | `string \| Buffer \| Uint8Array \| ArrayBuffer \| Blob` | A **file path**, `Buffer`, typed array, or `Blob` containing the media | | `opts.alt_text` | `string` | Alt text for accessibility (applied automatically after upload) | | `opts.mediaType` | `string` | Explicit MIME type (e.g. `"image/png"`). Auto-detected when omitted | | `opts.type` | `"tweet" \| "dm"` | Upload context - defaults to `"tweet"`. Use `"dm"` for direct messages | **Returns:** `{ media_id, ... }` - use `media_id` with `tweets.create()`. **Auto-detected types:** JPEG, PNG, GIF, WebP, MP4, WebM. If detection fails, pass `opts.mediaType` explicitly. ### Size limits | Type | Max size | | ----- | -------- | | Image | 5 MB | | GIF | 15 MB | | Video | 512 MB | ### Examples ```js import { readFileSync } from "fs"; // Upload from a file path const img = await client.media.create("./photo.jpg"); const tweet = await client.tweets.create("Check this out!", { mediaIds: [img.media_id], }); // Upload a Buffer with alt text const buf = readFileSync("./chart.png"); const chart = await client.media.create(buf, { alt_text: "A bar chart showing monthly revenue growth", }); await client.tweets.create("Q4 results 📊", { mediaIds: [chart.media_id], }); // Upload a video const video = await client.media.create("./clip.mp4"); await client.tweets.create("Watch this!", { mediaIds: [video.media_id], }); // Upload a Blob (e.g. from fetch) const response = await fetch("https://example.com/image.png"); const blob = await response.blob(); const uploaded = await client.media.create(blob); // Upload for a DM const dm = await client.media.create("./sticker.gif", { type: "dm" }); // Explicit MIME type const raw = await client.media.create(someBuffer, { mediaType: "image/webp", }); ``` ### Upload multiple media ```js const [a, b, c] = await Promise.all([ client.media.create("./pic1.jpg", { alt_text: "First photo" }), client.media.create("./pic2.jpg", { alt_text: "Second photo" }), client.media.create("./pic3.jpg", { alt_text: "Third photo" }), ]); await client.tweets.create("Photo dump 🧵", { mediaIds: [a.media_id, b.media_id, c.media_id], }); ``` ::: tip Always add `alt_text`! It makes your media accessible to users who rely on screen readers - and it only takes one extra option. ::: ## `media.createFromUrl(url, opts?)` Upload a GIF (or other media) from a URL. Twitter fetches the file directly from the URL - no chunked upload needed. Returns a `media_id` you can pass to [`tweets.create()`](./tweets#tweets-create-text-opts). This is used internally by [`tweets.create({ gif })`](./tweets#gifs) but can also be called directly for more control. | Param | Type | Description | | -------------------- | ----------------- | -------------------------------------------------------------------------------------------- | | `url` | `string` | The URL of the media to upload (e.g. a Giphy/Tenor GIF URL) | | `opts.mediaType` | `string` | MIME type (default `"image/gif"`) | | `opts.mediaCategory` | `string` | Upload category (default auto-detected, e.g. `"tweet_gif"`) | | `opts.type` | `"tweet" \| "dm"` | Upload context - defaults to `"tweet"`. Use `"dm"` for direct messages | | `opts.altText` | `string` | Alt text for accessibility | | `opts.origin` | `object` | Provider origin info: `{ provider, id }` (e.g. `{ provider: "giphy", id: "xSM46ernAUN3y" }`) | **Returns:** `{ media_id, ... }` - use `media_id` with `tweets.create()`. ### Examples ```js // Upload a GIF from a Giphy URL const result = await client.media.createFromUrl( "https://media4.giphy.com/media/xSM46ernAUN3y/giphy.gif", { altText: "Happy If You Say So GIF", origin: { provider: "giphy", id: "xSM46ernAUN3y" }, }, ); await client.tweets.create("Check this out!", { mediaIds: [result.media_id], }); // Upload a Tenor GIF const tenor = await client.media.createFromUrl( "https://media.tenor.com/zlRxesGf5u4AAAAC/charlie-kirk-charlie.gif", { altText: "Charlie Kirk Aura GIF", origin: { provider: "riffsy", id: "14867633041905280750" }, }, ); // Use with a GIF search result const { items } = await client.search.gifs("celebration"); const uploaded = await client.media.createFromUrl(items[0].original_image.url, { altText: items[0].alt_text, origin: items[0].found_media_origin, }); await client.tweets.create("🎉", { mediaIds: [uploaded.media_id] }); ``` ::: tip We recommend using the [`gif` option on `tweets.create()`](./tweets#gifs) instead as it handles the upload automatically. ::: ## `media.createMetadata(mediaId, altText, opts?)` Set alt text and metadata for an already-uploaded media item. Uses the v1.1 `media/metadata/create` endpoint. ::: tip `media.create()` will do this for you when passing an `alt_text` ::: | Param | Type | Description | | --------- | -------- | ----------------------------------- | | `mediaId` | `string` | The media ID returned from upload | | `altText` | `string` | Alt text description for the media | | `opts` | `object` | Additional metadata fields to merge | ```js await client.media.createMetadata("1234567890", "A photo of a sunset over the ocean"); ``` ## `media.createSubtitles(mediaId, subtitles)` Attach subtitles / captions to a video. Uses the v1.1 `media/subtitles/create` endpoint. | Param | Type | Description | | ----------- | ----------------- | ---------------------------------------------- | | `mediaId` | `string` | The video media ID | | `subtitles` | `object \| array` | A subtitle object or array of subtitle objects | Each subtitle object: | Field | Type | Description | | --------------- | -------- | ------------------------------- | | `media_id` | `string` | Media ID of the subtitle file | | `language_code` | `string` | Language code (e.g. `"en"`) | | `display_name` | `string` | Display name (e.g. `"English"`) | ```js // Single subtitle track await client.media.createSubtitles("1234567890", { media_id: "9876543210", language_code: "en", display_name: "English", }); // Multiple subtitle tracks await client.media.createSubtitles("1234567890", [ { media_id: "9876543210", language_code: "en", display_name: "English" }, { media_id: "1111111111", language_code: "es", display_name: "Español" }, ]); ``` --- --- url: /tweets/articles.md --- # Articles Read and manage X Articles (long-form posts). ::: warning Creating, editing, and publishing articles requires an eligible X Premium account. The read methods (`get`, `byUser`, `published`, `drafts`) work on any account. The write methods take `content_state`, which is the article editor's rich-text content state (Draft.js raw content state). emusks forwards it to X unchanged rather than constructing it for you. ::: ## `articles.get(articleEntityId)` Fetch a single article by its entity id. Uses the GraphQL `ArticleEntityResultByRestId` query. ```js const article = await client.articles.get("1234567890"); ``` ## `articles.byUser(userId, opts?)` Get a user's published articles as a parsed timeline. Uses the GraphQL `UserArticlesTweets` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `userId` | `string` | The user id | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.raw` | `boolean`| Return the raw GraphQL response instead of a parsed timeline | ```js const timeline = await client.articles.byUser("44196397"); ``` ## `articles.published(userId, opts?)` / `articles.drafts(userId, opts?)` List a user's articles by lifecycle. Both wrap the GraphQL `ArticleEntitiesSlice` query (`slice(userId, lifecycle)`). ```js const published = await client.articles.published("44196397"); const drafts = await client.articles.drafts(myUserId); ``` ## `articles.createDraft(contentState, opts?)` Create a new draft article. Uses the GraphQL `ArticleEntityDraftCreate` mutation. ```js const draft = await client.articles.createDraft(contentState); ``` ## `articles.updateTitle(articleEntityId, title)` Update an article's title. Uses the GraphQL `ArticleEntityUpdateTitle` mutation. ```js await client.articles.updateTitle(articleEntityId, "My headline"); ``` ## `articles.updateContent(articleEntityId, contentState)` Replace an article's body. Uses the GraphQL `ArticleEntityUpdateContent` mutation. ```js await client.articles.updateContent(articleEntityId, contentState); ``` ## `articles.updateCoverMedia(articleEntityId, coverMediaId)` Set the cover image. Pass a media id from the [media upload](/tweets/media) flow. Uses the GraphQL `ArticleEntityUpdateCoverMedia` mutation. ```js await client.articles.updateCoverMedia(articleEntityId, mediaId); ``` ## `articles.publish(articleEntityId)` / `articles.unpublish(articleEntityId)` Publish or unpublish an article. Use the GraphQL `ArticleEntityPublish` and `ArticleEntityUnpublish` mutations. ```js await client.articles.publish(articleEntityId); await client.articles.unpublish(articleEntityId); ``` ## `articles.remove(articleEntityId)` Delete an article. Uses the GraphQL `ArticleEntityDelete` mutation. ```js await client.articles.remove(articleEntityId); ``` --- --- url: /tweets/notes.md --- # Community Notes Read and contribute to Community Notes (the system formerly called Birdwatch). All read methods work on any account; contributing requires an enrolled Community Notes account. ::: warning The write methods (`create`, `rate`, `appeal`, alias/settings, admin) require an enrolled Community Notes account. `notes.create` takes `data_v1`, the note content payload (text, classification, and believability/misleading tags); emusks forwards it to X unchanged. The write paths were not executed end-to-end during testing. ::: ## Reading ### `notes.forTweet(tweetId)` Get the Community Notes on a tweet. Uses the GraphQL `BirdwatchFetchNotes` query. ```js const result = await client.notes.forTweet("1962559135531504125"); ``` ### `notes.get(noteId)` Fetch a single note by id. Uses the GraphQL `BirdwatchFetchOneNote` query. ```js const note = await client.notes.get("2062655324432240669"); console.log(note.appeal_status, note.birdwatch_profile?.alias); ``` ### `notes.translation(noteId, opts?)` Get a note's translation. Uses the GraphQL `BirdwatchFetchNoteTranslation` query. ### `notes.globalTimeline(opts?)` The global Community Notes timeline (recently noted tweets). Uses the GraphQL `BirdwatchFetchGlobalTimeline` query. ```js const { viewer } = await client.notes.globalTimeline(); ``` ### `notes.myProfile()` / `notes.profile(alias)` / `notes.contributorNotes(alias, opts?)` Your own Community Notes profile, another contributor's profile by alias, and a contributor's notes. Use `BirdwatchFetchAuthenticatedUserProfile`, `BirdwatchFetchBirdwatchProfile`, and `BirdwatchFetchContributorNotesSlice`. ```js const me = await client.notes.myProfile(); const them = await client.notes.profile("logical-raccoon"); const theirNotes = await client.notes.contributorNotes("logical-raccoon"); ``` ### `notes.batSignal(tweetId)` Whether a tweet has an open request for a note ("bat signal"). Uses `BirdwatchFetchBatSignal`. ### `notes.canBeMediaNote(tweetId)` / `notes.mediaMatches(tweetId, opts?)` / `notes.prominentMediaMatches(tweetId, opts?)` Media-note eligibility and notes matched to the same media. Use `BirdwatchFetchCanTweetBeMediaNote`, `BirdwatchFetchMediaMatchSlice`, `BirdwatchFetchProminentMediaMatchSlice`. ### `notes.sourceLinks(tweetId, opts?)` / `notes.sourceLinkTweet(tweetId)` Source links cited in notes for a tweet. Use `BirdwatchFetchSourceLinkSlice` and `BirdwatchFetchSourceLinkTweet`. ### `notes.clusterData(tweetId)` / `notes.authenticatedMatch(noteId)` / `notes.suggestionFeedback(suggestionId)` Cluster data, your match status for a note, and suggestion feedback. Use `BirdwatchFetchClusterData`, `BirdwatchFetchAuthenticatedBirdwatchMatchSlice`, `BirdwatchFetchSuggestionFeedbackOverview`. ### `notes.aliasOptions()` / `notes.aliasStatus()` / `notes.signUpEligibility()` / `notes.publicData()` Alias self-select options/status, your sign-up eligibility, and the public data file bundle. Use `BirdwatchFetchAliasSelfSelectOptions`, `BirdwatchFetchAliasSelfSelectStatus`, `BirdwatchFetchSignUpEligiblity`, `BirdwatchFetchPublicData`. ```js const elig = await client.notes.signUpEligibility(); console.log(elig.eligible); ``` ## Contributing ### `notes.create(tweetId, dataV1, opts?)` Write a note on a tweet. Uses the GraphQL `BirdwatchCreateNote` mutation. `dataV1` is the note content payload. ```js await client.notes.create(tweetId, dataV1); ``` ### `notes.remove(noteId)` Delete one of your notes. Uses `BirdwatchDeleteNote`. ### `notes.rate(noteId, rating?)` / `notes.deleteRating(noteId)` Rate a note helpful/not-helpful, or remove your rating. Use `BirdwatchCreateRating` and `BirdwatchDeleteRating`. `rating` is merged into the mutation variables. ```js await client.notes.rate(noteId, { helpful_tags: ["Clear", "Informative"] }); ``` ### `notes.appeal(noteId, opts?)` Appeal a note's status. Uses `BirdwatchCreateAppeal`. ### `notes.request(tweetId)` / `notes.deleteRequest(tweetId)` Request a note on a tweet (raise the "bat signal"), or withdraw it. Use `BirdwatchCreateBatSignal` and `BirdwatchDeleteBatSignal`. ### `notes.selectAlias(opts?)` / `notes.editUserSettings(params)` / `notes.editNotificationSettings(params)` / `notes.acknowledgeEarnOut()` Manage your contributor alias and settings. Use `BirdwatchAliasSelect`, `BirdwatchEditUserSettings`, `BirdwatchEditNotificationSettings`, `BirdwatchProfileAcknowledgeEarnOut`. ### `notes.admitUser(opts?)` / `notes.removeUser(opts?)` Admin moderation actions (require the relevant permissions). Use `BirdwatchAdmitUser` and `BirdwatchRemoveUser`. --- --- url: /users/users.md --- # Users Get user profiles, follow, block, mute, manage relationships, and fetch user content. ## `users.get(userId)` Get a user by their REST ID. Uses the GraphQL `UserByRestId` query. ```js const user = await client.users.get("44196397"); console.log(user.name); // "Elon Musk" console.log(user.username); // "elonmusk" ``` **Returns:** Parsed user object. ## `users.getByUsername(username)` Get a user by their screen name. Uses the GraphQL `UserByScreenName` query. ```js const user = await client.users.getByUsername("elonmusk"); console.log(user.id); console.log(user.stats.followers.count); ``` **Returns:** Parsed user object. ## `users.getMany(userIds)` Get multiple users by their REST IDs. Uses the GraphQL `UsersByRestIds` query. ```js const users = await client.users.getMany(["44196397", "12"]); users.forEach((u) => console.log(u.username)); ``` **Returns:** Array of parsed user objects. ## `users.getManyByUsername(screenNames)` Get multiple users by their screen names. Uses the GraphQL `UsersByScreenNames` query. ```js const users = await client.users.getManyByUsername(["elonmusk", "jack"]); users.forEach((u) => console.log(u.id)); ``` **Returns:** Array of parsed user objects. ## User content Fetch a user's tweets, replies, media, and highlights. ### `users.tweets(userId, opts?)` Get a user's tweets. Uses the GraphQL `UserTweets` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets, nextCursor } = await client.users.tweets("44196397"); console.log(tweets[0].text); // Paginate const page2 = await client.users.tweets("44196397", { cursor: nextCursor }); ``` ### `users.replies(userId, opts?)` Get a user's tweets and replies. Uses the GraphQL `UserTweetsAndReplies` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.users.replies("44196397"); ``` ### `users.media(userId, opts?)` Get a user's media tweets. Uses the GraphQL `UserMedia` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.users.media("44196397", { count: 40 }); ``` ### `users.highlights(userId, opts?)` Get a user's highlighted tweets. Uses the GraphQL `UserHighlightsTweets` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.users.highlights("44196397"); ``` ## Relationships ### `users.followers(userId, opts?)` Get a user's followers. Uses the GraphQL `Followers` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users, nextCursor } = await client.users.followers("44196397", { count: 50, }); ``` ### `users.following(userId, opts?)` Get users that a user is following. Uses the GraphQL `Following` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.following("44196397"); ``` ### `users.verifiedFollowers(userId, opts?)` Get a user's verified (Blue) followers. Uses the GraphQL `BlueVerifiedFollowers` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.verifiedFollowers("44196397"); ``` ### `users.followersYouKnow(userId, opts?)` Get followers of a user that you also follow. Uses the GraphQL `FollowersYouKnow` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.followersYouKnow("44196397"); ``` ### `users.follow(userId)` Follow a user. Uses the v1.1 `friendships/create` endpoint. ```js const user = await client.users.follow("44196397"); console.log(user.username); // "elonmusk" ``` **Returns:** Parsed user object. ### `users.unfollow(userId)` Unfollow a user. Uses the v1.1 `friendships/destroy` endpoint. ```js await client.users.unfollow("44196397"); ``` **Returns:** Parsed user object. ### `users.block(userId)` Block a user. Uses the v1.1 `blocks/create` endpoint. ```js await client.users.block("44196397"); ``` **Returns:** Parsed user object. ### `users.unblock(userId)` Unblock a user. Uses the v1.1 `blocks/destroy` endpoint. ```js await client.users.unblock("44196397"); ``` **Returns:** Parsed user object. ### `users.mute(userId)` Mute a user. Uses the v1.1 `mutes/users/create` endpoint. ```js await client.users.mute("44196397"); ``` **Returns:** Parsed user object. ### `users.unmute(userId)` Unmute a user. Uses the v1.1 `mutes/users/destroy` endpoint. ```js await client.users.unmute("44196397"); ``` **Returns:** Parsed user object. ### `users.removeFollower(userId)` Remove a user from your followers. Uses the GraphQL `RemoveFollower` mutation. ```js await client.users.removeFollower("44196397"); ``` ### `users.blocked(opts?)` Get all accounts you have blocked. Uses the GraphQL `BlockedAccountsAll` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.blocked({ count: 100 }); ``` ### `users.muted(opts?)` Get all accounts you have muted. Uses the GraphQL `MutedAccounts` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.muted(); ``` ## Profile management ### `users.lookup(params?)` Look up users via the v1.1 `users/lookup` endpoint. Supports lookup by `user_id` or `screen_name`. ```js // By user IDs const users = await client.users.lookup({ user_id: "44196397,12" }); // By screen names const users = await client.users.lookup({ screen_name: "elonmusk,jack" }); ``` ### `users.updateProfile(params?)` Update your profile. Uses the v1.1 `account/update_profile` endpoint. | Param | Type | Description | | ------------- | -------- | ------------ | | `name` | `string` | Display name | | `description` | `string` | Bio | | `location` | `string` | Location | | `url` | `string` | Website URL | ```js const updated = await client.users.updateProfile({ name: "New Name", description: "Updated bio", location: "San Francisco, CA", url: "https://example.com", }); ``` **Returns:** Parsed user object. ### `users.updateProfileImage(imageData)` Update your profile picture. Uses the v1.1 `account/update_profile_image` endpoint. ```js await client.users.updateProfileImage(base64ImageData); ``` ### `users.updateProfileBanner(bannerData)` Update your profile banner. Uses the v1.1 `account/update_profile_banner` endpoint. ```js await client.users.updateProfileBanner(base64BannerData); ``` ### `users.removeProfileBanner()` Remove your profile banner. Uses the v1.1 `account/remove_profile_banner` endpoint. ```js await client.users.removeProfileBanner(); ``` ## Subscriptions ### `users.subscriptions(userId, opts?)` Get a user's creator subscriptions. Uses the GraphQL `UserCreatorSubscriptions` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.subscriptions("44196397"); ``` ### `users.subscribers(userId, opts?)` Get a user's creator subscribers. Uses the GraphQL `UserCreatorSubscribers` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.subscribers("44196397"); ``` ### `users.superFollowers(opts?)` Get your super followers. Uses the GraphQL `SuperFollowers` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | ```js const { users } = await client.users.superFollowers(); ``` ### `users.recommendations(params?)` Get user recommendations. Uses the v1.1 `users/recommendations` endpoint. ```js const recs = await client.users.recommendations(); ``` ## Paginated response format All paginated methods (`tweets`, `replies`, `media`, `highlights`, `followers`, `following`, `blocked`, `muted`, etc.) return: ```js { tweets: [ /* parsed tweet objects */ ], users: [ /* parsed user objects */ ], nextCursor: "DAABCgAB...", previousCursor: "DAABCgAA...", raw: { /* original Twitter response */ }, } ``` Content methods populate `tweets`, relationship methods populate `users`. Pass `nextCursor` as `opts.cursor` to fetch the next page. ## User schema All methods that return users parse them into a consistent format: ```js { id: "44196397", name: "Elon Musk", username: "elonmusk", description: "...", banner: "https://pbs.twimg.com/...", url: "https://t.co/...", location: "Earth", protected: false, created_at: "Tue Jun 02 20:12:29 +0000 2009", backgroundColor: "...", profile_picture: { url: "https://pbs.twimg.com/...", shape: "Circle", }, stats: { followers: { count: 200000000, fast_followers: 0, normal_followers: 200000000, }, following: 900, subscriptions_count: 0, likes: 50000, listed: 150000, media: 2000, posts: 40000, }, verification: { verified: false, premium_verified: true, }, pinned_tweets: ["1234567890"], birthdate: {}, labels: { parody_commentary_fan_label: "None", highlightedLabel: undefined, }, misc: { ... }, } ``` --- --- url: /users/direct-messages.md --- # Direct messages Manage your DMs: inbox, conversations, search, blocking, and more. These are the old unencrypted DMs, NOT [XChat](/xchat/). ## `dms.inbox(params?)` Get your DM inbox initial state. Uses the v1.1 `dm/inbox_initial_state` endpoint. ```js const inbox = await client.dms.inbox(); ``` ## `dms.conversation(conversationId, params?)` Get a specific DM conversation by ID. Uses the v1.1 `dm/conversation` endpoint. ```js const convo = await client.dms.conversation("1234567890-9876543210"); ``` ## `dms.search(query, opts?)` Search across all DM conversations. Uses the GraphQL `DmAllSearchSlice` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const results = await client.dms.search("meeting"); ``` ## `dms.searchGroups(query, opts?)` Search group DM conversations. Uses the GraphQL `DmGroupSearchSlice` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const groups = await client.dms.searchGroups("project"); ``` ## `dms.searchPeople(query, opts?)` Search for people in DMs. Uses the GraphQL `DmPeopleSearchSlice` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const people = await client.dms.searchPeople("john"); ``` ## `dms.block(userId)` Block a user from sending you DMs. Uses the GraphQL `dmBlockUser` mutation. ```js await client.dms.block("44196397"); ``` ## `dms.unblock(userId)` Unblock a user in DMs. Uses the GraphQL `dmUnblockUser` mutation. ```js await client.dms.unblock("44196397"); ``` ## `dms.deleteConversations(conversationIds)` Delete one or more DM conversations. Uses the v1.1 `dm/conversation/bulk_delete` endpoint. ```js // Single conversation await client.dms.deleteConversations("1234567890-9876543210"); // Multiple conversations await client.dms.deleteConversations(["1234567890-9876543210", "1111111111-2222222222"]); ``` ## `dms.updateLastSeen(eventId)` Mark messages as read up to a given event ID. Uses the v1.1 `dm/update_last_seen_event_id` endpoint. ```js await client.dms.updateLastSeen("1234567890123456789"); ``` ## `dms.muted(opts?)` Get your muted DM conversations. Uses the GraphQL `DmMutedTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const muted = await client.dms.muted(); ``` ## `dms.edit(messageId, conversationId, text)` Edit a DM message. Uses the v1.1 `dm/edit` endpoint. | Param | Type | Description | | ---------------- | -------- | ------------------------ | | `messageId` | `string` | The message ID to edit | | `conversationId` | `string` | The conversation ID | | `text` | `string` | New text for the message | ```js await client.dms.edit("111222333", "1234567890-9876543210", "Updated message"); ``` ## `dms.permissions(params?)` Check DM permissions. Uses the v1.1 `dm/permissions` endpoint. ```js const perms = await client.dms.permissions(); ``` ## `dms.nsfwFilter(enabled)` Toggle the NSFW media filter for DMs. Uses the GraphQL `DmNsfwMediaFilterUpdate` mutation. ```js // Enable filter await client.dms.nsfwFilter(true); // Disable filter await client.dms.nsfwFilter(false); ``` ## `dms.updateRelationship(userId, action)` Update your DM relationship state with a user. Uses the v1.1 `dm/user/update_relationship_state` endpoint. ```js await client.dms.updateRelationship("44196397", "trust"); ``` ## `dms.reportSpam(conversationId, messageId)` Report a DM as spam. Uses the v1.1 `direct_messages/report_spam` endpoint. ```js await client.dms.reportSpam("1234567890-9876543210", "111222333"); ``` ## `dms.report(conversationId, messageId)` Report a DM message. Uses the v1.1 `dm/report` endpoint. ```js await client.dms.report("1234567890-9876543210", "111222333"); ``` ## `dms.userUpdates(params?)` Get DM user updates. Uses the v1.1 `dm/user_updates` endpoint. ```js const updates = await client.dms.userUpdates(); ``` ## Full example ```js // Check your inbox const inbox = await client.dms.inbox(); // Search for a conversation const results = await client.dms.search("hello"); // Read a specific conversation const convo = await client.dms.conversation("1234567890-9876543210"); // Edit a message await client.dms.edit("111222333", "1234567890-9876543210", "corrected text"); // Block a spammer in DMs await client.dms.block("9999999999"); // Check your muted conversations const muted = await client.dms.muted(); // Clean up old conversations await client.dms.deleteConversations(["1234567890-9876543210", "1111111111-2222222222"]); ``` --- --- url: /users/lists.md --- # Lists Create, manage, and interact with Twitter/X lists. ## `lists.create(name, opts?)` Create a new list. Uses the GraphQL `CreateList` mutation. | Option | Type | Description | | ------------------ | --------- | ------------------------------------- | | `name` | `string` | The list name | | `opts.private` | `boolean` | Make the list private (default false) | | `opts.description` | `string` | List description | ```js await client.lists.create("Tech News"); // Private list with description await client.lists.create("My Favorites", { private: true, description: "People I follow closely", }); ``` ## `lists.delete(listId)` Delete a list you own. Uses the GraphQL `DeleteList` mutation. ```js await client.lists.delete("1234567890"); ``` ## `lists.update(listId, opts?)` Update a list's name, description, or visibility. Uses the GraphQL `UpdateList` mutation. | Option | Type | Description | | ------------------ | --------- | ----------------- | | `listId` | `string` | The list ID | | `opts.name` | `string` | New name | | `opts.description` | `string` | New description | | `opts.private` | `boolean` | Change visibility | ```js await client.lists.update("1234567890", { name: "Renamed List", description: "Updated description", private: false, }); ``` ## `lists.get(listId)` Get a list by its REST ID. Uses the GraphQL `ListByRestId` query. ```js const list = await client.lists.get("1234567890"); ``` ## `lists.getBySlug(slug, opts?)` Get a list by its slug and owner screen name. Uses the GraphQL `ListBySlug` query. | Option | Type | Description | | ---------------------- | -------- | ------------------------- | | `slug` | `string` | The list slug | | `opts.ownerScreenName` | `string` | The list owner's username | ```js const list = await client.lists.getBySlug("tech-news", { ownerScreenName: "elonmusk", }); ``` ## `lists.addMember(listId, userId)` Add a user to a list. Uses the GraphQL `ListAddMember` mutation. ```js await client.lists.addMember("1234567890", "44196397"); ``` ## `lists.removeMember(listId, userId)` Remove a user from a list. Uses the GraphQL `ListRemoveMember` mutation. ```js await client.lists.removeMember("1234567890", "44196397"); ``` ## `lists.members(listId, opts?)` Get the members of a list. Uses the GraphQL `ListMembers` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const members = await client.lists.members("1234567890", { count: 50 }); ``` ## `lists.subscribers(listId, opts?)` Get users who have subscribed to a list. Uses the GraphQL `ListSubscribers` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const subs = await client.lists.subscribers("1234567890"); ``` ## `lists.subscribe(listId)` Subscribe to a list. Uses the GraphQL `ListSubscribe` mutation. ```js await client.lists.subscribe("1234567890"); ``` ## `lists.unsubscribe(listId)` Unsubscribe from a list. Uses the GraphQL `ListUnsubscribe` mutation. ```js await client.lists.unsubscribe("1234567890"); ``` ## `lists.mute(listId)` Mute a list (hide its tweets from your timeline). Uses the GraphQL `MuteList` mutation. ```js await client.lists.mute("1234567890"); ``` ## `lists.unmute(listId)` Unmute a list. Uses the GraphQL `UnmuteList` mutation. ```js await client.lists.unmute("1234567890"); ``` ## `lists.timeline(listId, opts?)` Get the latest tweets from a list. Uses the GraphQL `ListLatestTweetsTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const tweets = await client.lists.timeline("1234567890"); // Paginate const next = await client.lists.timeline("1234567890", { cursor: "DAABCgAB..." }); ``` ## `lists.ranked(listId, opts?)` Get ranked (algorithmic) tweets from a list. Uses the GraphQL `ListRankedTweetsTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const ranked = await client.lists.ranked("1234567890"); ``` ## `lists.search(listId, query, opts?)` Search tweets within a list. Uses the GraphQL `ListSearchTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `listId` | `string` | The list ID | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const results = await client.lists.search("1234567890", "breaking news"); ``` ## `lists.ownerships(userId, opts?)` Get lists owned by a user. Uses the GraphQL `ListOwnerships` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const owned = await client.lists.ownerships("44196397"); ``` ## `lists.memberships(userId, opts?)` Get lists a user is a member of. Uses the GraphQL `ListMemberships` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const memberships = await client.lists.memberships("44196397"); ``` ## `lists.discover(opts?)` Discover suggested lists. Uses the GraphQL `ListsDiscovery` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const suggestions = await client.lists.discover(); ``` ## `lists.combined(opts?)` Get your combined lists view. Uses the GraphQL `CombinedLists` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const all = await client.lists.combined(); ``` ## `lists.manage(opts?)` Get the list management page timeline. Uses the GraphQL `ListsManagementPageTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const managed = await client.lists.manage(); ``` ## `lists.editBanner(listId, mediaId)` Update a list's banner image. Uses the GraphQL `EditListBanner` mutation. ```js await client.lists.editBanner("1234567890", "9876543210"); ``` ## `lists.deleteBanner(listId)` Remove a list's banner image. Uses the GraphQL `DeleteListBanner` mutation. ```js await client.lists.deleteBanner("1234567890"); ``` ## `lists.pinTimeline(timelineId)` Pin a list timeline to your home tabs. Uses the GraphQL `PinTimeline` mutation. ```js await client.lists.pinTimeline("list-1234567890"); ``` ## `lists.unpinTimeline(timelineId)` Unpin a list timeline from your home tabs. Uses the GraphQL `UnpinTimeline` mutation. ```js await client.lists.unpinTimeline("list-1234567890"); ``` ## `lists.pinned()` Get your pinned list timelines. Uses the GraphQL `PinnedTimelines` query. ```js const pinned = await client.lists.pinned(); ``` ## Full example ```js // Create a list await client.lists.create("JavaScript Devs", { description: "Great JS developers to follow", private: false, }); // Add members await client.lists.addMember(listId, "44196397"); await client.lists.addMember(listId, "12"); // Browse the list timeline const tweets = await client.lists.timeline(listId, { count: 30 }); // Search within the list const results = await client.lists.search(listId, "React"); // Pin it to home await client.lists.pinTimeline(`list-${listId}`); // Check who's subscribed const subs = await client.lists.subscribers(listId); // See all your lists const myLists = await client.lists.ownerships(client.user.id); // Discover new lists const suggestions = await client.lists.discover(); // Clean up await client.lists.removeMember(listId, "12"); await client.lists.delete(listId); ``` --- --- url: /users/communities.md --- # Communities Create, join, manage, and moderate Twitter/X communities. ## `communities.create(name, opts?)` Create a new community. Uses the GraphQL `CreateCommunity` mutation. | Option | Type | Description | | ------------------ | -------- | ---------------------------- | | `name` | `string` | The community name | | `opts.description` | `string` | Community description | | `opts.rules` | `array` | Initial community rules | | `opts.variables` | `object` | Additional GraphQL variables | ```js await client.communities.create("Meowing Enthusiasts"); await client.communities.create("Meowing Enthusiasts", { description: "A community to Meow", rules: [{ name: "Be respectful" }, { name: "No spam" }], }); ``` ## `communities.get(communityId)` Get a community by its REST ID. Uses the GraphQL `CommunityByRestId` query. ```js const community = await client.communities.get("1234567890"); ``` ## `communities.join(communityId)` Join a community. Uses the GraphQL `JoinCommunity` mutation. ```js await client.communities.join("1234567890"); ``` ## `communities.leave(communityId)` Leave a community. Uses the GraphQL `LeaveCommunity` mutation. ```js await client.communities.leave("1234567890"); ``` ## `communities.requestJoin(communityId, opts?)` Request to join a community that requires approval. Uses the GraphQL `RequestToJoinCommunity` mutation. | Option | Type | Description | | ------------- | -------- | ---------------------------------- | | `opts.answer` | `string` | Answer to the community's question | ```js await client.communities.requestJoin("1234567890"); await client.communities.requestJoin("1234567890", { answer: "meow!", }); ``` ## `communities.timeline(communityId, opts?)` Get the community's tweet timeline. Uses the GraphQL `CommunityTweetsTimeline` query. | Option | Type | Description | | ------------------ | -------- | -------------------------------------------------- | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.rankingMode` | `string` | `"Recency"` or `"Relevance"` (default `"Recency"`) | | `opts.variables` | `object` | Additional GraphQL variables | ```js const tweets = await client.communities.timeline("1234567890"); // Ranked by relevance const top = await client.communities.timeline("1234567890", { rankingMode: "Relevance", count: 50, }); // Paginate const next = await client.communities.timeline("1234567890", { cursor: "DAABCgAB...", }); ``` ## `communities.media(communityId, opts?)` Get media tweets from a community. Uses the GraphQL `CommunityMediaTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const media = await client.communities.media("1234567890", { count: 40 }); ``` ## `communities.about(communityId, opts?)` Get the community's about timeline. Uses the GraphQL `CommunityAboutTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const about = await client.communities.about("1234567890"); ``` ## `communities.hashtags(communityId, opts?)` Get trending hashtags for a community. Uses the GraphQL `CommunityHashtagsTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const hashtags = await client.communities.hashtags("1234567890"); ``` ## `communities.editName(communityId, name)` Rename a community. Uses the GraphQL `CommunityEditName` mutation. ```js await client.communities.editName("1234567890", "Meowing Community"); ``` ## `communities.editPurpose(communityId, purpose)` Update a community's purpose/description. Uses the GraphQL `CommunityEditPurpose` mutation. ```js await client.communities.editPurpose("1234567890", "billions must meow."); ``` ## `communities.editBanner(communityId, mediaId)` Update a community's banner image. Uses the GraphQL `CommunityEditBannerMedia` mutation. ```js await client.communities.editBanner("1234567890", "9876543210"); ``` ## `communities.removeBanner(communityId)` Remove a community's banner image. Uses the GraphQL `CommunityRemoveBannerMedia` mutation. ```js await client.communities.removeBanner("1234567890"); ``` ## `communities.createRule(communityId, name, opts?)` Add a rule to a community. Uses the GraphQL `CommunityCreateRule` mutation. | Option | Type | Description | | ------------------ | -------- | ---------------- | | `communityId` | `string` | The community ID | | `name` | `string` | Rule name/title | | `opts.description` | `string` | Rule description | ```js await client.communities.createRule("1234567890", "No spam", { description: "Do not post promotional content or repetitive links", }); ``` ## `communities.editRule(communityId, ruleId, name, opts?)` Edit an existing community rule. Uses the GraphQL `CommunityEditRule` mutation. | Option | Type | Description | | ------------------ | -------- | ------------------------ | | `communityId` | `string` | The community ID | | `ruleId` | `string` | The rule ID to edit | | `name` | `string` | Updated rule name | | `opts.description` | `string` | Updated rule description | ```js await client.communities.editRule("1234567890", "rule123", "Be kind", { description: "Treat all members with respect", }); ``` ## `communities.removeRule(communityId, ruleId)` Remove a community rule. Uses the GraphQL `CommunityRemoveRule` mutation. ```js await client.communities.removeRule("1234567890", "rule123"); ``` ## `communities.reorderRules(communityId, ruleIds)` Reorder community rules. Uses the GraphQL `CommunityReorderRules` mutation. ```js await client.communities.reorderRules("1234567890", ["rule3", "rule1", "rule2"]); ``` ## `communities.editQuestion(communityId, question)` Set the join question for a community. Uses the GraphQL `CommunityEditQuestion` mutation. ```js await client.communities.editQuestion("1234567890", "Why do you want to meow?"); ``` ## `communities.updateRole(communityId, userId, role)` Update a member's role within a community. Uses the GraphQL `CommunityUpdateRole` mutation. ```js await client.communities.updateRole("1234567890", "44196397", "Moderator"); ``` ## `communities.invite(communityId, userId)` Invite a user to join a community. Uses the GraphQL `CommunityUserInvite` mutation. ```js await client.communities.invite("1234567890", "44196397"); ``` ## `communities.keepTweet(communityId, tweetId)` Keep a reported tweet in the community (moderation action). Uses the GraphQL `CommunityModerationKeepTweet` mutation. ```js await client.communities.keepTweet("1234567890", "9876543210"); ``` ## `communities.moderationCases(communityId, opts?)` Get pending moderation cases for a community. Uses the GraphQL `CommunityModerationTweetCasesSlice` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const cases = await client.communities.moderationCases("1234567890"); ``` ## `communities.moderationLog(communityId, opts?)` Get the moderation log for a community. Uses the GraphQL `CommunityTweetModerationLogSlice` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const log = await client.communities.moderationLog("1234567890", { count: 50 }); ``` ## `communities.explore(opts?)` Explore communities. Uses the GraphQL `CommunitiesExploreTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const explore = await client.communities.explore(); ``` ## `communities.discover(opts?)` Get community discovery suggestions. Uses the GraphQL `CommunitiesMainDiscoveryModule` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const suggestions = await client.communities.discover(); ``` ## `communities.ranked(opts?)` Get the ranked communities timeline. Uses the GraphQL `CommunitiesRankedTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const ranked = await client.communities.ranked(); ``` ## `communities.memberships(userId, opts?)` Get communities a user is a member of. Uses the GraphQL `CommunitiesMembershipsTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const memberships = await client.communities.memberships("44196397"); ``` ## `communities.memberSearch(communityId, query, opts?)` Search for members within a community. Uses the GraphQL `CommunityMemberRelationshipTypeahead` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `communityId` | `string` | The community ID | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | ```js const members = await client.communities.memberSearch("1234567890", "john"); ``` ## `communities.userSearch(communityId, query, opts?)` Search for users to invite to a community. Uses the GraphQL `CommunityUserRelationshipTypeahead` query. | Option | Type | Description | | ------------- | -------- | ------------------------------ | | `communityId` | `string` | The community ID | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | ```js const users = await client.communities.userSearch("1234567890", "jane"); ``` ## Full example ```js // Explore communities const explore = await client.communities.explore(); // Join a community await client.communities.join("1234567890"); // Browse the timeline const tweets = await client.communities.timeline("1234567890"); // Check media const media = await client.communities.media("1234567890"); // See your memberships const memberships = await client.communities.memberships(client.user.id); // Create your own community await client.communities.create("My Community", { description: "A community for cats", }); // Set up rules await client.communities.createRule(communityId, "Be respectful"); await client.communities.createRule(communityId, "Stay on topic", { description: "Keep discussions relevant to meowing", }); await client.communities.editQuestion(communityId, "Which languages do you use to meow?"); // Invite members await client.communities.invite(communityId, "44196397"); // Moderate const cases = await client.communities.moderationCases(communityId); await client.communities.keepTweet(communityId, tweetId); // Update settings await client.communities.editName(communityId, "Meowing Community"); await client.communities.editPurpose(communityId, "Updated purpose"); // Leave when done await client.communities.leave("1234567890"); ``` ## `communities.home(opts?)` The Communities home page timeline (your communities + recommendations). Uses the GraphQL `CommunitiesMainPageTimeline` query. ```js const home = await client.communities.home(); ``` ## `communities.browse(opts?)` The "discover communities" feed (browse communities to join). Uses the GraphQL `CommunityDiscoveryTimeline` query. ```js const discover = await client.communities.browse(); ``` ## `communities.myMemberships(opts?)` The communities the authenticated account belongs to. Uses the GraphQL `CommunitiesMembershipsSlice` query. (For another user's memberships, use [`communities.memberships(userId)`](#communities-memberships-userid-opts).) ```js const mine = await client.communities.myMemberships(); ``` ## `communities.publicTimeline(communityId, opts?)` A community's tweets via the logged-out / public timeline (no membership required, for communities that allow public viewing). Uses the GraphQL `CommunityTweetsLoggedOutTimeline` query. | Option | Type | Description | | ------------------ | -------- | -------------------------------------- | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.rankingMode` | `string` | `"Recency"` (default) or `"Relevance"` | ```js const tweets = await client.communities.publicTimeline("1453877367030484992"); ``` ## `communities.publicRanked(communityId, opts?)` The ranked ("Top") public view of a community's tweets. Uses the GraphQL `CommunityTweetsRankedLoggedOutTimeline` query. ```js const top = await client.communities.publicRanked(communityId); ``` ## `communities.publicMedia(communityId, opts?)` The media-only public view of a community. Uses the GraphQL `CommunityMediaLoggedOutTimeline` query. ```js const media = await client.communities.publicMedia(communityId); ``` --- --- url: /xchat.md --- # XChat XChat is X's [end-to-end encrypted chat](https://chat.x.com), a separate system from the legacy [DMs](/users/direct-messages) API. emusks has mostly reverse-engineered XChat for you. Sending an encrypted DM is pretty easy: set up an identity once, then `message()`. Emusks will handle all of the crypto for you. ```js import Emusks from "emusks"; const client = new Emusks(); await client.login("your_auth_token"); // one-time: create (or load) your encrypted-chat identity const identity = await client.xchat.createIdentity({ pin: "2468" }); // ... persist `identity`, then on later runs: await client.xchat.loadIdentity(identity) // send a fully end-to-end encrypted DM, in one call await client.xchat.message("44196397", "hello from xchat ^w^"); // react, edit, mark read, delete - all one call too await client.xchat.react("44196397", messageSequenceId, "🔥"); await client.xchat.edit("44196397", messageSequenceId, "fixed typo"); ``` You can also look things up without any setup of your own: ```js await client.xchat.isOnXChat("44196397"); // true await client.xchat.canMessage("44196397"); // can you reach them over XChat? await client.xchat.fingerprint(await client.xchat.publicKey("44196397")); // 8435:8166:a7fb:5c30:...:8e4b:b1c6 ``` Yes this was all fucking vibecoded, I'm not manually reading like 15 megabytes of minified JS (yes XChat loads 15 megabytes of JS) to write this. It works well though. Thanks Claude! ## Get started | Section | Methods | | --- | --- | | [Setup & identity](/xchat/identity) | `createIdentity` (PIN-backed), `loadIdentity`, `recover` (PIN + session) | | [Sending messages](/xchat/messaging) | `message`, `react`, `unreact`, `edit`, `markRead`, `markUnread`, `deleteMessages` | | [Reading messages](/xchat/reading) | `conversations`, `read` | | [Conversations](/xchat/conversations) | `pin`, `unpin`, `setNickname`, `reportScreenCapture` | | [Looking up users](/xchat/lookups) | `publicKey`, `publicKeys`, `profile`, `permissions`, `canMessage`, `isOnXChat`, `fingerprint`, `callPermissions` | | [Audio & video calls](/xchat/calls) | `call`, `answerCall`, `listenForCalls`, `startGroupCall`, `joinGroupCall`, `useWebRTC` | | [Advanced](/xchat/advanced) | `token`, `gql` | --- --- url: /xchat/identity.md --- # Setup & identity [Looking things up](/xchat/lookups) on XChat needs only a logged-in session. **Sending** anything needs an XChat identity: two P-256 keypairs whose public halves are published to X and whose private halves you keep. You can create a key once, persist it, and load it on later runs. ```js import Emusks from "emusks"; const client = new Emusks(); await client.login("your_auth_token"); const identity = await client.xchat.createIdentity({ pin: "2468" }); // first run // save `identity` somewhere safe, then on later runs: await client.xchat.loadIdentity(identity); ``` ## `client.xchat.createIdentity(opts?)` Generate a fresh chat identity, publish the public half, back it up under your PIN, and load it into the client. Returns a serializable identity object: persist it (it contains your private keys) and reuse it with `loadIdentity`. By default this is PIN-backed with [Juicebox](https://github.com/juicebox-systems), just like what the normal XChat uses, so the identity can be recovered with the PIN. Use the same passcode format the app uses, a **4-digit** code, so the identity is also unlockable from the official X UI (the realms accept arbitrary bytes, but the app's passcode field is 4 digits). | Option | Type | Description | | -------------------- | --------- | ----------------------------------------------------------------------- | | `pin` | `string` | Your passcode (4 digits, like the X app). Required unless `selfCustody` is set. Backs the keys up to the realms. | | `selfCustody` | `boolean` | Skip the PIN backup: you hold the only copy of the keys (no PIN recovery). | | `registrationMethod` | `string` | Override the published method (`CustomPin` for PIN-backed, `SelfCustody`, `ManagedPin`). | ```js // PIN-backed (default, like the app) const identity = await client.xchat.createIdentity({ pin: "2468" }); await fs.writeFile("xchat-identity.json", JSON.stringify(identity)); // also holds your private keys // or self-custody (no PIN backup) const local = await client.xchat.createIdentity({ selfCustody: true }); ``` ::: tip PIN-backed setup uses a bundled WebAssembly build of the Juicebox SDK; it loads only when you actually create or recover a PIN-backed identity. If the backup fails, `createIdentity` rolls back the published key so you never end up with an un-backed-up identity. Either way, the returned object also holds the private keys, so persist it. ::: ::: warning With `selfCustody: true` there is no cross-device PIN recovery: lose the identity object and you lose access to that identity's chats. ::: ## `client.xchat.loadIdentity(identity)` Load a previously created identity so you can send. Pass the object from `createIdentity` (or one assembled from saved key material). Prefer using this over `xchat.recover` when you can, since it's much faster and doesn't require the entire very complex recovery process. | Field | Type | Description | | ------------------------------------------- | -------- | ---------------------------------------- | | `version` | `string` | Your published key version | | `publicKeyB64` | `string` | Your identity public key (SPKI base64) | | `identityPrivateJwk` / `signingPrivateJwk` | `object` | Your private keys as JWK | | `userId` | `string` | Optional; defaults to the logged-in user | ```js const identity = JSON.parse(await fs.readFile("xchat-identity.json", "utf8")); await client.xchat.loadIdentity(identity); ``` Once loaded, head to [Sending messages](/xchat/messaging). ## `client.xchat.recover(pin)` Recover your identity from X's PIN-protected realms using **only your PIN and this logged-in session**, no saved key file. This is the "new device" flow: it fetches your published key, pulls the backed-up private keys from the realms with your PIN, reconstructs the keypairs, and loads them. After it returns you can [read](/xchat/reading) and send on all your existing conversations. | Param | Type | Description | | ----- | -------- | ------------------------------------ | | `pin` | `string` | The PIN you used with `createIdentity` | ```js const client = new Emusks(); await client.login("your_auth_token"); await client.xchat.recover("2468"); // identity restored from the realms, nothing stored locally for (const convo of await client.xchat.conversations()) { const { messages } = await client.xchat.read(convo.conversationId.split(":").find((id) => id !== me.id)); // ... all your chats, decrypted } ``` Only works for a PIN-backed identity (the default). If you used `selfCustody: true`, there is nothing in the realms to recover; `loadIdentity` your saved object instead. --- --- url: /xchat/messaging.md --- # Sending messages All of these are one call and fully end-to-end encrypted; the key agreement, cipher, signature, and encoding are handled for you. They need a loaded [identity](/xchat/identity). The first argument is the recipient: a numeric user id, a `@handle`, or a `{ id }` object (handles are resolved and cached). ```js const { sequenceId } = await client.xchat.message("tiagozip_", "hey, encrypted ^w^"); await client.xchat.react("tiagozip_", sequenceId, "🔥"); ``` ## `client.xchat.message(recipient, text, opts?)` Send an encrypted text message. Returns `{ conversationId, recipientId, messageId, sequenceId, response }`. Use the returned `sequenceId` to react to, edit, or delete the message. | Param | Type | Description | | ------ | -------- | ---------------------------------------- | | `recipient` | `string \| object` | User id, `@handle`, or `{ id }` | | `text` | `string` | The message text | | `opts.ttlMsec` | `number` | Optional disappearing-message lifetime | ```js const sent = await client.xchat.message("44196397", "hello!"); console.log(sent.sequenceId); ``` ## `client.xchat.react(recipient, messageSequenceId, emoji, opts?)` React to a message with an emoji. `messageSequenceId` is the target message's sequence id (from a `message()` result or from reading the conversation). ```js await client.xchat.react("44196397", sequenceId, "🔥"); ``` ## `client.xchat.unreact(recipient, messageSequenceId, emoji, opts?)` Remove a reaction you added. ```js await client.xchat.unreact("44196397", sequenceId, "🔥"); ``` ## `client.xchat.edit(recipient, messageSequenceId, newText, opts?)` Edit one of your sent messages. ```js await client.xchat.edit("44196397", sequenceId, "edited text"); ``` ## `client.xchat.markRead(recipient, sequenceId, opts?)` Mark a conversation read up to a sequence id. `opts.seenAtMillis` overrides the timestamp (defaults to now). ```js await client.xchat.markRead("44196397", sequenceId); ``` ## `client.xchat.markUnread(recipient, sequenceId, opts?)` Mark a conversation unread from a sequence id. ```js await client.xchat.markUnread("44196397", sequenceId); ``` ## `client.xchat.deleteMessages(recipient, sequenceIds, opts?)` Delete one or more messages (by sequence id) from your own view of the conversation. ```js await client.xchat.deleteMessages("44196397", [sequenceId]); ``` `opts.forEveryone: true` attempts to unsend for all participants (requires a signed delete action). ## Full example ```js import Emusks from "emusks"; import { readFileSync, writeFileSync, existsSync } from "node:fs"; const client = new Emusks(); await client.login("your_auth_token"); if (existsSync("xchat-identity.json")) { await client.xchat.loadIdentity(JSON.parse(readFileSync("xchat-identity.json", "utf8"))); } else { writeFileSync("xchat-identity.json", JSON.stringify(await client.xchat.createIdentity({ pin: "2468" }))); } if (await client.xchat.canMessage("tiagozip_")) { const { sequenceId } = await client.xchat.message("tiagozip_", "I am Charlie Kirk"); await client.xchat.react("tiagozip_", sequenceId, "✅"); } ``` --- --- url: /xchat/reading.md --- # Reading messages List your conversations and read decrypted message history. Reading needs a loaded [identity](/xchat/identity) (decryption uses your private key). ```js await client.xchat.loadIdentity(identity); for (const convo of await client.xchat.conversations()) { console.log(convo.conversationId, convo.latestMessage?.text); } const { messages } = await client.xchat.read("tiagozip_"); for (const m of messages) { if (m.kind === "message") console.log(m.senderId, m.text); } ``` ## `client.xchat.conversations(opts?)` List all your conversations (the inbox), newest first. Each conversation's latest message is decrypted for you. Returns an array of: | Field | Description | | --- | --- | | `conversationId` | The conversation id (for 1:1, `minId:maxId`) | | `type` | `"direct"` or `"group"` | | `participants` | Member user ids | | `groupName` | Group name (groups only) | | `isMuted` | Whether you've muted it | | `latestSequenceId` | Sequence id of the most recent event | | `latestMessage` | The decoded latest message (see `read` below), if any | ```js const convos = await client.xchat.conversations(); ``` ## `client.xchat.read(recipient, opts?)` Read and decrypt a conversation's messages. Returns `{ conversationId, hasMore, messages }`. Pass `opts.before` (a sequence id) to page into older history. Each entry in `messages` is decoded and decrypted: | Field | Description | | --- | --- | | `sequenceId` | The event's sequence id | | `senderId` | Who sent it | | `createdAt` | Timestamp (ms, string) | | `kind` | `"message"`, `"reaction_add"`, `"reaction_remove"`, `"edit"`, `"delete"`, `"conversation_key_change"`, … | | `text` | The decrypted message text (for `message` / `edit`) | | `emoji` | The reaction (for `reaction_add` / `reaction_remove`) | | `targetSequenceId` | The message a reaction/edit applies to | ```js const { messages, hasMore } = await client.xchat.read("tiagozip_"); for (const m of messages) { if (m.kind === "message") console.log(`${m.senderId}: ${m.text}`); if (m.kind === "reaction_add") console.log(`${m.senderId} reacted ${m.emoji}`); } ``` ::: tip `read` decrypts **received** messages (and reactions, edits, deletes). Like the official client, the server's message pull does not re-deliver your own sent messages, so a fresh client sees the other party's messages plus key-change and delete events; your outgoing messages are the ones you got back from `message()`. ::: --- --- url: /xchat/conversations.md --- # Conversations Conversation-level actions. Like [sending messages](/xchat/messaging), these need a loaded [identity](/xchat/identity) and take a recipient as a user id, `@handle`, or `{ id }`. ## `client.xchat.pin(recipient)` / `client.xchat.unpin(recipient)` Pin or unpin a conversation. Pin state syncs across your own devices. ```js await client.xchat.pin("44196397"); await client.xchat.unpin("44196397"); ``` ## `client.xchat.setNickname(recipient, nickname, opts?)` Set a per-conversation nickname. By default it names the recipient; pass `opts.userId` to nickname a specific participant. ```js await client.xchat.setNickname("44196397", "the boss"); ``` ## `client.xchat.reportScreenCapture(recipient, opts?)` Send the "screenshot detected" notice the official app sends when you screenshot a chat. `opts.recording: true` reports a screen recording instead. ```js await client.xchat.reportScreenCapture("44196397"); ``` Idk why you'd need this but it's here. --- --- url: /xchat/lookups.md --- # Looking up users Read-only helpers. These need only a logged-in session, no identity. Every one accepts a numeric user id, a `@handle`, or a `{ id }` object (handles are resolved and cached). ## `client.xchat.publicKey(userId)` A user's current XChat identity key, or `null` if they aren't on XChat. ```js const key = await client.xchat.publicKey("44196397"); // { version, publicKey, signingPublicKey, identityPublicKeySignature, registrationMethod } ``` `publicKey` is the identity key others use to wrap conversation keys to this user; `signingPublicKey` verifies their message signatures (see [Protocol → Encryption](/xchat/protocol#encryption)). ## `client.xchat.publicKeys(userIds, opts?)` Keys and permissions for many users at once. `opts.includeTokens` includes the Juicebox realm token map. Returns an array of profiles (see `profile`). ```js const users = await client.xchat.publicKeys(["44196397", "tiagozip_"]); ``` ## `client.xchat.profile(userId)` The full parsed XChat profile: `{ userId, onXChat, isManagedPinUser, permissions, keys }`. ```js const p = await client.xchat.profile("44196397"); ``` ## `client.xchat.permissions(userId)` Just the permission flags: `{ canDm, canDmOnXChat, dmBlocking, passesPremiumCheck }`. `canDmOnXChat` is relational (can *you* message *this* user over XChat). ```js const perms = await client.xchat.permissions("44196397"); ``` ## `client.xchat.canMessage(userId)` Boolean shortcut for `permissions().canDmOnXChat`. ```js if (await client.xchat.canMessage("44196397")) { /* reachable */ } ``` ## `client.xchat.isOnXChat(userId)` Whether the user has any published XChat key. ```js await client.xchat.isOnXChat("44196397"); // true ``` ## `client.xchat.fingerprint(key)` A short, verifiable fingerprint (colon-grouped SHA-256) of a key. Pass a key object from `publicKey` or a raw SPKI base64 string. Use it to compare a key out-of-band and confirm you are talking to the right person. ```js await client.xchat.fingerprint(await client.xchat.publicKey("44196397")); // "8435:8166:a7fb:5c30:68dc:df90:dd47:b519:9eff:588c:c2bd:0772:36aa:c127:8e4b:b1c6" ``` ## `client.xchat.callPermissions(userIds)` Check whether you may start an audio/video call with one or more users. Returns the per-user result (`{ can_dm, error_code }`). ```js await client.xchat.callPermissions("44196397"); ``` Real-time audio/video itself needs a WebRTC stack (see [Protocol → Calls](/xchat/protocol#calls)); this checks reachability. --- --- url: /xchat/calls.md --- # Audio & video calls XChat calls are real end-to-end WebRTC, the same audio/video stack the official client uses (Periscope signaling plus a Janus SFU). emusks gives you a small, high-level surface: start a call, answer one, stream a media file into it, and record what comes back. ## Prerequisite: a WebRTC engine Calls need a WebRTC implementation. Node and Bun don't ship one, so install [`@roamhq/wrtc`](https://www.npmjs.com/package/@roamhq/wrtc) (a prebuilt native binding): ```sh npm i @roamhq/wrtc ``` emusks loads it automatically the first time you place or answer a call. To use a different engine, pass it once: ```js import wrtc from "@roamhq/wrtc"; client.xchat.useWebRTC(wrtc); ``` `ffmpeg` is required only if you use the file helpers (`sendAudioFile`, `sendVideoFile`, `recordIncoming`); it must be on your `PATH` (or set `FFMPEG_PATH`). ## `client.xchat.call(recipient, opts?)` Place a 1:1 call. `recipient` is a numeric id, `@handle`, or `{ id }`. Returns a [call object](#the-call-object). ```js const call = await client.xchat.call("elonmusk", { video: false }); call.on("connected", () => console.log("call is up")); call.on("track", ({ kind }) => console.log("receiving", kind)); call.on("ended", ({ durationSeconds }) => console.log("call ended after", durationSeconds, "s")); ``` Options: | option | default | meaning | | --- | --- | --- | | `video` | `false` | send video as well as audio | | `audioOnly` | `true` | force audio-only (ignored when `video` is set) | | `announce` | `true` | also post the `AVCallStarted` event into the conversation (needs a loaded identity) | ## Listening for incoming calls ```js const sub = client.xchat.listenForCalls((incoming) => { console.log("call from", incoming.hostId); incoming.accept({ video: false }); // returns a call object // or: incoming.decline(); }); // later: sub.close(); ``` Each `incoming` has `{ broadcastId, hostId, conversationId, audioOnly, accept(opts?), decline() }`. ## `client.xchat.answerCall(incoming, opts?)` Answer a call you already know the details of (for example a `broadcastId` + `hostId` surfaced elsewhere). `incoming.accept()` above is a thin wrapper over this. ```js const call = await client.xchat.answerCall({ broadcastId, hostId }, { video: true }); ``` ## The call object Both `call()` and `answerCall()` return the same object (an `EventEmitter`). ### Events * `connected`: the peer connection is up, media is flowing. * `track` `{ kind, track }`: a remote audio/video track arrived. * `mediastatus` `{ isCameraDeactivated, isMicrophoneDeactivated }`: the other side toggled mic/camera. * `ended` `{ reason, durationSeconds }`: the call finished. * `error`: a non-fatal signaling error. ### Sending media from a file ```js await call.sendAudioFile("./hello.mp3"); // any audio ffmpeg can read await call.sendVideoFile("./clip.mp4"); // video + its audio track ``` Files are transcoded and streamed in real time. The returned controller exposes `done` (a promise) and `stop()`. ### Recording what you receive ```js const rec = call.recordIncoming({ audioPath: "./in.wav", videoPath: "./in.mp4" }); // ...later await rec.audio?.stop(); await rec.video?.stop(); ``` Prefer raw frames? Listen for `track` and attach your own sink from the WebRTC engine. ### Hanging up ```js await call.hangup(); // tears down media + leaves the broadcast call.durationSeconds; // seconds since connect ``` ## Group calls Group calls run over a Janus SFU (the Twitter Spaces backend). The host starts a room and invites people; everyone else joins by its broadcast id. The host **must** invite a participant (`invite`) for them to be allowed to auto-join. ```js // host const call = await client.xchat.startGroupCall({ audioOnly: true, invite: ["alice", "bob"] }); console.log("share this id:", call.broadcastId); // participant const call = await client.xchat.joinGroupCall(broadcastId, { audioOnly: true }); ``` `startGroupCall` options: `video`/`audioOnly`, `invite` (id/`@handle`/array), `conversationId`, and `videoCodec` (default `"vp8"`, so video works with `@roamhq/wrtc`; pass `"h264"` only with an H.264-capable engine for interop with the official app). The returned object is the same as the 1:1 [call object](#the-call-object): `sendAudioFile`, `sendVideoFile`, `recordIncoming`, `on("track")`, `hangup()`. ## Codecs `@roamhq/wrtc` supports VP8/VP9/AV1 but **not** H.264. The official X client uses H.264 for video. So: * Between two emusks clients, leave the default VP8 and video works. * To interop with the official app's video, supply a WebRTC engine that encodes/decodes H.264 (`client.xchat.useWebRTC(...)`) and set `videoCodec: "h264"`. Audio (Opus) is unaffected. ## A note on what's verified The full pipeline (signaling, ICE, perfect negotiation, media sources/sinks, ffmpeg file transcode) is implemented and verified end-to-end: two clients connect and exchange **real audio and 640×480 video both directions**, with files streamed in and recorded out. The Periscope bootstrap (token → login → guest token → TURN), broadcast creation, Janus connection, and go-live are all verified live. Whether a given **live** call connects through X depends on X's own infrastructure: * **1:1 calls** are gated server-side by X's calling rollout. Creating the `p2p/broadcast` returns `403` until your account is enabled, regardless of settings, follows, or Premium. The implementation matches the official client byte-for-byte, so it connects the moment an account is in the rollout. * **Group calls** connect to the live SFU and the host goes live, but X's gateway is the Spaces audio-mixing backend (sessions are isolated VideoRoom views), so cross-participant media is brokered by that backend rather than exchanged as direct peer feeds. If you have an account in the 1:1 rollout, the 1:1 path is the one that does true peer audio **and** video. --- --- url: /xchat/advanced.md --- # Advanced Lower-level helpers for when the high-level methods aren't enough. ## `client.xchat.token()` Mint a short-lived realtime auth token (a JWT) for the chat WebSocket. This is the token that goes in `wss://chat-ws.x.com/ws?token=...` (see [Protocol → Transport](/xchat/protocol#transport)). ```js const jwt = await client.xchat.token(); ``` ## `client.xchat.gql(name, variables?)` Call any known XChat GraphQL operation directly and get the raw response. Every other method in `client.xchat` is a wrapper over this. It throws on a GraphQL-level error. | Param | Type | Description | | ----------- | -------- | ---------------------------------------------------------------- | | `name` | `string` | Operation name (see [Protocol → GraphQL operations](/xchat/protocol#graphql-operations)) | | `variables` | `object` | GraphQL variables for the operation | ```js const res = await client.xchat.gql("GetPublicKeys", { ids: ["44196397"], include_juicebox_tokens: true }); ``` Use this to reach operations that don't yet have a dedicated helper (inbox paging, message requests, groups, media upload). The full wire format for those lives in the [Protocol](/xchat/protocol) reference. --- --- url: /xchat/protocol.md --- # Protocol This is the internals reference for XChat, reverse-engineered from the `chat.x.com` client. You do not need any of it to use `client.xchat` (start at [Setup & identity](/xchat/identity)); it is here if you want to understand how XChat works or build something lower-level. Everything below is descriptive, not API. Use it mostly for feeding it to your clankers. \[\[toc]] ## Architecture XChat is X's end-to-end encrypted chat, served from `chat.x.com`, a separate system from the legacy `/1.1/dm` API. The web client is Kotlin Multiplatform compiled to JavaScript; local state is an encrypted SQLite database over OPFS; key backup is a wasm-bindgen Juicebox SDK; calls are WebRTC against a Janus SFU. | Surface | Endpoint | | --- | --- | | Realtime | `wss://chat-ws.x.com/ws?token=` | | GraphQL | `https://api.x.com/graphql//` | | History (gRPC-web) | `https://api.x.com/xai.chat_service.v1.ChatService/GetMessageEventsPage` | | Encrypted media | `https://ton.x.com/i/ton/data/xchat_media/` | | Media upload (public) | `https://upload.x.com/1.1/media/upload.json` | | Key-backup realms | `realm-b.x.com`, `realm-east1.x.com`, `realm-west1.x.com` | | Group invite link | `https://x.com/i/chat/group_join/` | ## Wire format Every binary payload (WebSocket frames and the base64 blobs in GraphQL such as `encoded_message_create_event`) is **Apache Thrift `TBinaryProtocol`**, schema `com.x.dmv2.thriftjava.*`. It is not protobuf, although the field-numbered tables below look similar. Encoding it as protobuf yields a `StratoThriftDeserializationException`. `TBinaryProtocol`, by hand: * **Struct**: a sequence of fields, terminated by a `0x00` stop byte. * **Field header**: `[type (1 byte)] [field id (2 bytes, big-endian)]`, then the value. * **Types**: `2` bool, `8` i32, `10` i64, `11` string/binary, `12` struct, `15` list. * **string / binary**: `[length (4-byte big-endian)] [bytes]`. Booleans inline as one byte; i32/i64 are fixed-width big-endian. * **list**: `[element type (1 byte)] [count (4-byte big-endian)] [elements]`. ## Transport ### WebSocket `wss://chat-ws.x.com/ws?token=`. Authentication is the `?token=` query parameter only: no subprotocol, headers, or cookies on the browser socket. The token is a short-lived JWT from `GenerateXChatTokenMutation` (`client.xchat.token()`), cached and re-validated by its `exp` claim. `binaryType` is `arraybuffer`; there is no application-level hello frame. The frame boundary is the message boundary (no opcode or length prefix). Heartbeat: an empty `KeepAliveInstruction` every 30s. Reconnect: exponential `2^n` backoff, give up after 11 attempts; close `1011` (`unauthorized`) is treated as a 401 and refetches the token. ### GraphQL `POST https://api.x.com/graphql//` with `Bearer` + `ct0` cookie auth (the same session as the rest of emusks). The body is `{ operationName, variables, query, queryId }` (the client sends the full query document, so a persisted-query hash isn't required). Every field takes a `safety_level:` argument (commonly `XChat`). A bare `POST /graphql` 404s; the `//` path is required. ### History backfill `POST .../ChatService/GetMessageEventsPage` over `application/grpc-web+proto` with `Authorization: Bearer `, for paged history beyond what the socket streams. The same data is reachable via the `GetMessageEventsPageQuery` GraphQL op. ## Keys Every device holds two P-256 keypairs (all asymmetric crypto is P-256 over WebCrypto; there is no Ed25519): | Key | Usage | Purpose | | --- | --- | --- | | **Identity** | ECDH (`deriveBits`) | wrapping/unwrapping conversation keys | | **Signing** | ECDSA (`sign`/`verify`) | authenticating message events and admin actions | Both are versioned together (`NumericString` version). Public keys are `exportKey("spki")` base64 (91 bytes, starting `MFkwEwYHKoZIzj0CAQYI`). ECDSA signatures are raw `r || s` (64 bytes). ### Registration `AddXChatPublicKeyMutation` publishes `XChatPublicKeyInput { public_key, signing_public_key, identity_public_key_signature, registration_method }` with `generate_version: true` (the server assigns the version). `identity_public_key_signature` = ECDSA-P256-SHA256 over the **SPKI bytes** of the identity key, signed with the signing key. `registration_method` is `CustomPin`, `ManagedPin`, or `SelfCustody`. ### PIN backup (Juicebox) The private keys are backed up to PIN-protected realms using [Juicebox](https://github.com/juicebox-systems): register/recover the secret hardened by a numeric PIN (Argon2/OPRF, `Standard2019`), split across realms with a 20-guess limit. Thresholds: register to all 3, recover with any 2. The realm crypto lives in `juicebox-sdk_bg-*.wasm`. The secret is the two P-256 private scalars (`identityScalar(32) || signingScalar(32)`). Realm config + per-realm bearer tokens come from the `KeyStoreTokenMap` (the `token_map` returned by `AddXChatPublicKey`/`GetPublicKeys`); the wasm requests each realm's token through a `JuiceboxGetAuthToken(realmId)` callback, where `realmId` is the realm id bytes (hex-encoded in the token map). `emusks`' `createIdentity({ pin })` performs this Juicebox register by default (using a bundled build of the SDK wasm), exactly like the app; `{ selfCustody: true }` skips the backup. ## Encryption ### Conversation key (cKey) Each conversation has one symmetric key, the cKey, identified by a `conversation_key_version`. Message bodies are encrypted with it; the cKey itself is wrapped to each member. ### Wrapping the cKey (P-256 ECIES) Per recipient, the wrapped key is built as: 1. Generate an ephemeral P-256 ECDH keypair. 2. `Z = deriveBits(ephemeralPriv, recipientIdentityPub, 256)` (32 bytes). 3. `out = SHA-256(Z || 0x00000001 || ephemeralRawPub65)` (ConcatKDF, single block). 4. `aesKey = out[0:16]` (AES-128), `iv = out[16:32]`. 5. `ct = AES-GCM(aesKey, iv, cKey)` (no AAD, 128-bit tag). 6. `encrypted_conversation_key = base64(ephemeralRawPub65 || ct)` (113 bytes for a 32-byte cKey). The IV is re-derived on unwrap, not transmitted. Each wrapped copy is sent as `ApiConversationParticipantKeyInput { user_id, encrypted_conversation_key, public_key_version }`. ### Message body cipher libsodium `crypto_secretbox_easy` (XSalsa20-Poly1305) under the cKey: `frame = nonce(24) || crypto_secretbox_easy(plaintext, nonce, cKey)`. The plaintext is the serialized `MessageEntryHolder`. ### Media cipher `crypto_secretstream_xchacha20poly1305`, chunked at 1024-byte plaintext blocks, keyed by the cKey. The 24-byte stream header is prepended to the ciphertext in TON storage. The key is never sent with the attachment; only `media_hash_key` travels in the message. ### Event signatures Every message event is ECDSA-P256-SHA256 signed by the sender's signing key over a canonical comma-joined string. The live client uses **`signature_version = 7`**: ``` payload = ["MessageCreateEvent", conversation_token, senderUserId, conversationId, conversation_key_version, base64url(frame)].join(",") ``` `conversation_token` is the conversation's server-issued JWT (from `MessageEvent` field 5; empty only for the very first message of a brand-new conversation). Getting the version, the `conversation_token`, or the struct shape below wrong makes the server **accept** the call but silently drop the message (it never reaches the recipient). The signature is wrapped in a Thrift `MessageEventSignature` struct (with `signing_public_key` set, and a key-info list of just the sender), base64'd into `encoded_message_event_signature`: | Field | Name | | --- | --- | | 1 | `signature` (base64url of raw r‖s) | | 2 | `public_key_version` | | 3 | `signature_version` | | 4 | `signing_public_key` | | 5 | `message_signing_key_info_list` (list of `MessageSigningKeyInfo { member_id, public_key_version, signing_public_key }`) | The GraphQL mirror is `XChatMessageEventSignatureInput`. Admin/mutation actions (delete-for-everyone, mute, TTL, group ops) require an `ActionSignatureInput` of the same shape over an action-specific canonical string. ### Groups Group conversations derive the cKey from a TreeKEM/ART ratcheting tree (`GroupKeysMgr`), not sender keys. Membership changes rotate the root and re-encrypt node secrets to the affected members; the change rides on `ConversationKeyChangeEvent { conversation_key_version, conversation_participant_keys, ratchet_tree_change, for_key_rotation }`. ### Franking & Grok Each message carries `FrankingData { franking_tag, encrypted_nonce, encrypted_media_hashes }` so abuse reports verify against a message the server can't read (`ReportFrankedMessageMutation`). Messages to Grok are plaintext (`XChatSendGrokMessagePlaintextMutation`), outside the E2E boundary. ## Events & messages ### WebSocket envelope Top-level `Message` union: `1 messageEvent`, `2 messageInstruction`, `3 batchedMessageEvents`. `MessageEvent`: `1 sequence_id, 2 message_id, 3 sender_id, 4 conversation_id, 5 conversation_token, 6 created_at_msec, 7 detail, 8 relay_source, 9 message_event_signature, 10 previous_sequence_id, 11 is_trusted`. `MessageEventDetail` union: `1 messageCreateEvent, 3 conversationKeyChangeEvent, 4 groupChangeEvent, 5 messageFailureEvent, 6 messageTypingEvent, 7 messageDeleteEvent, 8 conversationDeleteEvent, 9 conversationMetadataChangeEvent, 10 grokSearchResponseEvent, 12 markConversationReadEvent, 13 markConversationUnreadEvent, 14 memberAccountDeleteEvent, 15 grokMessageEvent, 16 grokResponseEvent`. `MessageInstruction` union: `1 pullMessages, 2 keepAlive, 3 pullMessagesFinished, 4 pinReminder, 5 switchToHybridPull, 6 displayTemporaryPasscode, 7 deviceEnrollment`. ### Sending: MessageCreateEvent The encrypted body goes in `MessageCreateEvent`: `100 contents (the nonce‖ciphertext), 101 conversation_key_version, 102 should_notify, 103 ttl_msec, 104 delivered_at_msec, 105 is_pending_public_key, 106 priority, 107 additional_action_list, 108 franking_data, 109 is_message_request`. Base64'd into `encoded_message_create_event`, sent via `SendMessageCreateMutation`. ### Content variants The plaintext is a `MessageEntryHolder { 1: MessageEntryContents }`, where `MessageEntryContents` is a union naming the action: | Field | Variant | Payload | | --- | --- | --- | | 1 | `message` | `MessageContents` | | 2 | `reaction_add` | `MessageReactionAdd { message_sequence_id, emoji, message_attachment_id? }` | | 3 | `reaction_remove` | same shape as `reaction_add` | | 4 | `message_edit` | `MessageEdit { message_sequence_id, updated_text, entities }` | | 5 | `mark_conversation_read` | `MarkConversationRead { seen_until_sequence_id, seen_at_millis }` | | 6 | `mark_conversation_unread` | `MarkConversationUnread { seen_until_sequence_id }` | | 7 / 8 | `pin_conversation` / `unpin_conversation` | `{ conversation_id }` | | 9 | `screen_capture_detected` | `{ type }` (`DmScreenCaptureType`: Unknown/Screenshot/Recording) | | 10 / 11 / 16 | `av_call_ended` / `av_call_missed` / `av_call_started` | call lifecycle | | 12 | `draft_message` | draft sync | | 13 | `accept_message_request` | empty | | 14 | `nickname_message` | `{ user_id (int64), nickname_text }` | | 15 | `set_verified_status` | | `MessageContents`: `1 message_text, 2 entities, 3 attachments, 4 replying_to_preview, 6 forwarded_message, 7 sent_from, 8 quick_reply, 9 ctas, 10 additional_fields`. `RichTextEntity { start_index, end_index, content }` where `content` is an empty type marker (`hashtag=1, cashtag=2, mention=3, url=4, email=5, address=6, phoneNumber=7`). ## Media & attachments `MessageAttachment` union: `1 media, 2 post, 3 url, 4 unified_card, 5 money, 6 jetfuel`. `MediaAttachment`: `1 media_hash_key, 2 dimensions, 3 type, 4 duration_millis, 5 filesize_bytes, 6 filename, 7 attachment_id, 8 legacy_media_url_https, 9 legacy_media_preview_url, 10 grok_tag`. `MediaType`: `IMAGE=1, GIF=2, VIDEO=3, AUDIO=4, FILE=5, SVG=6`. Upload is `InitializeXChatMediaUploadMutation` → push the encrypted stream (private: resumable `PUT` to `ton.x.com/.../xchat_media//...?concurrent=true&resumeId=..&partNumber=..`; public: the `upload.x.com/1.1/media/upload.json` command flow) → `FinalizeXChatMediaUploadMutation`, then attach a `MediaAttachment` to a message. GIFs are Giphy bytes (`GifSearchQuery`) downloaded and re-uploaded as `MediaType.GIF`. Link cards resolve via `GetCardPreviewOrJetfuelFromUrlQuery`. ## Groups `InitializeGroupConversationMutation` allocates a conversation id, then `CreateGroupConversationMutation` creates it with `admin_user_ids`, `member_user_ids`, `conversation_key_version`, `conversation_participant_keys`, `base64_encoded_key_rotation`, and `action_signatures`. Membership changes (`AddGroupMembersMutation`, `RemoveFromGroupMutation`, `AddAsAdminMutation`, …) re-key (carry the same three key fields). Invite links: `EnableGroupInviteMutation` → `XChatGroupInviteDetails { invite_url, token, … }`; join via `GroupInviteDetailsQuery` + `RequestToJoinGroupMutation`. ## Calls Audio/video is real WebRTC over Periscope infrastructure. In the official web client this all lives behind the `useAvcallingSetup` hook (the `useAvcallingSetup-*.js` bundle: `ProxseeApi`, `GuestServiceApi`, the Janus client, `setupPeerConnection`, the `AvCallE2eePipelines`). The high-level API is in [Audio & video calls](/xchat/calls); this is what runs underneath. ### Two transports There are two signaling paths: * **1:1 calls** use a Periscope **P2P mesh**. The caller creates a `p2p/broadcast`, and both sides exchange WebRTC `OFFER` / `ANSWER` / `CANDIDATE` envelopes over the guest-service relay (`POST guest-cf.pscp.tv/api/v1/signaling/send` plus a cursor-based long-poll `signaling/receive`). The caller is the impolite offerer; on track setup each side sends a `MEDIA_STATUS` signal, and the host publishes then offers when it sees one. Candidates are trickled as `{ id: sdpMid, label: sdpMLineIndex, candidate }`. * **Group calls** use a **Janus SFU** (VideoRoom plugin). `proxsee.pscp.tv/api/v2/createBroadcast` returns the Janus gateway URL plus a JWT credential. The host creates a session, attaches two videoroom handles (publisher + subscriber), creates the room (`audiocodec: opus`, `videocodec: h264`, `h264_profile: 42e01f`, `dummy_publisher: true`), joins as publisher, and publishes a JSEP offer; `publishBroadcast` then ties the Janus publisher/handle/session ids to the broadcast to go live. Participants join via `getAudiospace` → `audiospace/join` → `audiospace/stream/negotiate`, then subscribe to publisher feeds. Janus messages are `POST {gw}/{session}/{handle}` with `{ janus: "message", body: { room, periscope_user_id, ... }, jsep? }`, the JWT in the `Authorization` header (no `Bearer`); events arrive via a `?maxev=1` long-poll. ### Bootstrap Both paths start the same way: GraphQL `useDirectCallSetupQuery` (`zCYojd6h_gVXYjFlaAk4bA`) returns `authenticate_periscope` (a JWT) → `POST proxsee.pscp.tv/api/v2/loginTwitterToken` for a Periscope cookie → `authorizeToken { service: "guest" }` for the guest-service bearer → `turnServers` (`turn-p2p.pscp.tv:3478/udp`, `turns:443/tcp`) for ICE. This whole bootstrap is verified working with a normal account session. ### Gateway architecture & codecs Two practical findings from reimplementing this headless: * The group gateway (`gw-prod-*.pscp.tv`) is the **Twitter Spaces** mixing backend, not a plain peer VideoRoom. Each session gets an **isolated** room view (its own publisher plus a `"Dummy publisher"` placeholder); participants don't see each other's Janus feeds directly, so peer media is brokered by the Spaces backend rather than exchanged feed-to-feed. The host reliably connects to the SFU and goes live; full cross-participant media is the Spaces layer's job. * The app pins **H.264** (`videocodec: h264`, profile `42e01f`) and **Opus**. Headless WebRTC engines like `@roamhq/wrtc` only do VP8/VP9/AV1, so video interop with the official client needs an H.264-capable engine; between two emusks clients you can create the room as VP8 (`videoCodec` option on `startGroupCall`). ### Lifecycle in the message channel Call lifecycle also surfaces in the conversation as content variants (see [Content variants](#content-variants)): `AVCallStarted { is_audio_only, broadcast_id }`, `AVCallEnded { sent_at_millis, duration_seconds, is_audio_only, broadcast_id }`, `AVCallMissed { sent_at_millis, is_audio_only }`. Incoming calls are also pushed over LivePipeline (`/avcall/create/{userId}`). ### Media encryption Media frames go through WebRTC encoded-insertable-streams. When enabled it is AES-GCM-256 with a key derived (HKDF) from conversation key material; the OFFER/ANSWER advertise an `encryption_info { fingerprint, version }` and both sides verify the fingerprint match. Passthrough (no media encryption) is a first-class fallback and is what runs when no `encryption_info` is exchanged. ### Permissions `DmAvPermissionsQuery` (`client.xchat.callPermissions()`) reports `{ can_dm, error_code }` per recipient; settings live in `av_settings` (`UpdateDmSettingsMutation`). Note that 1:1 calling is additionally gated server-side by X's rollout. ## GraphQL operations `POST https://api.x.com/graphql//`. The id is the `x-apollo-operation-id`; the full query is sent in the body so it isn't strictly required. | Operation | Operation id | Backend field | | --- | --- | --- | | `SendMessageCreateMutation` | `TWRPP7gnKwV_R8-tE-Dd3Q` | `xchat_send_create_message_event` | | `SendMessageEventMutation` | `G7WwJGKvTBVb-BXhZNSVMw` | `xchat_send_message_event` | | `DeleteMessageMutation` | `4gsDQKEmYkOtvsSIpHXdQA` | `xchat_delete_messages` | | `AddEncryptedConversationKeysMutation` | `4V1KC8ue2tHHvRuIzeczdg` | `xchat_add_encrypted_conversation_key` | | `AddXChatPublicKeyMutation` | `CQsk6GRuWAVabyXqqEG1sA` | `user_add_public_key` | | `DeleteXChatPublicKeyMutation` | `W5iiIL1MVw4vomq-zLPHUQ` | `user_delete_public_key` | | `GetPublicKeys` | `GJQbOZALDO5D3Zp2IZhH6w` | `user_results_by_rest_ids` | | `GenerateXChatTokenMutation` | `Qh3fZRjPPtPoHYR_2sCZsA` | `user_get_x_chat_auth_token` | | `MuteConversationMutation` | `6iDsxSkhGLvdiJpqtAtzTQ` | `xchat_mute_conversation` | | `UnmuteConversationMutation` | `_f8wd8RlQCCysv8yMKeiaw` | `xchat_unmute_conversation` | | `UpdateConversationTTLMutation` | `Gu3kCEwNN2V-Az8NDk30Zg` | `xchat_update_conversation_message_duration` | | `RemoveConversationTTLMutation` | `EqSXvxskUyw99ARuIbhYlg` | `xchat_remove_conversation_message_duration` | | `AcceptMessageRequestMutation` | `4YtAUhUwROL6ejia63Lj6Q` | `user_add_trust` | | `GetInboxPageRequestQuery` | `dVXHY3CBFIw_Gi6eaAum-w` | `get_inbox_page` | | `GetConversationPageQuery` | `IVlXls9JTnbgQ1gxsGAfJA` | `get_conversation_page` | | `GetMessageEventsPageQuery` | `OaSNyAhxUZ9AaW2z9cC26A` | `get_message_events_page` | | `GetMessageRequestsPageQuery` | `B4ibdNFzMv5MBhhxk3CyKw` | `get_message_requests_page` | | `DmAvPermissionsQuery` | `kfX5AHDKZrivyHwCaz68mQ` | `get_av_permissions` | | `GifSearchQuery` | `ciUL4BnRPKal2uL1fL2aHw` | `gif_search_slice` | | `InitializeXChatMediaUploadMutation` | `vTsSDEpF4eVYbR-waSl37g` | `xchat_initialize_media_upload` | | `FinalizeXChatMediaUploadMutation` | `P1CLOMdiMe9ii1MdIJbhcQ` | `xchat_finalize_media_upload` | | `CreateGroupConversationMutation` | `dKl4aC-sBqQWgRhkQXV2wg` | `xchat_create_group` | | `EnableGroupInviteMutation` | `WlxTMdzK_uh-miHVHXv15g` | `xchat_enable_group_invite` | | `RequestToJoinGroupMutation` | `cV0VjzT5UDJW3cbcvALYOg` | `xchat_request_join_group` | The full set of ~67 operations is available through [`client.xchat.gql(name, variables)`](/xchat/advanced#client-xchat-gql-name-variables). --- --- url: /discovery/search.md --- # Search Search tweets, users, media, and more across Twitter/X. Most of these support [Search operators](../more/search-operators.md). ## `search.tweets(query, opts?)` Search for tweets (Top results). Uses the GraphQL `SearchTimeline` query with `product: "Top"`. | Option | Type | Description | | ------------------ | -------- | ------------------------------------------------ | | `query` | `string` | Search query (supports Twitter search operators) | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.querySource` | `string` | Query source (default `"typed_query"`) | | `opts.product` | `string` | Override the product type (default `"Top"`) | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets, nextCursor } = await client.search.tweets("javascript"); // With search operators const { tweets: elonTweets } = await client.search.tweets("from:elonmusk AI"); // Paginate const page2 = await client.search.tweets("javascript", { cursor: nextCursor }); ``` ## `search.latest(query, opts?)` Search for tweets sorted by latest (chronological). Uses the GraphQL `SearchTimeline` query with `product: "Latest"`. | Option | Type | Description | | ------------------ | -------- | -------------------------------------- | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.querySource` | `string` | Query source (default `"typed_query"`) | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.search.latest("breaking news"); ``` ## `search.users(query, opts?)` Search for user profiles. Uses the GraphQL `SearchTimeline` query with `product: "People"`. | Option | Type | Description | | ------------------ | -------- | -------------------------------------- | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.querySource` | `string` | Query source (default `"typed_query"`) | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { users } = await client.search.users("elon"); ``` ## `search.media(query, opts?)` Search for tweets containing media (images, videos, GIFs). Uses the GraphQL `SearchTimeline` query with `product: "Media"`. | Option | Type | Description | | ------------------ | -------- | -------------------------------------- | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.querySource` | `string` | Query source (default `"typed_query"`) | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.search.media("cute cats"); ``` ## `search.lists(query, opts?)` Search for Twitter/X lists. Uses the GraphQL `SearchTimeline` query with `product: "Lists"`. | Option | Type | Description | | ------------------ | -------- | -------------------------------------- | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.querySource` | `string` | Query source (default `"typed_query"`) | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.search.lists("tech news"); ``` ## `search.typeahead(query, params?)` Get typeahead/autocomplete suggestions. Uses the v1.1 `search/typeahead` endpoint. Returns events, users, topics, and lists matching the query. | Param | Type | Description | | -------------------- | -------- | ------------------------------------------------------- | | `query` | `string` | Search query | | `params.src` | `string` | Source context (default `"search_box"`) | | `params.result_type` | `string` | Types to return (default `"events,users,topics,lists"`) | ```js const suggestions = await client.search.typeahead("elon"); console.log(suggestions.users); console.log(suggestions.topics); ``` ## `search.communities(query, opts?)` Search for posts within communities globally. Uses the GraphQL `GlobalCommunitiesPostSearchTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.search.communities("web development"); ``` ## `search.communitiesLatest(query, opts?)` Search for the latest posts within communities globally. Uses the GraphQL `GlobalCommunitiesLatestPostSearchTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const { tweets } = await client.search.communitiesLatest("open source"); ``` ## `search.gifs(query, params?)` Search for GIFs via the v1.1 `foundmedia/search` endpoint. Returns GIF items with thumbnail, preview, and original image URLs. | Param | Type | Description | | --------------- | -------- | ----------------- | | `query` | `string` | Search query | | `params.cursor` | `string` | Pagination cursor | **Response:** | Field | Type | Description | | -------- | --------- | ----------------------------------- | | `items` | `array` | Array of GIF items | | `cursor` | `string?` | Cursor for the next page, or `null` | Each item in `items` has: | Field | Type | Description | | ------------------ | -------- | ----------------------------------------------------------------------------- | | `id` | `string` | Unique item ID (e.g. `"giphy_04DMYESomjb6BCBNB8"`) | | `item_type` | `string` | Always `"gif"` | | `provider` | `object` | Provider info (`name`, `display_name` - e.g. `"giphy"`, `"riffsy"` for Tenor) | | `url` | `string` | Canonical URL of the GIF | | `alt_text` | `string` | Alt text / description | | `original_image` | `object` | Full-size image (`url`, `width`, `height`, `byte_count`, `still_image_url`) | | `preview_image` | `object` | Preview image (same shape as `original_image`) | | `thumbnail_images` | `array` | Array of thumbnail images (same shape as `original_image`) | ```js const { items, cursor } = await client.search.gifs("funny cat"); console.log(items[0].original_image.url); // GIF URL console.log(items[0].alt_text); // "Funny Cat GIF" console.log(items[0].provider.name); // "giphy" or "riffsy" (Tenor) // Paginate const page2 = await client.search.gifs("funny cat", { cursor }); ``` ## `search.adaptive(query, params?)` Perform an "adaptive search" via the v2 API. Uses the v2 `search/adaptive` endpoint. Returns raw search results with full tweet and user objects. ::: warning This hasn't been fully tested yet and may have some quirks. It also doesn't support all the operators and features of the normal GraphQL search. **Using the [normal search methods](#search-tweets-query-opts) over this one is recommended for most use cases** unless you have a specific reason for needing to use v2 search or want to experiment with it. ::: | Param | Type | Description | | ----------------------------- | -------- | --------------------------------------- | | `query` | `string` | Search query | | `params.count` | `number` | Number of results (default 20) | | `params.query_source` | `string` | Query source (default `"typed_query"`) | | `params.pc` | `number` | Promoted content flag (default 1) | | `params.spelling_corrections` | `number` | Enable spelling corrections (default 1) | ```js const results = await client.search.adaptive("machine learning", { count: 50 }); ``` ## Full example ```js // Search tweets with different modes const { tweets: top } = await client.search.tweets("React vs Vue"); const { tweets: latest } = await client.search.latest("React vs Vue"); const { tweets: mediaTweets } = await client.search.media("React vs Vue"); // Search for users const { users: devs } = await client.search.users("javascript developer"); // Get autocomplete suggestions const suggestions = await client.search.typeahead("type"); // Search for GIFs const { items: gifs, cursor: gifCursor } = await client.search.gifs("celebration"); console.log(gifs[0].original_image.url); // Search with the v2 adaptive endpoint const adaptive = await client.search.adaptive("web development", { count: 30 }); // Search within communities const { tweets: communityResults } = await client.search.communities("open source"); // Advanced search with operators const { tweets: advanced } = await client.search.tweets( 'from:github "open source" min_faves:100 lang:en since:2025-01-01', ); // Paginate through results let cursor; for (let i = 0; i < 5; i++) { const page = await client.search.tweets("javascript", { count: 20, cursor }); // process page.tweets... cursor = page.nextCursor; if (!cursor) break; } // Find lists about a topic const { tweets: listResults } = await client.search.lists("machine learning"); ``` --- --- url: /discovery/grok.md --- # Grok Chat with Grok, X's built-in AI, and manage your Grok conversations. Grok replies stream back from X as newline-delimited JSON; emusks parses that stream into a single result object for you. ::: tip The high-level `grok.ask()` is all you need for one-shot questions. Use `grok.createConversation()` + `grok.respond()` when you want to manage conversation IDs yourself. ::: ## `grok.ask(message, opts?)` Ask Grok a question. Creates a conversation (unless you pass `opts.conversationId`), sends the message, and returns the parsed response. | Option | Type | Description | | ----------------------- | --------- | ---------------------------------------------------------------- | | `message` | `string` | Your prompt | | `opts.conversationId` | `string` | Continue an existing conversation instead of creating a new one | | `opts.model` | `string` | Model option id (default `grok-3-latest`, see `grok.models()`) | | `opts.reasoning` | `boolean` | Enable reasoning mode (default `false`) | | `opts.deepsearch` | `boolean` | Enable DeepSearch (default `false`) | | `opts.systemPromptName` | `string` | Named system prompt | | `opts.fileAttachments` | `array` | Attachment objects to include with the prompt | | `opts.returnSearchResults` | `boolean` | Include web search results (default `true`) | | `opts.returnCitations` | `boolean` | Include citations (default `true`) | | `opts.raw` | `boolean` | Return the raw NDJSON stream as a string | | `opts.body` | `object` | Merge extra fields into the request body | ```js const res = await client.grok.ask("what's the latest on the falcon 9?"); console.log(res.message); console.log(res.conversationId); // continue the same conversation const followUp = await client.grok.ask("and the booster landing?", { conversationId: res.conversationId, }); // reasoning mode const math = await client.grok.ask("what is 17 * 23? show your reasoning", { reasoning: true, }); console.log(math.reasoning, math.message); ``` The returned object: | Field | Type | Description | | ---------------------- | ---------- | ------------------------------------------------- | | `conversationId` | `string` | The conversation this response belongs to | | `message` | `string` | The assembled final answer | | `reasoning` | `string` | The assembled reasoning/thinking trace | | `followUpSuggestions` | `array` | Suggested follow-up prompts | | `webResults` | `array` | Web search results, when search was used | | `citations` | `array` | Citations, when returned | | `images` | `array` | Generated image attachments, when any | | `agentChatItemId` | `string` | The response message id (use it to delete) | | `chunks` | `array` | Every raw parsed line from the stream | ## `grok.respond(conversationId, message, opts?)` Low-level: send a message to an existing conversation. Same `opts` as `ask`. Returns the parsed response (without re-wrapping the conversation id). ```js const id = await client.grok.createConversation(); const res = await client.grok.respond(id, "hello"); ``` ## `grok.createConversation()` Create an empty Grok conversation and return its id. Uses the GraphQL `CreateGrokConversation` mutation. ```js const conversationId = await client.grok.createConversation(); ``` ## `grok.home()` Get the Grok home payload: eligibility, default model, and available model options. Uses the GraphQL `GrokHome` query. ```js const home = await client.grok.home(); console.log(home.eligible_for_grok, home.default_grok_model_option_id); ``` ## `grok.models()` Convenience wrapper around `grok.home()` that returns just the `grok_model_options` array. ```js const models = await client.grok.models(); // [{ id: "grok-3-latest", name: "Fast", ... }] ``` ## `grok.history(opts?)` List your Grok conversations. Uses the GraphQL `GrokHistory` query. | Option | Type | Description | | ------------- | -------- | ----------------- | | `opts.count` | `number` | Number of results | | `opts.cursor` | `string` | Pagination cursor | ```js const { items } = await client.grok.history({ count: 20 }); ``` ## `grok.conversation(restId, opts?)` Fetch the items (messages) in a conversation by its rest id. Uses the GraphQL `GrokConversationItemsByRestId` query. ```js const conv = await client.grok.conversation("2062645889592938576"); console.log(conv.grok_conversation_items_by_rest_id.items); ``` ## `grok.mediaHistory(opts?)` List your Grok-generated media. Uses the GraphQL `GrokMediaHistory` query. ```js const media = await client.grok.mediaHistory(); ``` ## `grok.search(query, opts?)` Search your Grok conversations. Uses the GraphQL `SearchGrokConversations` query. ```js const results = await client.grok.search("falcon 9"); ``` ## `grok.pinned()` / `grok.pin(conversationId)` / `grok.unpin(conversationId)` Manage pinned conversations. Use the GraphQL `GrokPinnedConversations`, `GrokPinConversation`, and `GrokUnpinConversation` operations. ```js await client.grok.pin("2062645889592938576"); const { items } = await client.grok.pinned(); await client.grok.unpin("2062645889592938576"); ``` ## `grok.deleteMessage(conversationId, chatItemId)` Delete a single message from a conversation. Uses the GraphQL `DeleteGrokMessage` mutation. ```js const res = await client.grok.ask("hi"); await client.grok.deleteMessage(res.conversationId, res.agentChatItemId); ``` ## `grok.clear()` Delete all your Grok conversations. Uses the GraphQL `ClearGrokConversations` mutation. ```js await client.grok.clear(); ``` ## `grok.share(shareId)` Resolve a shared Grok conversation by its share id. Uses the GraphQL `GrokShare` query. ```js const shared = await client.grok.share("some_share_id"); ``` ## `grok.setPreferences(params)` Update your Grok preferences. Uses the GraphQL `SetGrokPreferences` mutation. ```js await client.grok.setPreferences({ default_grok_mode: "Normal" }); ``` --- --- url: /discovery/immersive.md --- # Immersive feed X's vertical, full-screen video feed (the "immersive" media viewer you get when you tap into a video). You seed it with a tweet id and get a timeline of full-screen media to page through. ## `immersive.media(pinnedTweetId, opts?)` The immersive media feed, seeded from a tweet. Uses the GraphQL `ImmersiveMedia` query. | Option | Type | Description | | -------------- | -------- | -------------------------------------------------- | | `pinnedTweetId`| `string` | The tweet to start the feed from | | `opts.pageName`| `string` | Surface name (default `immersive_video`) | ```js const feed = await client.immersive.media("2062156420066185217"); console.log(feed.timeline); ``` ## `immersive.profile(pinnedTweetId, opts?)` The immersive feed scoped to a single author's media. Uses the GraphQL `ImmersiveProfile` query. ```js const feed = await client.immersive.profile("2062156420066185217"); ``` --- --- url: /discovery/spaces.md --- # Spaces Get, search, and interact with Twitter/X Spaces (live audio rooms). ## `spaces.get(spaceId)` Get details about a Space by its ID. Uses the GraphQL `AudioSpaceById` query. ```js const space = await client.spaces.get("1eaKbrPZlbwKX"); console.log(space); ``` ## `spaces.search(query, opts?)` Search for Spaces. Uses the GraphQL `AudioSpaceSearch` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `query` | `string` | Search query | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const results = await client.spaces.search("tech talk"); // Paginate const next = await client.spaces.search("tech talk", { cursor: "DAABCgAB..." }); ``` ## `spaces.browseTopics(opts?)` Browse available Space topics. Uses the GraphQL `BrowseSpaceTopics` query. | Option | Type | Description | | ---------------- | -------- | ---------------------------- | | `opts.variables` | `object` | Additional GraphQL variables | ```js const topics = await client.spaces.browseTopics(); ``` ## `spaces.subscribe(spaceId)` Subscribe to a scheduled Space to get notified when it starts. Uses the GraphQL `SubscribeToScheduledSpace` mutation. ```js await client.spaces.subscribe("1eaKbrPZlbwKX"); ``` ## `spaces.unsubscribe(spaceId)` Unsubscribe from a scheduled Space. Uses the GraphQL `UnsubscribeFromScheduledSpace` mutation. ```js await client.spaces.unsubscribe("1eaKbrPZlbwKX"); ``` ## `spaces.addSharing(spaceId)` Share a Space (add sharing). Uses the GraphQL `AudioSpaceAddSharing` mutation. ```js await client.spaces.addSharing("1eaKbrPZlbwKX"); ``` ## `spaces.deleteSharing(spaceId)` Remove sharing from a Space. Uses the GraphQL `AudioSpaceDeleteSharing` mutation. ```js await client.spaces.deleteSharing("1eaKbrPZlbwKX"); ``` ## Full example ```js // Search for live spaces const results = await client.spaces.search("AI discussion"); // Get details about a specific space const space = await client.spaces.get("1eaKbrPZlbwKX"); // Browse available topics const topics = await client.spaces.browseTopics(); // Subscribe to an upcoming space await client.spaces.subscribe("1eaKbrPZlbwKX"); // Share a space await client.spaces.addSharing("1eaKbrPZlbwKX"); // Later, unsubscribe await client.spaces.unsubscribe("1eaKbrPZlbwKX"); ``` --- --- url: /discovery/trends.md --- # Trends Explore trending topics, manage trend settings, and interact with trends. ## `trends.available()` Get available trend locations. Uses the v1.1 `trends/available` endpoint. ```js const locations = await client.trends.available(); ``` ## `trends.history(opts?)` Get your trend history. Uses the GraphQL `TrendHistory` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const history = await client.trends.history(); // Paginate const next = await client.trends.history({ cursor: "DAABCgAB..." }); ``` ## `trends.relevantUsers(trendName, opts?)` Get users relevant to a trend. Uses the GraphQL `TrendRelevantUsers` query. | Option | Type | Description | | ---------------- | -------- | ---------------------------- | | `trendName` | `string` | The trend name or hashtag | | `opts.variables` | `object` | Additional GraphQL variables | ```js const users = await client.trends.relevantUsers("JavaScript"); ``` ## `trends.explore(opts?)` Get the Explore page content (trending topics, events, etc.). Uses the GraphQL `ExplorePage` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const explore = await client.trends.explore(); // Paginate const next = await client.trends.explore({ count: 40, cursor: "DAABCgAB..." }); ``` ## `trends.exploreSidebar(opts?)` Get the Explore sidebar content. Uses the GraphQL `ExploreSidebar` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const sidebar = await client.trends.exploreSidebar(); ``` ## `trends.report(trendId)` Report a trend. Uses the GraphQL `ReportTrend` mutation. ```js await client.trends.report("trend_id_123"); ``` ## `trends.save(trendId)` Save a trend. Uses the GraphQL `SaveTrend` mutation. ```js await client.trends.save("trend_id_123"); ``` ## `trends.action(trendId, action)` Perform an action on a trend. Uses the GraphQL `ActionTrend` mutation. | Param | Type | Description | | --------- | -------- | --------------------- | | `trendId` | `string` | The trend ID | | `action` | `string` | The action to perform | ```js await client.trends.action("trend_id_123", "dismiss"); ``` ## `trends.getById(trendId)` Get an AI-generated trend summary by its REST ID. Uses the GraphQL `AiTrendByRestId` query. ```js const trend = await client.trends.getById("trend_id_123"); ``` ## `trends.exploreSettings()` Get your Explore page settings (location, etc.). Uses the v2 `guide/get_explore_settings` endpoint. ```js const settings = await client.trends.exploreSettings(); ``` ## `trends.setExploreSettings(params?)` Update your Explore page settings. Uses the v2 `guide/set_explore_settings` endpoint. ```js await client.trends.setExploreSettings({ location: { woeid: 23424977 }, // United States }); ``` ## Full example ```js // See what's trending on the Explore page const explore = await client.trends.explore(); // Check available trend locations const locations = await client.trends.available(); // Get explore settings and update location const settings = await client.trends.exploreSettings(); await client.trends.setExploreSettings({ location: { woeid: 44418 }, // London }); // See who's involved in a trend const users = await client.trends.relevantUsers("#JavaScript"); // Get AI summary for a trend const summary = await client.trends.getById("trend_id_123"); // View sidebar content const sidebar = await client.trends.exploreSidebar(); // Browse your trend history const history = await client.trends.history(); // Save an interesting trend await client.trends.save("trend_id_456"); // Report a problematic trend await client.trends.report("trend_id_789"); ``` --- --- url: /discovery/topics.md --- # Topics Follow, unfollow, and manage Twitter/X topics. ## `topics.follow(topicId)` Follow a topic. Uses the GraphQL `TopicFollow` mutation. ```js await client.topics.follow("123456789"); ``` ## `topics.unfollow(topicId)` Unfollow a topic. Uses the GraphQL `TopicUnfollow` mutation. ```js await client.topics.unfollow("123456789"); ``` ## `topics.notInterested(topicId)` Mark a topic as "Not Interested". Uses the GraphQL `TopicNotInterested` mutation. ```js await client.topics.notInterested("123456789"); ``` ## `topics.undoNotInterested(topicId)` Undo a "Not Interested" action on a topic. Uses the GraphQL `TopicUndoNotInterested` mutation. ```js await client.topics.undoNotInterested("123456789"); ``` ## `topics.get(topicId)` Get a topic by its REST ID. Uses the GraphQL `TopicByRestId` query. ```js const topic = await client.topics.get("123456789"); ``` ## `topics.landingPage(topicId, opts?)` Get the landing page for a topic (tweets, related content). Uses the GraphQL `TopicLandingPage` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const page = await client.topics.landingPage("123456789"); // Paginate const next = await client.topics.landingPage("123456789", { cursor: "DAABCgAB...", }); // Fetch more const more = await client.topics.landingPage("123456789", { count: 50 }); ``` ## `topics.toFollow(opts?)` Get suggested topics to follow (sidebar suggestions). Uses the GraphQL `TopicToFollowSidebar` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const suggestions = await client.topics.toFollow(); ``` ## `topics.manage(opts?)` Get your topics management page (all followed topics, not interested topics). Uses the GraphQL `TopicsManagementPage` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const managed = await client.topics.manage(); ``` ## `topics.picker(opts?)` Get the topic picker page (for onboarding or discovery). Uses the GraphQL `TopicsPickerPage` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const picker = await client.topics.picker(); ``` ## `topics.pickerById(topicId, opts?)` Get the topic picker page for a specific topic. Uses the GraphQL `TopicsPickerPageById` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `topicId` | `string` | The topic ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const picker = await client.topics.pickerById("123456789"); ``` ## `topics.viewing(userId, opts?)` View the topics another user follows. Uses the GraphQL `ViewingOtherUsersTopicsPage` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `userId` | `string` | The user's REST ID | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const userTopics = await client.topics.viewing("44196397"); ``` ## Full example ```js // Discover topics to follow const suggestions = await client.topics.toFollow(); // Browse the topic picker const picker = await client.topics.picker(); // Follow a topic await client.topics.follow("123456789"); // Get topic details const topic = await client.topics.get("123456789"); // Browse topic content const content = await client.topics.landingPage("123456789", { count: 30 }); // Paginate through topic content const page2 = await client.topics.landingPage("123456789", { cursor: "DAABCgAB...", }); // Mark a topic as not interesting await client.topics.notInterested("987654321"); // Undo that action await client.topics.undoNotInterested("987654321"); // Manage your followed topics const managed = await client.topics.manage(); // See what topics another user follows const theirTopics = await client.topics.viewing("44196397"); // Unfollow a topic await client.topics.unfollow("123456789"); ``` --- --- url: /jetfuel.md --- # Jetfuel Jetfuel is Twitter's new server-driven UI framework which is used for the Creator hub, the new signup/login onboarding flow, the sports and live-events hub, Stories, and more. emusks talks to the Jetfuel API and decodes the binary into plain JSON for you: ```js import Emusks from "emusks"; const client = new Emusks(); await client.login("your_auth_token"); // example: global top-posts leaderboard, as real tweets const { tweets } = await client.jetfuel.topPosts({ engagement: "Likes", period: "Daily", country: "USA", }); for (const t of tweets) console.log(t.text); ``` ## What you can do | Section | Methods | | --- | --- | | [Top posts](/jetfuel/top-posts) | `topPosts`, `topPostsFeed`, `topPostsTimelineId`, `inspirationPage` | | [Surfaces](/jetfuel/surfaces) | `stories`, `storiesRemote`, `newsArticle`, `sportHome`, `league`, `game`, `event`, `brackets`, `bracketView`, `creatorsStudio`, `onboardingTopics`, `health` | | [Requests](/jetfuel/requests) | `client.jf()`, `page`, `remote`, `raw`, `ogImageUrl`, `mediaPreviewUrl` | | [Wire format](/jetfuel/wire-format) | `decode`, the binary decoder, and the `Timeline:` token format | ::: warning Jetfuel is freshly reverse-engineered and X changes it without notice. The decoder is best-effort and structural: it recovers framing, strings, the node tree, atom references and reference lists, but it does not resolve the interned style atoms (their dictionary lives in a lazy client chunk). Treat decoded pages as inspectable structure, not a stable schema. ::: --- --- url: /jetfuel/top-posts.md --- # Top posts The Creator hub's "Inspiration" surface (`x.com/i/jf/creators/inspiration/top_posts`) is a global top-posts leaderboard: the most-engaged public posts, filterable by engagement metric, time window and country. It is the one Jetfuel feed that resolves to real tweets, so emusks gives it a proper helper. ## `jetfuel.topPosts(opts?)` Fetch the leaderboard as parsed tweets. Returns the standard timeline shape: `{ tweets, users, nextCursor, previousCursor, raw }`, plus the echoed `engagement`, `period`, `country` and the resolved `timelineId`. | Option | Type | Description | | --- | --- | --- | | `opts.engagement` | `string` | Metric to rank by (default `"Likes"`). See [engagements](#engagements). | | `opts.period` | `string` | Time window (default `"Daily"`). One of `Daily`, `Weekly`, `Monthly`. | | `opts.country` | `string` | ISO-3 country code, or `"All"` for the global board (default `"All"`). | | `opts.count` | `number` | Page size (default 20). | | `opts.cursor` | `string` | Pagination cursor (from `nextCursor`). | | `opts.viaRemote` | `boolean` | Fetch the binary feed to obtain the timeline token instead of building it locally. Costs an extra request; off by default. | | `opts.raw` | `boolean` | Return the unparsed `GenericTimelineById` GraphQL response instead of parsed tweets. | ```js // most-liked posts in the US today const { tweets } = await client.jetfuel.topPosts({ engagement: "Likes", period: "Daily", country: "USA", }); // most-replied globally this week, then paginate const first = await client.jetfuel.topPosts({ engagement: "Replies", period: "Weekly" }); const next = await client.jetfuel.topPosts({ engagement: "Replies", period: "Weekly", cursor: first.nextCursor, }); ``` By default `topPosts` builds the timeline token locally (no extra request) and resolves it directly. The token is deterministic, so this is identical to what the live UI fetches. Pass `viaRemote: true` if you would rather have the server hand you the token (useful as a sanity check, or if X ever changes the token format). ### Engagements The server only honors these values; anything else silently falls back to `Likes`. | Value | UI label | | --- | --- | | `Likes` | Most Likes | | `Replies` | Most Replies | | `Bookmarks` | Most Bookmarks | | `Quotes` | Most Quotes | | `VideoQualityViews` | Most Video Views | Exported as `ENGAGEMENTS`. Periods are exported as `PERIODS` (`Daily`, `Weekly`, `Monthly`); any other period silently falls back to `Daily`. The supported country codes are exported as `COUNTRIES`. ```js import { ENGAGEMENTS, PERIODS, COUNTRIES } from "emusks/src/helpers/jetfuel.js"; ``` ## `jetfuel.topPostsTimelineId(opts?)` Build the `GenericTimelineById` token for a given leaderboard query, offline, without any network call. Same `engagement` / `period` / `country` options as `topPosts`. This is exactly the token the feed returns. ```js const id = client.jetfuel.topPostsTimelineId({ engagement: "Quotes", country: "BRA", period: "Monthly" }); // "VGltZWxpbmU6DADKCwABAAAABlF1b3Rlcw..." // resolve it yourself, e.g. with extra GraphQL variables const raw = await client.graphql("GenericTimelineById", { variables: { timelineId: id, count: 40 } }); ``` ## `jetfuel.topPostsFeed(opts?)` Fetch the raw binary inspiration feed (`creators/inspiration/remote/urt`) and return what it actually contains: the embedded timeline token, its decoded parameters, and the full decoded wire format. | Field | Description | | --- | --- | | `token` | The base64 `Timeline:` token (feed into `GenericTimelineById`). | | `params` | `{ engagement, country, period }` decoded from the token. | | `decoded` | The full decoded payload (see [wire format](/jetfuel/wire-format)). | | `status` | HTTP status. | ```js const feed = await client.jetfuel.topPostsFeed({ engagement: "Likes", period: "Daily", country: "PRT" }); feed.token; // "VGltZWxpbmU6DADK..." feed.params; // { engagement: "Likes", country: "PRT", period: "Daily" } ``` ## `jetfuel.inspirationPage(opts?)` Fetch and decode the leaderboard **page** itself (the UI shell: the country and language filter dropdowns and the engagement tabs), not the post data. Returns the decoded wire format. Useful if you want the canonical list of countries/languages X exposes. ```js const page = await client.jetfuel.inspirationPage(); page.strings.filter((s) => s.length > 2); // "United States", "Canada", "Brazil", ... ``` --- --- url: /jetfuel/surfaces.md --- # Surfaces Beyond [top posts](/jetfuel/top-posts), Jetfuel powers a lot of X: the Creator hub, Stories, News, the sports and live-events hub, onboarding, and a few framework routes. Most of these are pure UI (their payloads describe components, not data you can map to a clean model), so these helpers fetch and [decode](/jetfuel/wire-format) the page and let you read its `.strings`, `.nodes` and `.timelineTokens`. Each one is just sugar over [`client.jetfuel.page(route)`](/jetfuel/requests), so anything here also works with a raw route string. All accept the same `opts` as `page` (`params`, `theme`, `headers`, etc.). ## Stories & News ### `jetfuel.stories(opts?)` The Jetfuel Stories browser (`stories/home`): a categorized news hub (Top Stories, For You, Politics, Sports, Business & Finance, Technology, Music, Movies & TV, Science, Anime, ...). ```js const home = await client.jetfuel.stories(); home.strings.includes("Top Stories"); // true ``` ### `jetfuel.storiesRemote({ category }, opts?)` A single Stories category feed (`stories/storiesRemote`). A category is required; a bare call returns an empty stub. ```js const sports = await client.jetfuel.storiesRemote({ category: "Sports" }); ``` ### `jetfuel.newsArticle(id, opts?)` A Jetfuel news article page (`news/article/id/`). The id comes from a Stories/News card; a bare `news/article` returns a 400. ```js const article = await client.jetfuel.newsArticle("1895256113429241856"); ``` ## Sports & live events Jetfuel powers X's sports and live-events hub, including fantasy leagues and brackets. Event ids are not guessable; they come from timeline score cards. Sports not yet migrated render an `error_not_found` placeholder. ### `jetfuel.sportHome(sport, opts?)` A sport's landing page (`/home`), e.g. `f1`, `nba`, `nfl`. ```js const f1 = await client.jetfuel.sportHome("f1"); // rich: live race header, podiums, stories ``` ### `jetfuel.league(sport, opts?)` A sport's fantasy-league landing (`/league/home`), e.g. `nba`, `nfl`, `soccer`, `ncaamb`. ### `jetfuel.game({ sport, id }, opts?)` A specific game / race / match detail page. The URL shape differs per sport, so emusks infers it (overridable with `{ noun, home }`): | Sport | Route shape | | --- | --- | | `nba`, `nfl` | `/game/home/id/` | | `f1` | `f1/race/home/id/` | | `nhl` | `nhl/game/id/` | | `soccer` | `soccer/match/id/` | The mapping is exported as `SPORT_EVENTS`. ```js await client.jetfuel.game({ sport: "f1", id: "1895256113429241856" }); await client.jetfuel.game({ sport: "soccer", id: "..." }); // custom sport/shape: await client.jetfuel.game({ sport: "ncaamb", id: "...", noun: "game", home: true }); ``` ### `jetfuel.event(id, opts?)` The generic Jetfuel event screen (`events/event?id=`). Event pages embed a `Timeline:` token, surfaced as `.timelineTokens` on the decoded result; you can feed it to `GenericTimelineById` like [top posts](/jetfuel/top-posts) does. ```js const ev = await client.jetfuel.event("1895256113429241856"); ev.timelineTokens; // ["VGltZWxpbmU6..."] ``` ### `jetfuel.brackets(opts?)` · `jetfuel.bracketView(userId?, opts?)` The March Madness bracket challenge (`brackets/home`, `brackets/view`, `brackets/view/id/`). Gated behind the `march_madness_brackets_enabled` flag; renders `error_not_found` when off. ## Creator hub & onboarding ### `jetfuel.creatorsStudio(opts?)` The Creator hub home (`creators/studio`), which links out to revenue sharing, subscriptions, inspiration and payouts. Most sub-pages are gated per account; fetch them directly with `page`: ```js await client.jetfuel.page("creators/revenue_sharing"); await client.jetfuel.page("creators/subscriptionsv2/eligibility"); await client.jetfuel.page("creators/stripe_connect"); ``` ### `jetfuel.onboardingTopics(opts?)` The onboarding "Pick your interests" catalog (`onboarding/remotes/topics`): the full topic list (News, Sports, Technology, Gaming, Memes, Cryptocurrency, ...) with select/deselect actions and topic ids in `.nodes`. ```js const topics = await client.jetfuel.onboardingTopics(); topics.nodes.filter((n) => n.type === "deselect_topic").map((n) => n.key); // ["News", "Sports", "Business & Finance", ...] ``` ## Framework ### `jetfuel.health(opts?)` The health check (`jf.x.com/health`). Returns the plain-text body. Served from the `jf.x.com` origin (not the jfapi data API), so this helper hits that origin directly. ```js await client.jetfuel.health(); // "I'm good, mom" ``` ### Images See [requests](/jetfuel/requests#image-helpers) for `ogImageUrl(tweetId)` and `mediaPreviewUrl(tweetId)`. --- --- url: /jetfuel/requests.md --- # Requests Beyond the [top-posts helpers](/jetfuel/top-posts), emusks gives you a low-level request primitive and thin fetch-and-decode wrappers so you can hit any Jetfuel route directly. ## `client.jf(route, opts?)` The low-level primitive, alongside `client.graphql`, `client.v1_1` and `client.v2`. It hits `https://x.com/i/jfapi/` with the same auth, fingerprint and proxy plumbing as the rest of the library, adds the Jetfuel-specific headers (`x-jf-client-theme`, `x-jf-v`), and returns the raw binary response. | Option | Type | Description | | --- | --- | --- | | `route` | `string` | Path under `/i/jfapi` (may include a query string). | | `opts.params` | `object` | Query params (merged into the URL). | | `opts.method` | `string` | HTTP method (default `"GET"`; use `"POST"` for `actions/` routes). | | `opts.body` | `string` | Request body for POST actions. | | `opts.theme` | `string` | `x-jf-client-theme` value (default `"business"`). | | `opts.jfVersion` | `string` | `x-jf-v` value (default `"JP-5"`). | | `opts.headers` | `object` | Extra headers (merged last). | | `opts.referer` | `string` | Override the `Referer` (defaults to the matching `i/jf` mount). | It resolves to: ```js { status, // HTTP status headers, // response headers contentType, // e.g. "text/plain" buffer, // Node Buffer of the raw bytes text(), // bytes as a UTF-8 string decode(), // bytes decoded into the structured wire-format tree } ``` ```js const res = await client.jf("creators/inspiration/remote/urt", { params: { engagement: "Likes", period: "Daily", country: "USA" }, }); res.status; // 200 res.buffer.length // 173 res.decode(); // { frame, strings, nodes, atoms, lists, timelineTokens } ``` ## `jetfuel.page(route, opts?)` Fetch a route and decode its binary payload into JSON in one call. Same options as `client.jf`. Returns the [decoded wire format](/jetfuel/wire-format) plus `status` and `contentType`. ```js const studio = await client.jetfuel.page("creators/studio"); studio.frame; // { payloadLen, count, footer } studio.strings; // every string in the page studio.nodes; // [{ type, key }, ...] ``` ## `jetfuel.remote(route, opts?)` Alias for `page`, named to read better when fetching `remote/` data feeds. ```js const feed = await client.jetfuel.remote("creators/inspiration/remote/urt", { params: { engagement: "Bookmarks", period: "Weekly" }, }); feed.timelineTokens; // ["VGltZWxpbmU6..."] ``` ## `jetfuel.raw(route, opts?)` Fetch a route and return the raw response (the `client.jf` result) without decoding. Use this when you want the bytes, the headers, or to decode later. ```js const res = await client.jetfuel.raw("creators/inspiration/top_posts"); res.buffer; // raw bytes res.decode(); // decode on demand ``` ## Image helpers Jetfuel also server-renders images for any public tweet. These helpers return the URLs (no auth needed to fetch them). ### `jetfuel.ogImageUrl(tweetId)` The Open Graph share card (1200x630 PNG): avatar, name, verified/affiliate badge, tweet text, media and the X logo. Always light theme. Private or deleted tweets return 404. ```js client.jetfuel.ogImageUrl("1895256113429241856"); // "https://jf.x.com/images/post/1895256113429241856" ``` ### `jetfuel.mediaPreviewUrl(tweetId)` A PNG re-render of a tweet's attached media (1200x935). Returns 404 for tweets with no media. ```js client.jetfuel.mediaPreviewUrl("1895256113429241856"); // "https://jf.x.com/images/media-preview/1895256113429241856" ``` ## Other routes The Jetfuel surface is large (the Creator hub, Stories, the sports and live-events hub, onboarding). Most routes are pure UI rather than data feeds, but you can fetch and inspect any of them: ```js await client.jetfuel.page("stories/home"); await client.jetfuel.page("creators/studio"); await client.jetfuel.page("nba/home"); ``` `actions/` routes are POST mutations. A bare GET returns a `400` or a validation message: ```js await client.jf("onboarding/actions/resendcode", { method: "POST", body: "..." }); ``` --- --- url: /jetfuel/wire-format.md --- # Wire format ## `jetfuel.decode(buffer)` Decode an already-fetched payload (a `Buffer`, `ArrayBuffer`, typed array, or binary string) into a structured tree. This is the same decoder `page`, `remote` and `res.decode()` use. ```js const res = await client.jetfuel.raw("creators/inspiration/top_posts"); const tree = client.jetfuel.decode(res.buffer); ``` Returns: | Field | Description | | --- | --- | | `frame` | `{ payloadLen, count, footer }`: the 11-byte framing header. | | `tokens` | Every decoded element in order, each tagged `string` / `node` / `atom` / `list` / `raw` with its byte `offset`. | | `strings` | Every length-prefixed UTF-8 string, in order. | | `nodes` | `[{ type, key }]`: each component node (`0x11` intro) paired with the two strings that name it. | | `atoms` | `[{ field, hash }]`: interned style/property references (the hash is a FarmHash u32). | | `lists` | `[{ kind, count, hashes }]`: typed reference lists (children, style sets, variants). | | `timelineTokens` | Any base64 `Timeline:` tokens embedded in the payload. | ```js tree.frame; // { payloadLen: 27389, count: 181, footer: "b40600" } tree.strings; // ["page", "inspiration_top_posts", "Country", "United States", ...] tree.nodes; // [{ type: "page", key: "inspiration_top_posts" }, { type: "select_country", key: "event_info" }, ...] tree.timelineTokens; // ["VGltZWxpbmU6..."] ``` ### The format, briefly * **Framing.** `uint16 LE` payload length, three zero bytes, a `uint8` count/version, then the payload, ending in a 3-byte footer. * **Strings.** Inline, single-byte length prefix, UTF-8: ` `. * **Node intro `0x11`.** Begins a component, followed by its `[typeString, keyString]`. * **Atom reference `02 00 00 00 `.** Binds a property slot to a globally interned style atom, identified by a 4-byte FarmHash. The same hash recurs across pages (a content-addressed dictionary), which is why the decoder surfaces them but cannot name them: the atom dictionary lives in a lazy client chunk. * **Reference list `03 00 00 00 00 `.** A typed collection of references. The decode is best-effort and structural. It reliably recovers framing, strings, the node tree, atom references and reference lists. It does not resolve interned atoms to their style definitions, and the leading symbol-table region produces some short noise strings. Treat the output as inspectable structure, not a stable schema. ## `Timeline:` tokens The creator-inspiration feed (`remote/urt`) does not return tweets. It returns a base64-encoded `Timeline:` token that the main timeline service resolves via `GenericTimelineById`. The thrift struct carries three string fields: engagement (id 1), country (id 3), period (id 6). emusks both builds and decodes these. They are exported from the parser: ```js import { buildTimelineToken, decodeTimelineToken } from "emusks/src/parsers/jetfuel.js"; const token = buildTimelineToken({ engagement: "Likes", country: "PRT", period: "Daily" }); // "VGltZWxpbmU6DADKCwABAAAABUxpa2VzCwADAAAAA1BSVAsABgAAAAVEYWlseQAA" decodeTimelineToken(token); // { engagement: "Likes", country: "PRT", period: "Daily" } ``` Because the token is deterministic, [`topPosts`](/jetfuel/top-posts) builds it locally and skips the binary feed entirely. `buildTimelineToken` produces byte-identical output to what the live server returns for the same parameters. ### `extractTimelineTokens(buffer)` Scan any payload for embedded base64 `Timeline:` tokens. Also exported from the parser, and surfaced as `timelineTokens` on every decode. ```js import { extractTimelineTokens } from "emusks/src/parsers/jetfuel.js"; const res = await client.jetfuel.raw("creators/inspiration/remote/urt", { params: { engagement: "Likes" } }); extractTimelineTokens(res.buffer); // ["VGltZWxpbmU6..."] ``` --- --- url: /account/settings.md --- # Settings Manage your general account settings including language, timezone, discoverability, and data saver preferences. ## `account.settings()` Get your current account settings. Uses the v1.1 `account/settings` endpoint. ```js const settings = await client.account.settings(); console.log(settings.screen_name); console.log(settings.language); console.log(settings.time_zone); ``` ## `account.updateSettings(params?)` Update your account settings. Uses the v1.1 `account/settings` endpoint (POST). | Param | Type | Description | | ------------------------------ | --------- | ----------------------------------- | | `params.language` | `string` | Language code (e.g. `"en"`, `"ja"`) | | `params.time_zone` | `string` | Timezone name (e.g. `"US/Eastern"`) | | `params.sleep_time_enabled` | `boolean` | Enable quiet hours | | `params.start_sleep_time` | `number` | Quiet hours start (0–23) | | `params.end_sleep_time` | `number` | Quiet hours end (0–23) | | `params.trend_location_woeid` | `number` | Trend location WOEID | | `params.discoverable_by_email` | `boolean` | Allow others to find you by email | | `params.discoverable_by_phone` | `boolean` | Allow others to find you by phone | ```js await client.account.updateSettings({ language: "en", time_zone: "America/New_York", discoverable_by_email: false, discoverable_by_phone: false, }); ``` ### Quiet Hours Suppress notifications during specific hours: ```js await client.account.updateSettings({ sleep_time_enabled: true, start_sleep_time: 23, end_sleep_time: 7, }); ``` ## `account.dataSaverMode()` Get the current data saver mode setting. Uses the GraphQL `DataSaverMode` query. ```js const mode = await client.account.dataSaverMode(); console.log(mode); ``` ## `account.setDataSaver(dataSaverMode)` Enable or disable data saver mode. When enabled, images load at lower quality and videos don't autoplay. ```js // Enable data saver await client.account.setDataSaver(true); // Disable data saver await client.account.setDataSaver(false); ``` ## `account.helpSettings()` Get help and support settings. Uses the v1.1 `help/settings` endpoint. ```js const help = await client.account.helpSettings(); ``` ## `account.rateLimitStatus(params?)` Check your current rate limit status across API endpoints. Uses the v1.1 `application/rate_limit_status` endpoint. | Param | Type | Description | | ------------------ | -------- | ------------------------------------------ | | `params.resources` | `string` | Comma-separated resource families to check | ```js // Check all rate limits const limits = await client.account.rateLimitStatus(); // Check specific resource families const specific = await client.account.rateLimitStatus({ resources: "statuses,friends,followers", }); ``` ## `account.emailNotificationSettings(params?)` Update email notification settings. Uses the GraphQL `WriteEmailNotificationSettings` mutation. ```js await client.account.emailNotificationSettings({ setting: "email_new_follower", enabled: false, }); ``` ## `account.viewerEmailSettings()` Get the current email notification settings. Uses the GraphQL `ViewerEmailSettings` query. ```js const emailSettings = await client.account.viewerEmailSettings(); ``` ## `account.multiList()` List all accounts in your multi-account session. Uses the v1.1 `account/multi/list` endpoint. ```js const accounts = await client.account.multiList(); ``` ## `account.logout()` Log out the current session. Uses the v1.1 `account/logout` endpoint. ```js await client.account.logout(); ``` ## `account.deactivate()` Deactivate your account. Uses the v1.1 `account/deactivate` endpoint. ::: danger This will deactivate your account. You have 30 days to reactivate before permanent deletion. ::: ```js await client.account.deactivate(); ``` ## Full example ```js // Check your current settings const settings = await client.account.settings(); console.log(`Language: ${settings.language}`); console.log(`Timezone: ${settings.time_zone}`); // Update language and timezone await client.account.updateSettings({ language: "en", time_zone: "America/New_York", }); // Disable discoverability await client.account.updateSettings({ discoverable_by_email: false, discoverable_by_phone: false, }); // Enable quiet hours overnight await client.account.updateSettings({ sleep_time_enabled: true, start_sleep_time: 23, end_sleep_time: 7, }); // Toggle data saver mode const mode = await client.account.dataSaverMode(); await client.account.setDataSaver(true); // Check rate limits before a batch operation const limits = await client.account.rateLimitStatus({ resources: "statuses,friends", }); // Manage email notification preferences const emailSettings = await client.account.viewerEmailSettings(); await client.account.emailNotificationSettings({ setting: "email_new_follower", enabled: false, }); // View all accounts in multi-account session const accounts = await client.account.multiList(); ``` --- --- url: /account/profile.md --- # Profile View your profile information, manage account labels, and check the availability of emails, phone numbers, and usernames. ## `account.viewer()` Get the currently authenticated user's full profile. Uses the GraphQL `Viewer` query. ```js const me = await client.account.viewer(); console.log(me.id); console.log(me.username); console.log(me.name); console.log(me.stats.followers.count); console.log(me.stats.following); ``` **Returns:** A parsed user object with all profile fields, stats, and verification status. ## `account.claims()` Get user claims such as verified status claims. Uses the GraphQL `GetUserClaims` query. ```js const claims = await client.account.claims(); console.log(claims); ``` ## `account.phoneState()` Get your profile's phone verification state. Uses the GraphQL `ProfileUserPhoneState` query. ```js const state = await client.account.phoneState(); console.log(state); ``` ## `account.accountLabel()` Get your current account label status (e.g. government, business, bot). Uses the GraphQL `UserAccountLabel` query. ```js const label = await client.account.accountLabel(); console.log(label); ``` ## `account.disableAccountLabel()` Remove your account label. Uses the GraphQL `DisableUserAccountLabel` mutation. ```js await client.account.disableAccountLabel(); ``` ## `account.enableVerifiedPhoneLabel()` Enable the verified phone label on your profile. Uses the GraphQL `EnableVerifiedPhoneLabel` mutation. ```js await client.account.enableVerifiedPhoneLabel(); ``` ## `account.disableVerifiedPhoneLabel()` Disable the verified phone label on your profile. Uses the GraphQL `DisableVerifiedPhoneLabel` mutation. ```js await client.account.disableVerifiedPhoneLabel(); ``` ## `account.emailAvailable(email)` Check if an email address is available for registration. Uses the v1.1 `users/email_available` endpoint. ```js const result = await client.account.emailAvailable("test@example.com"); console.log(result.valid); // true if available ``` ## `account.phoneAvailable(phone)` Check if a phone number is available. Uses the v1.1 `users/phone_number_available` endpoint. ```js const result = await client.account.phoneAvailable("+1234567890"); console.log(result); ``` ## `account.usernameAvailable(username)` Check if a username (handle) is available and get alternative suggestions. Uses the GraphQL `GetUsernameAvailabilityAndSuggestions` mutation. ```js const result = await client.account.usernameAvailable("desired_username"); console.log(result); ``` ## `account.emailPhoneInfo(params?)` Get the email and phone information associated with the account. Uses the v1.1 `users/email_phone_info` endpoint. ```js const info = await client.account.emailPhoneInfo(); console.log(info); ``` ## `account.resendConfirmationEmail()` Resend the confirmation email for your account if it hasn't been verified yet. Uses the v1.1 `account/resend_confirmation_email` endpoint. ```js await client.account.resendConfirmationEmail(); ``` ## Updating Your Profile Profile updates like changing your display name, bio, location, website, and profile images are available through the **Users** namespace. See the [Users documentation](/users/users) for details. ```js // Update display name, bio, location, and website await client.users.updateProfile({ name: "New Display Name", description: "My updated bio ✨", location: "San Francisco, CA", url: "https://example.com", }); // Update profile picture (base64 image data) await client.users.updateProfileImage(base64ImageData); // Update banner image await client.users.updateProfileBanner(base64BannerData); // Remove banner await client.users.removeProfileBanner(); ``` ## Full example ```js // Get your full profile const me = await client.account.viewer(); console.log(`Logged in as @${me.username}`); console.log(`Name: ${me.name}`); console.log(`Followers: ${me.stats.followers.count}`); console.log(`Following: ${me.stats.following}`); console.log(`Tweets: ${me.stats.posts}`); // Check your account label const label = await client.account.accountLabel(); console.log(`Account label: ${JSON.stringify(label)}`); // Check phone verification state const phoneState = await client.account.phoneState(); console.log(`Phone state: ${JSON.stringify(phoneState)}`); // Get claims (verification info) const claims = await client.account.claims(); // Check availability before changing your username const available = await client.account.usernameAvailable("new_handle"); console.log(`Username available: ${JSON.stringify(available)}`); // Check email availability const emailCheck = await client.account.emailAvailable("newemail@example.com"); // Get email and phone info on file const contactInfo = await client.account.emailPhoneInfo(); // Manage labels await client.account.enableVerifiedPhoneLabel(); // or await client.account.disableVerifiedPhoneLabel(); // Update your profile via the users namespace await client.users.updateProfile({ name: "Updated Name", description: "Building cool things 🚀", location: "Earth", }); ``` --- --- url: /account/security.md --- # Security Manage your account security: passwords, two-factor authentication, sessions, backup codes, and connected applications. ## `account.verifyPassword(password)` Verify a password against the current account. Useful for confirming the user's identity before sensitive operations. Uses the v1.1 `account/verify_password` endpoint. ```js const result = await client.account.verifyPassword("my_password"); console.log(result.status); // "ok" ``` ## `account.changePassword(currentPassword, newPassword)` Change your account password. Uses the v1.1 `account/change_password` endpoint. ::: warning Requires elevated access. Call `client.elevate(password)` first. ::: ```js await client.elevate("current_password"); await client.account.changePassword("current_password", "new_secure_password"); ``` ## `account.passwordStrength(password)` Check the strength of a password before setting it. Uses the v1.1 `account/password_strength` endpoint. ```js const strength = await client.account.passwordStrength("my_new_password_123"); console.log(strength); ``` ::: tip Always check password strength before calling `changePassword()` to ensure the new password meets Twitter's requirements. ::: ## Two-Factor Authentication ### `account.disable2FA()` Disable two-factor authentication on your account. Uses the v1.1 `account/login_verification_enrollment` endpoint. ::: warning Requires elevated access. Call `client.elevate(password)` first. ::: ```js await client.elevate("your_password"); await client.account.disable2FA(); ``` ### `account.remove2FAMethod(methodId)` Remove a specific 2FA method, such as a security key or authenticator app. Uses the v1.1 `account/login_verification/remove_method` endpoint. ```js await client.account.remove2FAMethod("method_id_here"); ``` ### `account.tempPassword()` Generate a temporary password for app-specific login. This is useful when your account has 2FA enabled and you need to authenticate in a context that doesn't support interactive 2FA. Uses the v1.1 `account/login_verification/temporary_password` endpoint. ```js const temp = await client.account.tempPassword(); console.log(temp); ``` ### `account.renameSecurityKey(methodId, name)` Rename a registered security key (e.g. a YubiKey or passkey). Uses the v1.1 `account/login_verification/rename_security_key_method` endpoint. ```js await client.account.renameSecurityKey("method_id", "My YubiKey 5"); ``` ## Backup Codes ### `account.backupCode()` Get your current backup code. Backup codes can be used to regain access to your account if you lose your 2FA device. Uses the v1.1 `account/backup_code` endpoint (GET). ```js const code = await client.account.backupCode(); console.log(`Your backup code: ${code}`); ``` ### `account.generateBackupCode()` Generate a new backup code, invalidating the previous one. Uses the v1.1 `account/backup_code` endpoint (POST). ```js const newCode = await client.account.generateBackupCode(); console.log(`New backup code: ${newCode}`); ``` ::: danger Generating a new backup code will invalidate your previous code. Make sure to store the new code in a safe place. ::: ## Sessions ### `account.sessions()` Get a list of all active sessions on your account, including device info, location, and login time. Uses the GraphQL `UserSessionsList` query. ```js const sessions = await client.account.sessions(); console.log(sessions); ``` ## Connected Apps ### `account.connectedApps()` List all third-party applications that have been granted access to your account. Uses the v1.1 `oauth/list` endpoint. ```js const apps = await client.account.connectedApps(); console.log(`${apps.length} connected apps`); ``` ### `account.revokeApp(token)` Revoke access for a connected third-party application. Uses the v1.1 `oauth/revoke` endpoint. ```js await client.account.revokeApp("app_token_here"); ``` ## Single Sign-On ### `account.deleteSSOConnection(connectionId)` Delete an SSO (Single Sign-On) connection linked to your account (e.g. Google or Apple sign-in). Uses the v1.1 `sso/delete_connection` endpoint. ```js await client.account.deleteSSOConnection("connection_id"); ``` ## `account.emailYourData()` Request an email containing a copy of your Twitter data (tweets, DMs, profile info, etc.). Uses the v1.1 `account/personalization/email_your_data` endpoint. ```js await client.account.emailYourData(); ``` ## Full example ```js // Verify current password const result = await client.account.verifyPassword("my_password"); console.log(`Password valid: ${result.status}`); // Check strength of a new password const strength = await client.account.passwordStrength("new_p@ssw0rd_2025!"); // Elevate session for sensitive operations await client.elevate("my_password"); // Change password await client.account.changePassword("my_password", "new_p@ssw0rd_2025!"); // View active sessions const sessions = await client.account.sessions(); console.log(`Active sessions: ${JSON.stringify(sessions)}`); // Manage backup codes const currentCode = await client.account.backupCode(); console.log(`Current backup code: ${currentCode}`); const newCode = await client.account.generateBackupCode(); console.log(`New backup code: ${newCode}`); // Review and revoke connected apps const apps = await client.account.connectedApps(); for (const app of apps) { console.log(`Connected app: ${JSON.stringify(app)}`); } // Revoke a specific app await client.account.revokeApp("app_token_here"); // Manage 2FA await client.elevate("your_password"); await client.account.disable2FA(); // Rename a security key await client.account.renameSecurityKey("method_id", "Office YubiKey"); // Remove a specific 2FA method await client.account.remove2FAMethod("method_id"); // Generate a temporary password for app login const temp = await client.account.tempPassword(); // Remove an SSO connection await client.account.deleteSSOConnection("connection_id"); // Request a copy of your data await client.account.emailYourData(); ``` --- --- url: /account/preferences.md --- # Preferences Manage your content preferences: muted keywords, advanced mute filters, and personalization settings. ## Muted Keywords Muted keywords let you hide tweets containing specific words, phrases, or hashtags from your timelines and notifications. ### `account.mutedKeywords()` Get all your currently muted keywords. Uses the v1.1 `mutes/keywords/list` endpoint. ```js const keywords = await client.account.mutedKeywords(); console.log(`${keywords.length} muted keywords`); ``` ### `account.updateMutedKeyword(params?)` Create or update a muted keyword. Uses the v1.1 `mutes/keywords/update` endpoint. | Param | Type | Description | | ---------------------- | -------- | ----------------------------------------------------------------- | | `params.id` | `string` | The keyword mute ID (omit to create a new mute) | | `params.keyword` | `string` | The word, phrase, or hashtag to mute | | `params.mute_surfaces` | `string` | Where to apply the mute (e.g. `"home_timeline,notifications"`) | | `params.duration` | `number` | Duration in seconds (`0` for forever, `86400` for 24 hours, etc.) | ```js // Mute a keyword everywhere, forever await client.account.updateMutedKeyword({ keyword: "spoiler", mute_surfaces: "home_timeline,notifications", duration: 0, }); // Mute a keyword for 24 hours await client.account.updateMutedKeyword({ keyword: "gameofthrones", mute_surfaces: "home_timeline,notifications", duration: 86400, }); // Update an existing muted keyword await client.account.updateMutedKeyword({ id: "keyword_id_123", keyword: "spoiler", mute_surfaces: "home_timeline", duration: 604800, // 7 days }); ``` ### `account.deleteMutedKeyword(keywordId)` Remove a muted keyword. Uses the v1.1 `mutes/keywords/destroy` endpoint. ```js await client.account.deleteMutedKeyword("keyword_id_123"); ``` ## Advanced Mute Filters Advanced filters let you automatically hide tweets from accounts that match certain criteria, such as new accounts, accounts without profile pictures, or unverified accounts. ### `account.advancedFilters()` Get your current advanced mute filter settings. Uses the v1.1 `mutes/advanced_filters` endpoint (GET). ```js const filters = await client.account.advancedFilters(); console.log(filters); ``` ### `account.updateAdvancedFilters(params?)` Update your advanced mute filter settings. Uses the v1.1 `mutes/advanced_filters` endpoint (POST). | Param | Type | Description | | ----------------------------------- | --------- | --------------------------------------------------- | | `params.mute_new_accounts` | `boolean` | Hide tweets from recently created accounts | | `params.mute_not_following` | `boolean` | Hide tweets from accounts you don't follow | | `params.mute_default_profile_image` | `boolean` | Hide tweets from accounts with default avatars | | `params.mute_no_confirmed_email` | `boolean` | Hide tweets from accounts without a confirmed email | | `params.mute_no_confirmed_phone` | `boolean` | Hide tweets from accounts without a confirmed phone | ```js await client.account.updateAdvancedFilters({ mute_new_accounts: true, mute_not_following: false, mute_default_profile_image: true, mute_no_confirmed_email: true, mute_no_confirmed_phone: false, }); ``` ::: tip Advanced mute filters are a great way to reduce spam and low-quality content in your notifications without having to manually block or mute individual accounts. ::: ## Personalization ### `account.personalizationInterests()` Get the interests Twitter has inferred about you based on your activity. These interests are used to personalize your timeline, ads, and recommendations. Uses the v1.1 `account/personalization/twitter_interests` endpoint. ```js const interests = await client.account.personalizationInterests(); console.log(interests); ``` ### `account.preferences()` Get your general user preferences. Uses the GraphQL `UserPreferences` query. ```js const prefs = await client.account.preferences(); console.log(prefs); ``` ## Full example ```js // View all muted keywords const keywords = await client.account.mutedKeywords(); console.log(`Currently muting ${keywords.length} keywords`); // Mute spoilers for a week await client.account.updateMutedKeyword({ keyword: "spoilers", mute_surfaces: "home_timeline,notifications", duration: 604800, }); // Mute a hashtag forever await client.account.updateMutedKeyword({ keyword: "#Spoiler", mute_surfaces: "home_timeline,notifications", duration: 0, }); // Remove a muted keyword when you're done await client.account.deleteMutedKeyword("keyword_id_123"); // Check current advanced filters const filters = await client.account.advancedFilters(); console.log(`Advanced filters: ${JSON.stringify(filters)}`); // Enable aggressive spam filtering await client.account.updateAdvancedFilters({ mute_new_accounts: true, mute_default_profile_image: true, mute_no_confirmed_email: true, mute_no_confirmed_phone: true, }); // See what Twitter thinks you're interested in const interests = await client.account.personalizationInterests(); console.log(`Twitter thinks you're interested in: ${JSON.stringify(interests)}`); // Get general preferences const prefs = await client.account.preferences(); console.log(`Preferences: ${JSON.stringify(prefs)}`); ``` ## `account.supportedLanguages()` The list of languages X supports (code, name, local name, status). Uses the GraphQL `SupportedLanguages` query. ```js const langs = await client.account.supportedLanguages(); // [{ code: "en", name: "English", local_name: "English", status: "production" }, ...] ``` --- --- url: /account/notifications.md --- # Notifications Access your notification timeline, manage notification settings, and check badge counts. ## `notifications.timeline(opts?)` Get your notifications timeline. Uses the GraphQL `NotificationsTimeline` query. | Option | Type | Description | | ---------------- | -------- | ------------------------------ | | `opts.count` | `number` | Number of results (default 20) | | `opts.cursor` | `string` | Pagination cursor | | `opts.variables` | `object` | Additional GraphQL variables | ```js const notifs = await client.notifications.timeline(); // Fetch more const more = await client.notifications.timeline({ count: 50 }); // Paginate const next = await client.notifications.timeline({ cursor: "DAABCgAB..." }); ``` ## `notifications.enableWebNotifications()` Enable logged-out web push notifications. Uses the GraphQL `EnableLoggedOutWebNotifications` mutation. ```js await client.notifications.enableWebNotifications(); ``` ## `notifications.saveSettings(params?)` Save notification settings. Uses the v1.1 `notifications/settings/save` endpoint. ```js await client.notifications.saveSettings({ // notification setting parameters }); ``` ## `notifications.loginSettings(params?)` Update notification settings on login. Uses the v1.1 `notifications/settings/login` endpoint. ```js await client.notifications.loginSettings({ // login notification parameters }); ``` ## `notifications.checkin(params?)` Check in for notification settings. Uses the v1.1 `notifications/settings/checkin` endpoint. ```js await client.notifications.checkin(); ``` ## `notifications.badge(params?)` Get the notification badge count (unread count). Uses the v2 `badge_count/badge_count` endpoint. ```js const badge = await client.notifications.badge(); console.log(badge); ``` ## Full example ```js // Check unread notification count const badge = await client.notifications.badge(); // Fetch your notifications const notifs = await client.notifications.timeline(); // Get more notifications with pagination const page2 = await client.notifications.timeline({ count: 40, cursor: "DAABCgAB...", }); // Enable web push notifications await client.notifications.enableWebNotifications(); // Save custom notification settings await client.notifications.saveSettings({ // your settings }); ``` --- --- url: /account/delegates.md --- # Delegates & act-as X lets accounts grant each other delegate access. emusks exposes the delegate lists and lets any request act on behalf of an account you're a delegate for. ## Listing delegates ### `delegates.contributees(opts?)` The accounts **you can act as** (accounts that have added you as a delegate). Uses the v1.1 `users/contributees` endpoint. | Option | Type | Description | | ----------------- | -------- | ---------------------------- | | `opts.userId` | `string` | Look up another user's list | | `opts.screenName` | `string` | Look up by @handle | ```js const accounts = await client.delegates.contributees(); // [{ id, id_str, screen_name, name, ... }] ``` ### `delegates.contributors(opts?)` The accounts **that can act as you** (delegates you've granted access to). Uses the v1.1 `users/contributors` endpoint. ```js const delegates = await client.delegates.contributors(); ``` ## Acting as another account When you're a delegate for an account, set `actingAs` to its user id and every subsequent request carries the `x-act-as-user-id` header, so reads and writes happen on that account. ### `client.setActingAs(userId)` Set (or clear, with `null`) the account to act as. Returns the client for chaining. ```js client.setActingAs("1340268334785376258"); await client.tweets.create("posted on behalf of the delegated account"); client.setActingAs(null); // back to your own account ``` ### `delegates.actAs(userId)` / `delegates.stop()` / `delegates.current()` Convenience wrappers: `actAs` is an alias for `setActingAs`, `stop` clears it, and `current` returns the active id. ```js client.delegates.actAs(targetUserId); const home = await client.timelines.home(); // the delegated account's timeline client.delegates.stop(); ``` ::: tip You can also act-as for a single call by passing the header directly to any request: `client.graphql("...", { headers: { "x-act-as-user-id": userId } })`. The header threads through the GraphQL, v1.1, and v2 layers. ::: ::: warning Acting as an account you are not a delegate for returns a `Contributor access is not permitted` error. Some endpoints disallow acting-as entirely. ::: --- --- url: /more/queries.md --- # Running queries Beyond the high-level helper namespaces, emusks gives you direct access to Twitter's underlying API layers. This is useful when you need to call endpoints that aren't wrapped by a helper, experiment with new or undocumented features, or get the raw unprocessed response from Twitter. Twitter/X uses three main API layers internally. emusks exposes all three. ## GraphQL API Twitter's primary modern API layer. Most features - tweets, users, timelines, communities, and more - are powered by GraphQL endpoints. ```js const res = await client.graphql("UserByScreenName", { variables: { screen_name: "elonmusk" }, }); console.log(res.data.user.result); ``` The first argument is the **operation name** - the name of the GraphQL query or mutation as defined by Twitter internally. The second argument is an options object where you can pass `variables`, `features`, and other GraphQL parameters. You can get these from devtools. emusks will automatically handle obtaining query IDs and other necessary metadata for you. ## v1.1 REST API The legacy REST API. Still actively used for account management, media uploads, user lookups, friendships, mutes, blocks, and many other features. ```js const res = await client.v1_1("users/lookup", { params: { screen_name: "elonmusk" }, }); console.log(res); ``` The first argument is the **endpoint path** (without the `/1.1/` prefix or `.json` suffix). The second argument is an options object where you can pass `params` (query parameters) and other request options. ## v2 API Twitter's v2 API layer, used for adaptive search, guide settings, badge counts, and some other features. ```js const res = await client.v2("search/adaptive", { params: { q: "hello", count: 10 }, }); console.log(res); ``` ## When to use raw query APIs These APIs are extremely powerful and flexible, and in fact what emusks uses internally to implement everything else. However, they also require more work to use effectively, since you need to understand Twitter's internal API structure and handle the raw responses yourself. You'll also need to beware that, unlike query APIs, all other functions parse and normalize Twitter's deeply nested, inconsistent API responses into clean JavaScript objects. Raw API methods return the response exactly as Twitter sends it, which are harder to work with and subject to change without warning. --- --- url: /more/search-operators.md --- # Search operators Twitter supports dozens of advanced search filters and operators that let you find specific tweets, users, or media. These operators work with [`search.tweets()`](/discovery/search#search-tweets-query-opts) along with most official clients. They mostly do **not work** with [`search.adaptive`](/discovery/search#search-adaptive-query-params), as it uses v1.1 search. ## Operators ### Tweet content | Operator | Finds tweets… | | | :--------------------- | :----------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | | `nasa esa` | Containing both "nasa" and "esa". (Spaces = **AND**) | [🔗](https://x.com/search?q=esa%20nasa\&f=live) | | `nasa OR esa` | Either "nasa" or "esa" (or both). **OR** must be capitalized. | [🔗](https://x.com/search?q=nasa%20OR%20esa\&f=live) | | `"state of the art"` | The exact phrase. Also matches "state-of-the-art". | [🔗](https://x.com/search?q=%22state%20of%20the%20art%22\&f=live) | | `"this is the * time"` | Quoted phrase with a **wildcard**. `*` only works inside quotes. | [🔗](https://x.com/search?q=%22this%20is%20the%20*%20time%20this%20week%22\&f=live) | | `+radiooooo` | Forces the term as-is. Prevents "did you mean" corrections. | [🔗](https://x.com/search?q=%2Bradiooooo\&f=live) | | `-love` | Excludes tweets containing "love". Works with phrases: `-"live laugh love"`. | [🔗](https://x.com/search?q=bears%20-chicagobears\&f=live) | | `#tgif` | Tweets containing a specific hashtag. | [🔗](https://x.com/search?q=%23tgif\&f=live) | | `$TWTR` | Tweets containing a cashtag (stock symbols). | [🔗](https://x.com/search?q=%24TWTR\&f=live) | | `What ?` | Finds tweets where a question mark is used. | [🔗](https://x.com/search?q=\(Who%20OR%20What\)%20%3F\&f=live) | | `:)` / `:(` | Matches positive (`:)`, `:-)`, `:P`, `:D`) or negative (`:(`, `:-(`) emoticons. | [🔗](https://x.com/search?q=%3A%29%20OR%20%3A%28\&f=live) | | `👀` | Matches specific emojis. Usually requires a keyword or `lang:` to work. | [🔗](https://x.com/search?q=%F0%9F%91%80%20lang%3Aen\&f=live) | | `url:google.com` | Matches URLs. Tokenized: use underscores for hyphens (e.g., `url:t_mobile.com`). | [🔗](https://x.com/search?q=url%3Agu.com\&f=live) | | `lang:en` | Filters by language (e.g., `en`, `es`, `fr`, `ja`). See [Language codes](#language-codes). | [🔗](https://x.com/search?q=lang%3Aen\&f=live) | ### User & account | Operator | Finds tweets… | Example | | :--------------------- | :-------------------------------------------------------------------------- | :---------------------- | | `from:user` | Sent by a specific `@username`. | `dogs from:NASA` | | `to:user` | Tweets that are a direct reply to `@username`. | `to:NASA #MoonTunes` | | `@user` | Tweets mentioning `@username`. Combine with `-from:user` for mentions only. | `@cern -from:cern` | | `list:ID` | Tweets from members of a public List (Use ID or `user/slug`). | `list:esa/astronauts` | | `filter:verified` | From legacy verified accounts. | `filter:verified` | | `filter:blue_verified` | From accounts with X Premium (paid checkmark). | `filter:blue_verified` | | `filter:follows` | Only from accounts you follow. (Cannot be negated). | `kitten filter:follows` | | `filter:social` | From your extended network (based on activity). | `filter:social` | ### Location | Operator | Finds tweets… | Example | | :------------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------- | | `near:city` | Geotagged in a specific location. Supports phrases: `near:"The Hague"`. | `near:London` | | `near:me` | Geotagged near your current IP/detected location. | `near:me` | | `within:radius` | Use with `near` to set a radius (km or mi). | `fire near:SF within:10km` | | `geocode:lat,long,r` | Tweets within radius `r` of exact coordinates. | `geocode:37.77,-122.41,1km` | | `place:ID` | Search by X [Place Object ID](https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/object-model/geo). | `place:96683cc9126741d1` | ### Time & date | Operator | Finds tweets… | Example | | :------------------- | :------------------------------------------------------------------------ | :------------------------------ | | `since:YYYY-MM-DD` | Sent on or after this date. (Inclusive). | `since:2024-01-01` | | `until:YYYY-MM-DD` | Sent before this date. (**Not** inclusive). | `until:2024-01-01` | | `since:DATE_TIME_TZ` | Granular time: `since:2023-01-01_23:59:59_UTC`. | `since:2023-10-13_00:00:00_UTC` | | `since_time:12345` | On or after a specific Unix timestamp (seconds). | `since_time:1561720321` | | `until_time:12345` | Before a specific Unix timestamp (seconds). | `until_time:1562198400` | | `since_id:ID` | After (not inclusive) a specific Snowflake Tweet ID. | `since_id:1138872932` | | `max_id:ID` | At or before (inclusive) a specific Snowflake Tweet ID. | `max_id:1144730280` | | `within_time:10m` | Within the last `d` (days), `h` (hours), `m` (minutes), or `s` (seconds). | `nasa within_time:30s` | ### Tweet type & logic | Operator | Finds tweets… | Example | | :----------------------- | :----------------------------------------------------------------------- | :-------------------------------- | | `filter:nativeretweets` | Only "retweet button" retweets. (Last 7-10 days only). | `from:nasa filter:nativeretweets` | | `include:nativeretweets` | Includes retweets in results (hidden by default). | `include:nativeretweets` | | `filter:replies` | Only tweets that are replies. | `filter:replies -to:nasa` | | `filter:self_threads` | Only self-replies (the author replying to themselves). | `filter:self_threads` | | `filter:quote` | Only contains Quote Tweets. | `filter:quote` | | `conversation_id:ID` | Every tweet in a specific thread/conversation. | `conversation_id:1140437409` | | `quoted_tweet_id:ID` | Quotes of a specific Tweet ID. | `quoted_tweet_id:113863184` | | `quoted_user_id:ID` | All quotes of a specific User ID (numeric). | `quoted_user_id:11348282` | | `card_name:pollXchoice` | Tweets with polls. Options: `poll2choice_text_only`, `poll3choice`, etc. | `card_name:poll4choice_text_only` | ### Engagement & media | Operator | Finds tweets… | Example | | :----------------------- | :------------------------------------------------------------ | :----------------------- | | `filter:has_engagement` | Has at least one like/reply/RT. Negate to find "dead" tweets. | `-filter:has_engagement` | | `min_retweets:X` | Minimum number of Retweets. | `min_retweets:500` | | `min_faves:X` | Minimum number of Likes (Faves). | `min_faves:1000` | | `min_replies:X` | Minimum number of Replies. | `min_replies:100` | | `-min_faves:X` | Maximum number of Likes. | `-min_faves:10` | | `filter:media` | Any media (images, video, GIFs). | `cat filter:media` | | `filter:images` | Tweets containing images. | `filter:images` | | `filter:twimg` | Specifically native X/Twitter images (`pic.twitter.com`). | `filter:twimg` | | `filter:videos` | Any video (Native, YouTube, etc.). | `filter:videos` | | `filter:consumer_video` | Native X video only. | `filter:consumer_video` | | `filter:pro_video` | Amplify/Pro video only. | `filter:pro_video` | | `filter:spaces` | Live or recorded Twitter Spaces. | `filter:spaces` | | `card_name:animated_gif` | Tweets containing GIFs. | `card_name:animated_gif` | ## Matching ### Logical grouping Spaces are implicit **AND**. Use parentheses to group conditions. * **Query:** `(puppy OR kitten) (sweet OR cute) -filter:nativeretweets min_faves:10` * **Meaning:** Find tweets about puppies or kittens AND sweet or cute, excluding retweets, with 10+ likes. ### Force literal matches X uses "signal words." If you search for the word `photo`, X thinks you want `filter:images`. To avoid this, wrap it in quotes, like `"photo"`. ### URL tokenization X strips hyphens from parameters and you should use underscore instead. `url:t-mobile.com` won't work, but `url:t_mobile.com` will. This applies to all URL-based operators (`url:`, `filter:links`, etc.). ### Quote-Tweet Permalinks From a technical perspective, quote-tweets are tweets with a URL of another tweet. It's possible to find Tweets that quote a specific Tweet by searching for the URL of that Tweet. To find everyone quoting a specific tweet, search for the tweet's URL and exclude the original author, like `https://x.com/jack/status/20 -from:jack`. Any parameters must be removed or only Tweets that contain the parameter as well are found. This includes the automatic client parameter added when using the share menu (eg. ?s=20 for the Web App and ?s=09 for the Android app) ## Snowflakes All X IDs (tweets, users) embed a timestamp. This allows you to search with precise time boundaries using `since_id` or `max_id`. To convert an **ID to millisecond epoch**, use `milliepoch = (tweet_id >> 22) + 1288834974657`. To convert an **epoch to ID**, use `tweet_id = (milliepoch - 1288834974657) << 22`. ```python def milliepoch_to_id(ms): if ms <= 1288834974657: raise ValueError("Too early") return (ms - 1288834974657) << 22 def id_to_milliepoch(id): if id <= 0: raise ValueError("Invalid ID") return (id >> 22) + 1288834974657 ``` ## Language codes Twitter supports several unique language filters for specific tweet structures: | Code | Purpose | | :--------- | :------------------------------------------------ | | `lang:und` | Unknown (usually just links, numbers, or emojis). | | `lang:qam` | Tweets containing **Mentions only**. | | `lang:qct` | Tweets containing **Cashtags only**. | | `lang:qht` | Tweets containing **Hashtags only**. | | `lang:qme` | Tweets containing **Media links only**. | | `lang:qst` | Tweets with **Very short text**. | | `lang:zxx` | **No text** (Media or Twitter Card only). | ## Searching by client You can filter tweets by the app they were sent from. These should work for every single client, official or not. If it isn't, try putting the client name in quotes or replace spaces with underscores. You cannot copy an existing name. This operator needs to be combined with something else to work, eg `lang:en`. **Examples:** * `source:twitter_web_client` * `source:twitter_web_app` * `source:twitter_for_iphone` * `source:twitter_for_ipad` * `source:twitter_for_mac` * `source:twitter_for_android` * `source:twitter_ads` (promoted tweets) * `source:tweetdeck` * `source:tweetdeck_web_app` * `source:twitter_for_advertisers` * `source:twitter_media_studio` * `source:cloudhopper` (sms gateway) * custom clients, like `source:IFTTT` or `source:Instagram` ## News site filter When using `filter:news`, Twitter makes sure every result includes a link to a [whitelist of "news" websites](https://gist.github.com/igorbrigadir/ef143d2f3167258359007a0ff7ac401d). ## Limitations * **Operator limit:** You can typically only use ~22-23 operators in a single query. * **Card names:** `card_name:` searches are usually limited to the last 7-8 days of data. * **Private accounts:** Tweets from private (protected), suspended, or locked accounts will never appear in search results. * **Time logic:** Date/Time operators usually must be combined with a keyword or user filter to function correctly. --- --- url: /more/syndication.md --- # Syndication API Syndication is the internal Twitter API used for generating tweet embeds, unauthenticated. It's great for converting thousands of tweet IDs to actual tweets, fast, and needing an account that could get suspended. As far as I know, this API has no ratelimits. This API only supports fetching tweets as profile timelines have been effectively removed from the embeds API and no longer work. ## `syndication.getTweet(tweetId)` ```js import Emusks from "emusks"; const client = new Emusks(); // you don't have to authenticate! const tweet = await client.syndication.getTweet("2023357708301455483"); ``` ```json { "__typename": "Tweet", "lang": "en", "favorite_count": 14, "created_at": "2026-02-16T11:24:04.000Z", "display_text_range": [ 0, 155 ], "entities": { "hashtags": {}, "urls": {}, "user_mentions": {}, "symbols": {} }, "id_str": "2023357708301455483", "text": "Underdocumented: `bun build --compile` supports `--splitting -format=esm` Lazy load code embedded in your single-file executable to improve CLI start time", "user": { "id_str": "2489440172", "name": "Jarred Sumner", "screen_name": "jarredsumner", "is_blue_verified": true, "profile_image_shape": "Circle", "verified": false, "profile_image_url_https": "https://pbs.twimg.com/profile_images/1342417825483300864/Vz4ChOFG_normal.jpg", "highlighted_label": { "description": "Bun", "badge": { "url": "https://pbs.twimg.com/profile_images/1809959835884216320/MDn6rbnJ_bigger.jpg" }, "url": { "url": "https://twitter.com/bunjavascript", "url_type": "DeepLink" }, "user_label_type": "BusinessLabel", "user_label_display_type": "Badge" } }, "edit_control": { "edit_tweet_ids": [ "2023357708301455483" ], "editable_until_msecs": "1771244644000", "is_edit_eligible": true, "edits_remaining": "5" }, "conversation_count": 1, "news_action_type": "conversation", "isEdited": false, "isStaleEdit": false } ``` ### Videos ```json { "__typename": "Tweet", "lang": "und", "favorite_count": 1101, "possibly_sensitive": false, "created_at": "2026-02-16T01:18:42.000Z", "display_text_range": [0, 130], "entities": { "hashtags": [], "urls": [], "user_mentions": [], "symbols": [], "media": [ { "display_url": "pic.x.com/5xrYfslWJc", "expanded_url": "https://x.com/ExtremeBlitz__/status/2023205364368351634/video/1", "indices": [86, 109], "url": "https://t.co/5xrYfslWJc" } ] }, "id_str": "2023205364368351634", "text": "…", "user": { … }, "edit_control": { … }, "mediaDetails": [ { "additional_media_info": {}, "display_url": "pic.x.com/5xrYfslWJc", "expanded_url": "https://x.com/ExtremeBlitz__/status/2023205364368351634/video/1", "ext_media_availability": { "status": "Available" }, "indices": [86, 109], "media_url_https": "https://pbs.twimg.com/amplify_video_thumb/2023205304318488576/img/ljIYhvhxV9SKP3IB.jpg", "original_info": { "height": 514, "width": 1080, "focus_rects": [] }, "sizes": { "large": { "h": 514, "resize": "fit", "w": 1080 }, "medium": { … }, "small": { … }, "thumb": { … } }, "type": "video", "url": "https://t.co/5xrYfslWJc", "video_info": { "aspect_ratio": [540, 257], "duration_millis": 9516, "variants": [ { "content_type": "application/x-mpegURL", "url": "https://video.twimg.com/amplify_video/2023205304318488576/pl/MC4uaCsa2h7OlCkT.m3u8" }, { "bitrate": 256000, "content_type": "video/mp4", "url": "https://video.twimg.com/amplify_video/2023205304318488576/vid/avc1/566x270/AYxnjkt3c60VBmoa.mp4" }, … ] } } ], "photos": [], "video": { "aspectRatio": [540, 257], "contentType": "media_entity", "durationMs": 9516, "mediaAvailability": { "status": "available" }, "poster": "https://pbs.twimg.com/amplify_video_thumb/2023205304318488576/img/ljIYhvhxV9SKP3IB.jpg", "variants": [ { "type": "application/x-mpegURL", "src": "https://video.twimg.com/amplify_video/2023205304318488576/pl/MC4uaCsa2h7OlCkT.m3u8" }, { "type": "video/mp4", "src": "https://video.twimg.com/amplify_video/2023205304318488576/vid/avc1/566x270/AYxnjkt3c60VBmoa.mp4" }, … ], "videoId": { "type": "tweet", "id": "2023205364368351634" }, "viewCount": 0 }, "conversation_count": 23, "news_action_type": "conversation", "isEdited": false, "isStaleEdit": false } ``` ### Deleted tweets If a tweet is deleted or the account suspended, it'll show up as a `TweetTombstone`: ```json { "__typename": "TweetTombstone", "tombstone": { "text": { "text": "This Post was deleted by the Post author. Learn more", "entities": [ { "from_index": 42, "to_index": 52, "ref": { "__typename": "TimelineUrl", "url": "https://help.twitter.com/rules-and-policies/notices-on-twitter", "url_type": "ExternalUrl" } } ], "rtl": false } } } ``` --- --- url: /more/credits.md --- # Credits * [fa0311](https://github.com/fa0311) for their amazing work [scraping Twitter's GraphQL queries](https://github.com/fa0311/TwitterInternalAPIDocument) * [re4k](https://github.com/re4k) for documenting a few of [Twitter client's API keys](https://gist.github.com/pvieito/ee6d2c8934a8f84b9aeb467585277b8a) * [zedeus](https://github.com/zedeus) for publishing their reverse-engineered Twitter login flow for [nitter](https://github.com/zedeus/nitter) * [lami](https://lami.zip/) for [documenting Twitter's `x-client-transaction-id` header](https://github.com/Lqm1/x-client-transaction-id) * part of emusks' helpers are written by LLMs due to the sheer amount of queries, most of which don't have documented parameters. The rest of the codebase is written by humans.