Twitter Skill
Posts text and images to X (Twitter) via Chrome Computer Use Mode.
Script Directory
Important: All scripts are located in the scripts/ subdirectory of this skill.
Agent Execution Instructions:
- Determine this SKILL.md file's directory path as
{baseDir} - Script paths:
- Clipboard helper:
{baseDir}/scripts/copy-to-clipboard.ts - Tweet exporter/scraper:
{baseDir}/scripts/export-tweets.ts - Official X API helper:
{baseDir}/scripts/x-api.ts
- Clipboard helper:
- Resolve
${BUN_X}runtime: ifbuninstalled →bun; ifnpxavailable →npx -y bun; else suggest installing bun - Replace all
{baseDir}and${BUN_X}in this document with actual values
Execution Mode
This skill follows a Hybrid Execution Model:
- API First (Recommended for text posts): Attempt to post the tweet using the official X API script (
x-api.ts). This is fast, stable, and uses no browser resources. - Browser Fallback: If the API call fails (e.g., monthly 1500-tweet Free tier quota exceeded, rate limit) or API credentials are not configured in
subscription.yaml, fall back automatically to Chrome Computer Use Mode / CLI Bridge to simulate browser actions.
Prerequisites
- For API Mode: Configure your X API credentials (OAuth 1.0a User Context Tokens) in your local
subscription.yamlfile (which is git-ignored):x_api: api_key: "YOUR_API_KEY" api_key_secret: "YOUR_API_KEY_SECRET" access_token: "YOUR_ACCESS_TOKEN" access_token_secret: "YOUR_ACCESS_TOKEN_SECRET" - For Browser Fallback: Google Chrome installed, logged into X (Twitter) in Chrome, and macOS accessibility permissions granted if required.
Regular Posts Workflow (Text & Images)
When executing a post:
- Start the agent turn by calling
get_app_state(or equivalent tool) forGoogle Chrome. - Open or navigate Google Chrome to
https://x.com/compose/post. - Locate the tweet composer input box.
- Type the post text into the composer using Computer Use keyboard inputs.
- If there are any images to attach (max 4):
For each image:
a. Run the clipboard helper script to copy the image to the clipboard:
b. Paste the image into the composer using the paste shortcut (${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts image /absolute/path/to/image.pngsuper+von macOS,control+von Windows/Linux). c. Wait 2-3 seconds until X finishes uploading the media. - Publish Safety: Never click
Publish,Post, or any equivalent button to publish the tweet without getting explicit final confirmation from the user in the current conversation. - Once the user confirms, click the
Postbutton to publish. - After publishing, close the composer modal so the UI doesn't stay stuck on the compose dialog. Use the close button or Escape:
- DOM Selector:
[data-testid="app-bar-close"]or[aria-label="Close"] - Fallback: dispatch an
Escapekeydown event
- DOM Selector:
- Auto-Reload Feed (Optional): If the user has other tabs open to their profile (e.g.,
x.com/[username]) or home feed (x.com/home), reload them so the new tweet is visible immediately.
var closeBtn = document.querySelector('[data-testid="app-bar-close"], [aria-label="Close"]');
if (closeBtn) { closeBtn.click(); }
else { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); }
CLI Bridge (No Computer Use Tools)
When the environment lacks Computer Use keyboard/mouse tools, use platform-specific methods to open Chrome and inject JavaScript into the page.
macOS (AppleScript)
Open compose page:
open -a "Google Chrome" "https://x.com/compose/post"
Execute JavaScript in Chrome — write JS to a temp file first (avoids shell escaping issues), then run via AppleScript:
cat > /tmp/tweet.js << 'EOF'
(function() { /* your code */ })();
EOF
osascript -e '
tell application "Google Chrome"
activate
set js to read "/tmp/tweet.js"
set result to execute front window'"'"'s active tab javascript js
return result
end tell'
Pattern: Always write the JS payload to a temp file first. Do NOT attempt inline osascript -e with embedded JS — quote/escaping conflicts will cause parse errors.
Close composer after publishing (otherwise the modal stays open):
var closeBtn = document.querySelector('[data-testid="app-bar-close"], [aria-label="Close"]');
if (closeBtn) { closeBtn.click(); }
else { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); }
Reload profile/home tabs to reflect the new post immediately (Optional):
osascript -e '
tell application "Google Chrome"
tell window 1
set tabList to every tab whose URL contains "x.com/hankunpeng" or URL contains "x.com/home"
repeat with t in tabList
reload t
end repeat
end tell
end tell'
Linux
google-chrome "https://x.com/compose/post"
# Use chrome-remote-interface or similar CDP client to execute JS
Windows
Start-Process "chrome" "https://x.com/compose/post"
# Use CDP via --remote-debugging-port or PowerShell automation
JavaScript / DOM Automation Guidelines (Fallback)
If direct Computer Use keyboard/mouse events are not available, or you are running in browser scripting/CDP modes, follow these guidelines to interact with X's React/Draft.js editor:
-
Multiple Editor Detection: X pages often contain multiple compose textareas (e.g., inline composer on home feed and active modal dialog). Always query all editors:
var els = document.querySelectorAll('[data-testid="tweetTextarea_0"]'); var el = els.length > 1 ? els[els.length - 1] : els[0];Always target the active modal composer (usually the last element in the list).
-
Binding Selection & Focus: Before inserting text, you MUST click the element to trigger Draft.js selection binding, then focus:
el.click(); el.focus(); -
Preserving Editor Structure:
- Do NOT use
el.innerHTML = ''ordocument.execCommand('delete')on an empty composer. Wiping the DOM nodes destroys Draft.js's internal wrapper structure (e.g.,public-DraftStyleDefault-blockspan), which crashes the React component and leaves the Post button permanently disabled. - Simply use
document.execCommand('insertText', false, text)directly into the empty focused editor.
- Do NOT use
-
Triggering React State Updates: After text insertion, dispatch a bubbled
inputevent to notify React:el.dispatchEvent(new Event('input', { bubbles: true })); -
Locating the Correct Post Button: The button testids (
tweetButtonInlineandtweetButton) might be swapped depending on the context. Always scan for the visible, enabled button:var btns = document.querySelectorAll('[data-testid="tweetButtonInline"], [data-testid="tweetButton"]'); var activeBtn = Array.from(btns).find(function(btn) { var isVisible = btn.offsetWidth > 0 && btn.offsetHeight > 0; var isDisabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; return isVisible && !isDisabled; }); if (activeBtn) activeBtn.click();
Delete Tweet Workflow
When executing a deletion:
- Open or navigate Google Chrome to the user's profile page (
https://x.com/[username]) or the direct tweet URL (https://x.com/[username]/status/[tweetId]). - Search for the target tweet
<article>container containing the text to delete. - Click the options menu button on the tweet:
- DOM Selector:
[data-testid="caret"]
- DOM Selector:
- Wait 1-2 seconds, then click the "Delete" menu item:
- DOM Selector: A
[role="menuitem"]element whose text contains "Delete" or "删除".
- DOM Selector: A
- Wait 1-2 seconds, then click the confirmation delete button in the dialog sheet:
- DOM Selector:
[data-testid="confirmationSheetConfirm"](or fallback to any dialog button with text "Delete" or "删除").
- DOM Selector:
CLI Bridge Example (macOS)
Use the same temp-file + AppleScript pattern as posting. Replace TWEET_TEXT_HERE with the target tweet content.
Step 1 — Find tweet and click caret:
cat > /tmp/del-1.js << 'EOF'
(function() {
var articles = document.querySelectorAll('article');
var target = null;
var needle = 'TWEET_TEXT_HERE';
for (var i = 0; i < articles.length; i++) {
var textEl = articles[i].querySelector('[data-testid="tweetText"]');
if (textEl && textEl.textContent.trim().indexOf(needle) !== -1) {
target = articles[i];
break;
}
}
if (!target) return 'ERROR: tweet not found';
var caret = target.querySelector('[data-testid="caret"]');
if (!caret) return 'ERROR: caret not found';
caret.click();
return 'OK: caret clicked';
})();
EOF
osascript -e '
tell application "Google Chrome"
set js to read "/tmp/del-1.js"
set result to execute front window'"'"'s active tab javascript js
return result
end tell'
Step 2 — Click Delete menuitem (wait 1-2s after step 1):
cat > /tmp/del-2.js << 'EOF'
(function() {
var items = document.querySelectorAll('[role="menuitem"]');
for (var i = 0; i < items.length; i++) {
var txt = items[i].textContent.trim();
if (txt === 'Delete' || txt === '删除') {
// Click the menuitem itself, or if it contains a nested clickable element
var clickTarget = items[i].querySelector('[role="menuitem"]') || items[i];
clickTarget.click();
return 'OK: delete clicked (index ' + i + ')';
}
}
// Fallback: click the first menuitem (always "Delete" in the dropdown)
var first = document.querySelectorAll('[role="menuitem"]')[0];
if (first) { first.click(); return 'OK: first menuitem clicked'; }
return 'ERROR: no menuitems';
})();
EOF
osascript -e '
tell application "Google Chrome"
set js to read "/tmp/del-2.js"
set result to execute front window'"'"'s active tab javascript js
return result
end tell'
Step 3 — Confirm deletion (wait 1-2s after step 2):
cat > /tmp/del-3.js << 'EOF'
(function() {
var confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (!confirmBtn) {
var buttons = document.querySelectorAll('[role="button"], button');
for (var i = 0; i < buttons.length; i++) {
var txt = buttons[i].textContent.trim();
if (txt === 'Delete' || txt === '删除') {
confirmBtn = buttons[i];
break;
}
}
}
if (!confirmBtn) return 'ERROR: confirm button not found';
confirmBtn.click();
return 'OK: confirm clicked';
})();
EOF
osascript -e '
tell application "Google Chrome"
set js to read "/tmp/del-3.js"
set result to execute front window'"'"'s active tab javascript js
return result
end tell'
Export Tweets Workflow
To export/save all or filtered tweets from your profile page:
- Run the exporter script:
${BUN_X} {baseDir}/scripts/export-tweets.ts [startDate] [endDate]- Optional Date Filters: You can pass
startDate(e.g.2026-06-01) andendDate(e.g.2026-06-30) to filter the output by date range. If omitted, all scraped tweets are exported. - Output File: The tweets will be saved in
/Users/alex/twitter/twitter.yamlwhere the tweet URL is the key, and the tweet text content is the value.
- Optional Date Filters: You can pass