hive.x-automation — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited hive.x-automation (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
browser_navigate(wait_until="load") returns in 1.3–1.6 s on a warm cache.| 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.
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 modalCritical: 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:
# 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.
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[data-testid="tweet"]');
const out = [];
tweets.forEach((t, i) => {
const reply = t.querySelector('[data-testid="reply"]');
if (!reply) return;
const r = reply.getBoundingClientRect();
if (r.width <= 0 || r.y < 0 || r.y > window.innerHeight) return;
const text = (t.textContent || '').slice(0, 120);
out.push({
index: i,
preview: text,
cx: r.x + r.width/2,
cy: r.y + r.height/2,
});
});
return out;
})();
""")
# For each unreplied candidate...
for c in candidates:
if already_replied(c['preview']):
continue # see dedup pattern below
# Click reply
browser_click_coordinate(c['cx'], c['cy'])
sleep(1.5) # composer slide-in
# Click the textarea to focus Draft.js
ta = browser_get_rect("[data-testid='tweetTextarea_0']")
browser_click_coordinate(ta.cx, ta.cy)
sleep(0.5)
# Type the reply
browser_type("[data-testid='tweetTextarea_0']", reply_text)
sleep(1.5) # Draft.js state commit takes a beat
# Verify button enabled
state = browser_evaluate("""
(function(){
const b = document.querySelector('[data-testid="tweetButton"]');
return b ? {d: b.disabled || b.getAttribute('aria-disabled') === 'true'} : {d: true};
})();
""")
if state['d']:
# Recovery: click the textarea again + one extra character toggles React state
browser_click_coordinate(ta.cx, ta.cy)
browser_press("End")
browser_press(" ")
browser_press("Backspace")
sleep(0.5)
else:
browser_click("[data-testid='tweetButton']")
sleep(2)
# Mark the task done in progress.db — see hive.colony-progress-tracker
# Close the composer (press Escape or click the Close button)
browser_press("Escape")
sleep(random.uniform(10, 20)) # human cadence — see rate limitsFor "daily reply to live posts matching query X" — e.g. job-market replies.
query = "job market"
url = f"https://x.com/search?q={urllib.parse.quote(query)}&src=typed_query&f=live"
browser_navigate(url, wait_until="load")
sleep(3)
browser_scroll("down", 2000)
sleep(1.5)
# Same replyable-tweets probe as above, then same reply-to-tweet loopbrowser_navigate("https://x.com/<your_username>/with_replies", wait_until="load")
sleep(3)
# Find the target article (by text match or index)
target_caret = browser_evaluate("""
(function(target_text){
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
for (const t of tweets){
if (!(t.textContent || '').includes(target_text)) continue;
const caret = t.querySelector('[data-testid="caret"]');
if (!caret) continue;
const r = caret.getBoundingClientRect();
return {cx: r.x + r.width/2, cy: r.y + r.height/2};
}
return null;
})();
""", target_text)
browser_click_coordinate(target_caret['cx'], target_caret['cy'])
sleep(0.8) # menu animation
# The Delete menuitem doesn't have a stable data-testid — find by text
delete_rect = browser_evaluate("""
(function(){
const items = document.querySelectorAll('[role="menuitem"]');
for (const el of items){
if ((el.textContent || '').trim() === 'Delete'){
const r = el.getBoundingClientRect();
return {cx: r.x + r.width/2, cy: r.y + r.height/2};
}
}
return null;
})();
""")
browser_click_coordinate(delete_rect['cx'], delete_rect['cy'])
sleep(0.8)
# Confirmation sheet — this one DOES have a stable testid
browser_click("[data-testid='confirmationSheetConfirm']")
sleep(1.5)X's compose editor is the canonical test case for every rich-text-editor bug the GCU bridge has ever had. What you need to know:
browser_type now does this click automatically.Input.dispatchKeyEvent with delay_ms=20 — also works, but insertText is more reliable and faster. Only pass delay_ms > 0 (which falls back to per-char dispatch) if you're specifically testing the keystroke timing path."estin" instead of "testin", prepend a throwaway character or use insertText.browser_evaluate on aria-disabled.End, press a space, press Backspace. This forces React to recompute hasRealContent and usually flips the button on.| Action | Limit |
|---|---|
| Tweets per hour | ~50 before throttling |
| Replies per session | 5–10 per run, randomized 10–20 s delays |
| DMs per day | Varies by account age; 50–100 for established accounts |
| Follow/unfollow | <400/day spread over time |
| Like per day | 1000 max; 200–300 is safer |
Signals you're being rate-limited or flagged:
https://x.com/account/access (anti-bot check)If any of these appear, stop the run, screenshot the state, and surface the issue. Do not retry immediately.
Dedup is handled by the colony progress queue, not a separate JSON file. The queen enqueues one row in the tasks table per reply target (keyed by tweet URL); workers claim, reply, and mark done. Already-done rows are skipped on the next claim — that's your crash-resume and cross-day dedup, for free. See hive.colony-progress-tracker for the full claim/update protocol.
Extract the tweet URL via browser_evaluate so the queen can use it as the task key:
url = browser_evaluate("""
(function(article_index){
const t = document.querySelectorAll('article[data-testid="tweet"]')[article_index];
if (!t) return null;
const link = t.querySelector('a[href*="/status/"]');
return link ? link.href : null;
})();
""", article_index)If you need to check whether a given tweet URL has already been replied to in a prior run (e.g., scanning live search results before enqueuing), query the queue directly:
sqlite3 "<db_path>" "SELECT status FROM tasks WHERE payload LIKE '%\"tweet_url\":\"<url>\"%';"Empty → not yet enqueued, safe to add. Otherwise honor the existing row's status.
These are soft rules derived from the backed-up x-daily-replies and x-job-market-replies skills — tune per your operator's preference.
Daily replies (siren_fs persona, dark humorous):
Job-market replies (casual slang + prediction-market CTA):
browser_type handles this automatically now, but if you're using raw CDP calls, click first.browser_type default (uses Input.insertText). Only use delay_ms=20 fallback if you need per-keystroke timing.[data-testid="tweetButton"]'s disabled / aria-disabled before clicking. If disabled, run the recovery dance./account/access. Detect by checking the URL after navigation and the presence of the home nav: challenged = browser_evaluate("""
(function(){
return window.location.href.includes('/account/access') ||
!document.querySelector('a[data-testid="AppTabBar_Home_Link"]');
})();
""")If challenged, stop and surface to the operator. Do not try to solve it.
sleep(1.5) after clicking before trying to query the textarea.browser_navigate reloads everything and slows the run. Use browser_click on internal links when possible.Check logged-in state before any action:
logged_in = browser_evaluate("""
(function(){
return !!document.querySelector('a[data-testid="AppTabBar_Home_Link"]') &&
!window.location.href.includes('/i/flow/login');
})();
""")If not logged in, stop immediately and surface. Do not attempt to log in via automation.
browser-automation skill — general CDP/coord/screenshot rules, click-then-type pattern, Input.insertTextlinkedin-automation skill — LinkedIn equivalent~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.