| name | twitter-filter-rules |
| description | Generate Twitter Filter spam-blocking rules from real x.com examples. Use when the user pastes one or more tweet URLs or profile URLs of accounts they want filtered, and wants ready-to-import rules for the Twitter Filter browser extension. Fetches reply data through Claude in Chrome (uses the user's existing x.com login), identifies cross-account patterns, and emits import-ready JSON with `hide` action by default. |
twitter-filter-rules
Help the user write Twitter Filter rules from real spam examples. The user pastes URLs; you open them in their logged-in browser, look at the underlying data, find common signals, and produce import-ready JSON.
When to invoke
Trigger this skill when the user:
- Pastes 1+ x.com URLs (tweet or profile) and asks to filter "accounts like these"
- Says something like "the spam under this tweet keeps slipping through" + URL
- Asks to "generate / build / write a rule for this kind of account"
Do not trigger for:
- Generic "how do I write a rule for X" questions (no concrete examples). Point them at the rule reference docs in the extension instead.
- Requests to filter someone they personally dislike but who isn't actually spam — politely decline.
Prerequisites (verify before starting)
The skill needs all of:
- Claude in Chrome MCP tools — load via ToolSearch with
select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__tabs_create_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__javascript_tool,mcp__claude-in-chrome__read_network_requests,mcp__claude-in-chrome__browser_batch. If these aren't available, stop and tell the user to install the Claude in Chrome extension.
- User logged into x.com — the Claude in Chrome extension uses the user's existing browser profile, so the x.com session is shared. Unauthenticated requests can't read replies.
- The Twitter Filter extension — to actually import the generated rules. If unsure, mention it but proceed with rule generation regardless.
Workflow
Step 1: Confirm scope
Echo back what you understood: how many URLs, what the user described as the problem. Ask for clarification only if scope is ambiguous (e.g., they pasted a profile URL but want to filter that user's followers — clarify which).
Step 2: Open a working tab and gather data
Use tabs_context_mcp first; create a new tab via tabs_create_mcp rather than reusing an existing one. Then for each URL:
For each tweet URL of the form x.com/<user>/status/<id>:
- Navigate to the URL via
navigate
- Wait ~4 seconds for the SPA to load
- Scroll to load the reply section (
window.scrollTo(0, 1500) then wait ~3s)
- Use
read_network_requests with urlPattern: "TweetDetail" to capture the current request URL (the GraphQL query hash rotates; do not hardcode it)
- Re-fetch that URL via
fetch() in the page context, with credentials: 'include' and the bearer token + x-csrf-token from the ct0 cookie. This gives you the full JSON body with all reply data.
- Walk the response for every
tweet_results.result node. Extract per reply:
legacy.id_str, legacy.full_text, legacy.lang, legacy.in_reply_to_status_id_str
- From
core.user_results.result: core.screen_name, core.name, core.created_at, legacy.followers_count, legacy.friends_count, legacy.statuses_count
- From
core.user_results.result.legacy: description, url, default_profile, default_profile_image
- Important: replace
t.co placeholders in description and url with the matching expanded_url from entities.description.urls and entities.url.urls. The Twitter Filter extension does this transparently, so your analysis must too — otherwise description contains "t.me/" will look like it doesn't match when it actually does.
- Look for a
cursorType: "ShowMoreThreads" node with a displayTreatment.actionText like "Show probable spam". If present, re-issue the same TweetDetail call with cursor: <that value> in variables to fetch the hidden replies. Merge results.
For a profile URL of the form x.com/<user>:
Treat the user's own profile as a single sample. Capture the same user fields. (Useful when the user wants to filter "accounts like this one".)
Step 3: Sanity check the sample
Before pattern-mining, check:
- At least 2 samples: with only 1, you can produce a single-account-shape rule but cannot identify cross-account patterns. Be explicit with the user about this limitation.
- The samples really are spam-like: filter out the OP and replies from the OP themselves. Look at follower counts — if everything you got is from accounts with >1k followers and full bios, you may have grabbed legitimate replies; ask the user to point to specific account handles instead.
Step 4: Mine patterns
For each candidate signal, count how many samples it covers:
| Signal | Check |
|---|
| Brand-new account | days_since(created_at) < 30 (or 60, or 90 — pick the tightest band that still covers the samples) |
| Low followers | followers_count < 2, < 5, or < 10 — again, tightest band |
| Suspicious post volume for age | e.g. statuses_count > 50 on a <90-day-old account |
| Empty or default profile | description empty, or default_profile_image == true |
| Bot-like username | screen_name matches ^[a-zA-Z]+\d{8,}$ |
| Telegram drainage in bio | expanded description contains t.me/ |
| Telegram euphemisms (Chinese spam) | description contains 电报, 飞机/, 纸飞机, 飞机号, 电报号 |
| Lookalike substitution in bio | description contains visually-similar variants (e.g., 丄 U+4E04 instead of 上 U+4E0A — common in Chinese escort spam) |
| Invisible characters in tweet text | tweet.text contains any of [͏] (see exclusions below) |
| All-emoji / no real script | tweet.text doesn't match [a-zA-Z0-9一-鿿-ヿᄀ-ᇿ가-] |
| Common short text template | same 4-8 character template across samples |
| Shared external handle | same t.me/<handle> or domain across samples (a "fingerprint" of the campaign) |
Combine 2–4 signals per rule. Single-signal rules are not allowed unless the signal itself is structurally rare enough to stand alone (e.g., description contains "t.me/<specific-shared-handle>" only when at least 2 samples share it).
Step 5: Propose rules
For each rule, present:
- The DSL expression
- Which sample(s) it would catch
- Why this combination (which signals it covers)
- Recommended action (default
hide, never block unless explicitly requested AND FP risk is essentially zero)
- Estimated false-positive risk (low / medium — don't overclaim "zero")
Example presentation:
Rule 1: Telegram-drainage low-follower replies
Condition:
tweet.is_reply
&& user.description contains "t.me/"
&& user.followers < 10
Catches: 8/8 samples
Why: bio links are this campaign's monetization funnel; combined with low-follower
reply context, FP risk is low (some legit crypto/Persian users have t.me bios
but they tend to have >10 followers).
Action: hide
FP risk: low
Step 6: Confirm before emitting JSON
Wait for the user to approve / tweak. Then emit import-ready JSON in this shape (one object per rule, in an array):
[
{
"name": "Telegram-drainage low-follower replies",
"condition": "tweet.is_reply && user.description contains 't.me/' && user.followers < 10",
"action": "hide",
"enabled": true
}
]
Tell the user to open the extension's Rules tab → Import → paste the JSON.
Hard constraints
These are non-negotiable. Violating them produces rules that either rot quickly or harass legitimate users.
- Never write a rule that is just a single keyword or single handle. Spammers rotate handles every few weeks; a
user.screen_name == 'foo123' rule will be dead in a month and adds nothing today (the user can already block that account with one click).
- Default action is
hide. Never propose block unless: (a) the user explicitly asks for it, AND (b) FP risk is genuinely near-zero (e.g., bio contains a known scam-only string + multiple structural signals). When in doubt, hide.
- Need ≥2 samples for cross-account patterns. With only 1 sample, restrict yourself to rules based on structural features of that account (e.g.,
user.default_profile_image && user.followers < 2 && days_since(user.created_at) < 30), not on shared keywords or handles.
- Invisible-character rules must exclude these code points:
U+200D (Zero Width Joiner) — required for emoji sequences like 👨👩👧, 🏳️🌈, 👨💻
U+200C (Zero Width Non-Joiner) — required for Persian/Arabic/Indic scripts
U+FE0F (Variation Selector-16) — required for emoji color rendering
The safe set is [͏] (and even those should be combined with tweet.is_reply && user.followers < N to suppress edge-case FPs from CMS-pasted text).
description and url are pre-expanded by the extension. Write rules against the expanded form (e.g., description contains "t.me/"), not the t.co form.
- Do not propose rules that depend on fields not in the DSL. See the variable list below — anything else will silently no-op.
Heuristic playbook (rule templates that work)
Use these as starting points; tighten the thresholds based on the actual sample data.
Mass-reply bot (account-stat-only)
tweet.is_reply
&& user.followers < 2
&& len(user.description) == 0
&& days_since(user.created_at) < 90
&& user.statuses_count > 50
Telegram drainage in bio
tweet.is_reply
&& user.description contains "t.me/"
&& user.followers < 10
Variants for Chinese-language spam (Telegram euphemisms):
user.description contains "电报:" || user.description contains "飞机/"
|| user.description contains "纸飞机" || user.description contains "飞机号"
Invisible-character text padding
tweet.is_reply
&& user.followers < 10
&& tweet.text matches "[͏]"
Bot-style usernames (letters + 8+ digits)
user.screen_name matches "^[a-zA-Z]+\d{8,}$"
&& user.followers < 10
&& days_since(user.created_at) < 180
All-symbol/emoji replies
tweet.is_reply
&& !(tweet.text matches "[a-zA-Z0-9一-鿿-ヿᄀ-ᇿ가-]")
&& len(tweet.text) > 0
&& user.followers < 2
&& days_since(user.created_at) < 90
Lookalike-character bio (escort/gambling spam evading exact-keyword filters)
If samples share a visually-similar substitution (e.g., 丄 for 上, 戝 for 财):
user.description contains "丄门"
Combine with low-follower / reply context if possible.
Rule DSL reference (compact)
Variables
User: user.id, user.name, user.screen_name, user.description, user.location, user.url, user.avatar, user.default_profile, user.default_profile_image, user.blue_verified, user.verified, user.verified_type, user.followers, user.following, user.statuses_count, user.created_at, user.protected, user.is_following, user.followed_by, user.blocking, user.blocked_by, user.muting
Tweet: tweet.text, tweet.is_reply, tweet.has_links, tweet.has_media, tweet.lang (BCP-47 string, special values "und" and "zxx"), tweet.is_promoted
Operators
== != < > <= >= && || ! + - * /
contains (case-insensitive substring; strips U+FE0F automatically)
matches (regex; uses JavaScript RegExp constructor — style escapes work)
Functions
is_null(val), len(val) (string length, 0 for non-strings), days_since(iso_date) (Infinity for invalid), lower(val)
Null handling
- Arithmetic with null → null
- Comparison (
<, >, etc.) with null → false (so user.followers < 2 is false when followers is null — be aware)
- Equality works (
user.description == null is true when bio is empty)
contains / matches return false when either operand isn't a string
Actions
hide (default — completely hidden from timeline), mark (yellow border + reduced opacity, useful for previewing), block (hides + blocks the account after a 3s undo window — destructive, opt-in only)
Output format reminder
After confirmation, emit a single fenced JSON block, an array of rule objects:
[
{
"name": "Short human-readable label",
"condition": "<DSL expression>",
"action": "hide",
"enabled": true
}
]
Then one line: "Open the Twitter Filter extension → Rules tab → Import → paste."
Do not include id, createdAt, or other fields — the extension assigns them on import.