I built xThreads.app because I was frustrated with my own workflow. Every morning, I would spend 10-15 minutes crafting thoughtful replies to tweets in my feed - reading the context, checking the profile, figuring out the right tone, typing and retyping. It was exhausting, and I knew other creators felt the same way.
The existing automation tools felt robotic. They ignored conversation context, used generic templates, and produced replies that were clearly machine-written. There was a gap between "write everything manually" and "paste a generic template" - and I wanted to fill it with an AI tool that understood nuance.
This post walks through every stage of building xThreads: the Chrome extension architecture, the AI integration, the DOM manipulation challenges, and the lessons learned from 1000+ users. All code examples are simplified versions of what actually shipped.
What you will learn: Chrome extension architecture (Manifest V3) · Injecting UI into Twitter's dynamic DOM · Context extraction with MutationObserver · OpenAI API integration for reply generation · Request queuing and rate limiting · Chrome Storage API for persistence · Analytics and user dashboard
1. Chrome Extension Architecture
Every Chrome extension has three layers: the manifest (configuration), the background service worker (persistent logic), and the content script (DOM interaction). xThreads uses all three:
Diagram 1: xThreads extension architecture
The service worker handles API calls to OpenAI - this keeps the API key secure and avoids exposing it to the page. The content script interacts with Twitter's DOM, injecting the "Generate Reply" button and extracting conversation context. The popup provides a dashboard for analytics and settings.
The Manifest
Every Chrome extension starts with a manifest file. Here is xThreads' manifest (Manifest V3):
{
"manifest_version": 3,
"name": "xThreads - AI Reply Generator",
"version": "1.2.0",
"permissions": [
"storage", // save user preferences
"activeTab" // access current Twitter tab
],
"host_permissions": [
"https://*.twitter.com/*", // Twitter/X domain
"https://*.x.com/*", // New domain
"https://api.openai.com/*" // OpenAI API
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://*.twitter.com/*", "https://*.x.com/*"],
"js": ["content-script.js"],
"css": ["styles.css"]
}
],
"action": {
"default_popup": "popup.html",
"default_title": "xThreads"
}
}Key decisions: We request host_permissions only for Twitter/X and OpenAI - no broad permissions. The storage permission lets us save user preferences and analytics data locally without needing a backend server.
2. The Hardest Part: DOM Manipulation on Twitter
Twitter's UI is a single-page application (React-based). The DOM is constantly changing - tweets load on scroll, reply threads expand dynamically, and Twitter ships new UI versions every few weeks. If you directly reference DOM structure, your extension breaks with every update.
The solution: use MutationObserver to detect when new content loads, and use attribute-based selectors (not class-based) to target elements:
// content-script.js - simplified version
let observer = null;
function initObserver() {
"Watch for new tweet compose areas and inject the xThreads button."
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if this is a tweet compose area
const composeArea = node.querySelector(
'div[data-testid="tweetTextarea_0"]'
);
if (composeArea) {
injectReplyButton(composeArea);
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
// Start watching as soon as the page loads
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initObserver);
} else {
initObserver();
}Diagram 2: Content injection flow - detecting new content and injecting the button
Extracting Conversation Context
To generate a good reply, the AI needs context - not just the current tweet, but the conversation thread, the user's bio, and the tweet's engagement. Here is how we extract that context:
function extractContext(composeArea) {
"Pull conversation context from the Twitter DOM."
// Find the tweet being replied to (the parent tweet)
const tweet = composeArea.closest('article[data-testid="tweet"]');
if (!tweet) return null;
const context = {
// The text of the tweet being replied to
tweetText: tweet.querySelector(
'div[data-testid="tweetText"]'
)?.textContent || "",
// The author's display name and handle
authorName: tweet.querySelector(
'div[data-testid="User-Name"]'
)?.textContent || "",
// Converted to "replies" count from aria-label
engagement: extractEngagement(tweet),
// Thread context: extract previous tweets in the thread
thread: extractThreadHistory(tweet),
};
return context;
}
function extractEngagement(tweet) {
"Parse engagement numbers from Twitter's DOM."
const actions = tweet.querySelector(
'div[role="group"]'
);
if (!actions) return {};
// Twitter uses aria-label for accessibility
// e.g. "3 replies. 12 likes. 2 reposts."
const labels = actions.querySelectorAll('[aria-label]');
return Array.from(labels).reduce((acc, el) => {
const label = el.getAttribute("aria-label") || "";
const num = parseInt(label.match(/\d+/)?.[0] || "0", 10);
if (label.includes("reply")) acc.replies = num;
if (label.includes("like")) acc.likes = num;
if (label.includes("repost")) acc.reposts = num;
return acc;
}, {});
}The key pattern: data-testid selectors. Twitter uses these stable attributes for its own tests, and they change far less often than CSS class names. A fallback chain (try selector A, then B, then C) handles Twitter's occasional UI updates without crashing.
3. AI Integration: Generating Replies
The service worker handles all API calls. The content script sends the extracted context to the service worker via chrome.runtime.sendMessage, and the service worker calls OpenAI:
// background.js - service worker
const OPENAI_API_KEY = await chrome.storage.local.get("api_key");
async function generateReply(context) {
"Call OpenAI to generate a context-aware reply."
const prompt = buildPrompt(context);
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini", // fast + cheap
messages: [
{
role: "system",
content: "You generate Twitter replies that match the " +
"user's tone and conversation context. Keep replies " +
"under 280 characters. Be natural, not robotic.",
},
{
role: "user",
content: prompt,
},
],
max_tokens: 150,
temperature: 0.7, // creative but focused
}),
});
const data = await response.json();
return data.choices[0].message.content;
}
function buildPrompt(context) {
"Build a structured prompt from DOM-extracted context."
return [
"Tweet to reply to:",
context.tweetText,
"",
"Author: " + context.authorName,
"Engagement: " + JSON.stringify(context.engagement),
"",
context.thread.length > 0
? "Thread history:\n" + context.thread.join("\n---\n")
: "",
"",
"Generate a natural reply:",
].filter(Boolean).join("\n");
}
// Listen for messages from content script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "generate") {
generateReply(request.context)
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true; // keep channel open for async response
}
});Diagram 3: Message flow - content script → service worker → OpenAI → back
4. Rate Limiting and Request Queue
When users start generating multiple replies quickly, you hit OpenAI rate limits and degrade the experience. We built a request queue with exponential backoff:
// background.js - request queue
class RequestQueue {
constructor() {
this.queue = [];
this.processing = false;
this.delay = 1000; // start with 1 second between requests
}
enqueue(request) {
return new Promise((resolve, reject) => {
this.queue.push({ request, resolve, reject });
if (!this.processing) {
this.processNext();
}
});
}
async processNext() {
if (this.queue.length === 0) {
this.processing = false;
return;
}
this.processing = true;
const { request, resolve, reject } = this.queue.shift();
try {
const result = await this.executeWithBackoff(request);
resolve(result);
this.delay = Math.max(500, this.delay * 0.9); // reduce delay
} catch (err) {
reject(err);
this.delay = Math.min(30000, this.delay * 2); // exponential backoff
}
// Wait before processing next request
setTimeout(() => this.processNext(), this.delay);
}
async executeWithBackoff(request, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await generateReply(request);
} catch (err) {
if (err.status === 429) { // rate limited
const wait = Math.pow(2, i) * 1000;
await new Promise((r) => setTimeout(r, wait));
continue;
}
throw err; // non-rate-limit errors fail immediately
}
}
throw new Error("Rate limited after 3 retries");
}
}
const queue = new RequestQueue();The key design decisions in this queue:
- Adaptive delay: The queue reduces delay on success (the API is handling the load) and increases it on failure (we are hitting limits).
- Exponential backoff: On HTTP 429 (rate limited), we wait 1 second, then 2, then 4 before giving up.
- Non-rate-limit errors fail fast: 401 (auth), 400 (bad request), 500 (server error) are not retried - they need human attention.
5. Local Persistence with Chrome Storage
We avoided building a backend server by using Chrome's built-in storage API. This keeps the extension self-contained - users install it and it works immediately, no signup required:
// Storage helper - wraps chrome.storage.local with typed access
const Storage = {
async get(keys) {
return chrome.storage.local.get(keys);
},
async set(data) {
return chrome.storage.local.set(data);
},
// Track analytics locally (no server needed)
async trackEvent(event) {
const { events = [] } = await this.get("events");
events.push({
...event,
timestamp: Date.now(),
});
// Keep only last 1000 events to stay under storage quota
if (events.length > 1000) events.splice(0, events.length - 1000);
await this.set({ events });
},
// Get reply count for analytics
async getStats() {
const { events = [] } = await this.get("events");
return {
totalReplies: events.filter((e) => e.type === "reply").length,
uniqueDays: new Set(events.map((e) =>
new Date(e.timestamp).toDateString()
)).size,
topTones: getMostCommon(events, "tone"),
};
},
// Save user preferences
async savePreferences(prefs) {
await this.set({ preferences: prefs });
},
async getPreferences() {
const { preferences = defaultPrefs } = await this.get("preferences");
return preferences;
},
};
const defaultPrefs = {
tone: "professional", // professional | witty | controversial
autoGenerate: false, // auto-generate on focus
maxLength: 280, // character limit
};Chrome's storage.local quota is 10 MB - more than enough for user preferences and analytics events. This approach eliminated our need for a backend during the MVP phase and let us ship the first version in under three weeks.
6. Results: What 1000+ Users Taught Us
xThreads reached 1000+ active users and generated 50,000+ AI replies. Here are the most important lessons:
Diagram 4: Key metrics - growth and usage patterns
- Tone selection matters most: Users who switched between Professional, Witty, and Controversial tones had 3x higher retention than those who used a single tone. The AI's adaptability was the core value, not just the time savings.
- DOM resilience is a constant battle: Twitter shipped 4 major UI updates in 6 months. Each one broke a selector. Our fallback chain (try 3 selectors, then degrade gracefully) saved us from crashing.
- Performance is UX: Users expected replies in under 3 seconds. The request queue + gpt-4o-mini kept average response time at 1.8 seconds. Every extra 100ms reduced completion rate by ~2%.
- Building in public works: Sharing development updates on Twitter itself - using xThreads to draft them - was the most effective marketing channel. The product sold itself.
7. Summary: What xThreads Taught Me About Building for Users
- Building a Chrome extension that interacts with Twitter's DOM requires robust injection strategies - use MutationObserver + data-testid selectors, not fragile class names.
- The message-passing architecture (content script ↔ service worker ↔ external API) keeps API keys secure and the user interface responsive.
- A request queue with adaptive delay and exponential backoff is essential for handling OpenAI rate limits gracefully at scale.
- Chrome's storage.local is sufficient for MVP analytics and user preferences - you do not need a backend server to start.
- User-centricity is not a buzzword. xThreads succeeded because it solved a real, personal pain point - not because it used the most advanced AI model.
Your next challenge
// TODO: Build your own Chrome extension
//
// 1. Pick a repetitive web task you do daily (filling forms,
// checking notifications, drafting responses)
// 2. Identify the DOM elements involved - use data-testid selectors
// 3. Build a content script with MutationObserver for injection
// 4. Add a service worker for API calls (if applicable)
// 5. Add a request queue for rate limiting
// 6. Use chrome.storage.local for persistence (no backend needed)
// 7. Ship it - even if it's just for your own use
// Starter template:
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "ping") {
sendResponse({ status: "Extension is running" });
}
return true; // async response
});The best Chrome extensions start as personal tools. xThreads began as a script I wrote for myself. If you find yourself doing the same manual task repeatedly, build the tool that eliminates it - and share it with the world.