Skip to main content
ClaudeWave
Skill10.5k repo starsupdated 14d ago

hive.x-automation

# ClaudeWave: hive.x-automation This Claude Code skill provides verified automation workflows for X/Twitter operations including posting, replying, deleting, and search-and-engage interactions. Use it when automating X tasks with the browser_* tools, as it documents Draft.js composer quirks (like dropped initial characters), current selector stability, and timing expectations for navigation and modal transitions. Requires activation of hive.browser-automation as a prerequisite for coordinate-based workflows.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/aden-hive/hive /tmp/hive.x-automation && cp -r /tmp/hive.x-automation/core/framework/skills/_preset_skills/x-automation ~/.claude/skills/hive.x-automation
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# X / Twitter Automation

X uses **Draft.js** (the original Facebook rich-text editor) for the compose text area, which was the original canary for all the rich-text editor quirks the `browser-automation` skill now documents. Most of the site is otherwise stable — `data-testid` attributes have held up for years, the SPA is reasonably honest about what it renders, and shadow DOM is minimal. The hard parts are the composer, rate limiting, and the occasional anti-bot challenge.

**Always activate `browser-automation` first.** This skill assumes you already know about CSS-px coordinates, click-first typing, and `Input.insertText`. The guidance below is X-specific.

## Timing expectations

- `browser_navigate(wait_until="load")` returns in **1.3–1.6 s** on a warm cache.
- After navigation, **`sleep(2–3)`** for SPA hydration before querying selectors.
- Compose modal slide-in: **~1.5 s** after clicking reply / compose.
- First 1–2 characters typed into the compose editor **may be dropped** — see "Draft.js quirks" below.

## Verified selectors (2026-04-11)

| Target | Selector |
|---|---|
| Home nav link | `a[data-testid='AppTabBar_Home_Link']` |
| Explore nav link | `a[data-testid='AppTabBar_Explore_Link']` |
| Notifications | `a[data-testid='AppTabBar_Notifications_Link']` |
| Main search input | `input[data-testid='SearchBox_Search_Input']` |
| Compose text area | `[data-testid='tweetTextarea_0']` (Draft.js contenteditable) |
| Post / Tweet submit button | `[data-testid='tweetButton']` |
| Reply button (on feed / tweet detail) | `[data-testid='reply']` |
| Like button | `[data-testid='like']` |
| Retweet / repost button | `[data-testid='retweet']` |
| Caret (⋯) menu on a post | `[data-testid='caret']` |
| Confirmation sheet confirm button | `[data-testid='confirmationSheetConfirm']` |
| Tweet article wrapper | `article[data-testid='tweet']` |
| Close modal / composer | `[aria-label='Close']` or press `Escape` |

All of these are light-DOM `data-testid` attributes — `wait_for_selector` and `browser_type(selector=...)` work on them directly, no shadow piercing needed.

## Post new tweet flow

```
browser_navigate("https://x.com/home", wait_until="load")
sleep(3)

# Open the compose UI (click the post-new-tweet nav or use shortcut N)
browser_press("n")   # keyboard shortcut — opens compose modal
sleep(1.5)

# Click the textarea to make sure Draft.js is in edit mode
ta_rect = browser_get_rect("[data-testid='tweetTextarea_0']")
browser_click_coordinate(ta_rect.cx, ta_rect.cy)
sleep(0.5)

# Type — browser_type handles Draft.js correctly now via Input.insertText
browser_type("[data-testid='tweetTextarea_0']", tweet_text)
sleep(1.0)  # let Draft.js commit state

# Verify the Post button is enabled — never click blindly, Draft.js sometimes
# doesn't register the input even with a prior click.
state = browser_evaluate("""
  (function(){
    const btn = document.querySelector('[data-testid="tweetButton"]');
    if (!btn) return {found: false};
    return {
      found: true,
      disabled: btn.disabled || btn.getAttribute('aria-disabled') === 'true',
    };
  })();
""")
if state['found'] and not state['disabled']:
    browser_click("[data-testid='tweetButton']")
    sleep(2)
    browser_press("Escape")  # close any leftover modal
```

## Posting a tweet WITH an image

**Critical: NEVER click the photo button.** On `x.com/compose/post` the media button is a styled `<button>` that triggers Chrome's native OS file picker when clicked — that dialog is unreachable via CDP and will wedge the automation. Instead, set the file directly on the hidden `<input type='file'>` element using `browser_upload`:

```python
# 1. Open the compose modal as usual
browser_press("n")
sleep(1.5)
browser_click_coordinate(ta_rect.cx, ta_rect.cy)
sleep(0.5)
browser_type("[data-testid='tweetTextarea_0']", tweet_text)

# 2. Find the hidden file input X uses for media uploads.
#    X's input is marked with data-testid='fileInput' and accepts
#    image/*,video/*. It's hidden (display:none) but still mounted.
inputs = browser_evaluate("""
  (function(){
    return Array.from(document.querySelectorAll('input[type="file"]'))
      .map(el => ({
        testid: el.getAttribute('data-testid') || '',
        accept: el.accept || '',
        multiple: el.multiple,
      }));
  })();
""")
# Expect to see: [{testid: 'fileInput', accept: 'image/jpeg,...', multiple: true}]

# 3. Set the file WITHOUT opening any dialog
browser_upload(
    selector="input[data-testid='fileInput']",
    file_paths=["/absolute/path/to/photo.png"],
)
sleep(2)  # X takes ~1-2s to show the preview thumbnail

# 4. Verify the preview rendered before posting — if not, the upload
#    didn't land and Post button will fail.
preview = browser_evaluate("""
  (function(){
    // X renders uploaded media as an <img> with data-testid='attachments'
    // (or similar) inside the composer.
    const att = document.querySelector('[data-testid="attachments"] img');
    return { hasPreview: !!att };
  })();
""")
if not preview['hasPreview']:
    raise Exception("Upload didn't render in composer — do NOT click Post")

# 5. Now click Post as usual
browser_click("[data-testid='tweetButton']")
sleep(3)  # media upload + post takes longer than text-only
browser_press("Escape")
```

If you don't already have the image file on disk, write it first: `write_file("/tmp/x_upload.png", base64_bytes)` or copy from a known location. `browser_upload` requires an absolute file path — relative paths and `~` expansion are not supported.

## Reply to a post flow

The reply flow is the same shape as posting, with a few scroll / find-and-click steps before.

```
browser_navigate("https://x.com/home", wait_until="load")
sleep(3)

# Load content by scrolling — X lazy-loads feed items
browser_scroll(direction="down", amount=2000)
sleep(1.5)

# Find replyable tweets — reply buttons, in visual/feed order
candidates = browser_evaluate("""
  (function(){
    const tweets = document.querySelectorAll('article