How Slop Signal works
This is how the extension decides what to label, from the page all the way to the server and back. I've also included the parts I got wrong the first time.
The idea is simple. You scroll X or LinkedIn like you normally do, and posts that look AI-written get a small tag next to them. I didn't want it to block anything or make you click anything. I also didn't want to run up a big bill or collect data I had no use for. Most of the choices below come back to those two things.
The short version
Here's what happens to every post you scroll past:
Spot new posts
A MutationObserver sees posts as the site renders them.
Pull out the text
Clean it up. Under 10 words? Skip it.
Wait until you’re close
Nothing happens until the post is about to scroll into view.
Check the cache
Seen this exact text in the last week? Reuse the label.
Ask the server
If not, the server asks the model and sends back a label.
Show the label
Two attributes on the post, drawn with CSS.
The extension is built with WXT (Manifest V3) in plain TypeScript. No React. The popup has a toggle, a few counts and a progress bar, and a framework would have been bigger than everything else put together. The backend runs on Convex, and the classifying is done by TypeSafe's Jev model through OpenRouter. The Convex client is the only dependency the extension ships with.
Finding posts
Each site has a small adapter that knows where the posts are and where the text lives. Nothing else in the code knows about X or LinkedIn markup, which matters because both sites change it often.
X was the easy one. Every post is an article[data-testid="tweet"], and the text is the first tweetText inside it. I take the first one on purpose, because a second one means a quoted tweet, and I didn't want someone else's post deciding the label on yours. I did add a fallback that matched any role="article" element. It started labelling my notifications, so I took it out.
LinkedIn took a lot longer. My first try was to find each post's outer container and watch that. Nothing ever got labelled. It turns out posts on the new feed are wrapped in elements with display: contents. Those have no box on screen, so the browser never reports them as visible. In the end I stopped looking for posts and went for the text blocks instead ([data-testid="expandable-text-box"], with a few older class names as backup). This also means longer comments get labels, which I didn't plan but kind of like.
Selectors will break again at some point. If the extension goes ten seconds on a page without finding a single post, it writes to the console how many elements each selector matched. Just the counts, no post content. When a redesign ships, that's usually the first clue.
Getting clean text
I started with innerText. It misses the part of a long post that X hides behind “Show more”, and it drops emoji, because X draws them as images. So the extension walks the DOM itself. It keeps text, turns line breaks and block elements into newlines, keeps each emoji image's alt text, and skips buttons.
After that the text gets tidied up. That means Unicode normalization, removing invisible characters (but not the joiner that emoji need), collapsing extra whitespace, and dropping lines that are really just buttons, like “Reply” or “See more”. The same post always ends up as the same string, which is what makes the cache work.
Anything under 10 words is ignored. I count words with Intl.Segmenter instead of splitting on spaces. Chinese, Japanese and Thai don't put spaces between words, and a space-based count would treat a whole paragraph as one word. Why 10? Detectors get unreliable on very short text. But plenty of slop is short, so I didn't want to set the bar much higher.
Only checking what you actually see
Both sites are single-page apps that keep loading more as you scroll. I didn't want to hook into their routing, so there's just one MutationObserver watching the page. It handles navigation, infinite scroll and re-renders. New elements get batched up and handled in a requestIdleCallback, when the browser has a spare moment. It ignores text changing outside posts. Early on, every “2m ago” timestamp ticking over was waking it up.
The extension remembers each post element in a WeakMap, along with its text. If the site reuses an element for a different post, the text won't match and it gets checked again. If the site re-renders a post and the label disappears, it just puts it back without asking the server again.
A post that's merely on the page doesn't get sent anywhere. An IntersectionObserver with a 600px margin waits until it's nearly on screen. Posts you never get to cost nothing. Posts that are ready go into a queue that runs three at a time, newest first. If you flick down the feed quickly, the post you stopped on gets its label before the ones you flew past.
One X quirk: long posts only show about 280 characters in the timeline, so that preview is what gets judged first. If you click “Show more”, the new text shows up, the observer notices, and the post is checked again with the full text.
The background worker
The script running inside X or LinkedIn doesn't call the server itself. It sends a message to the extension's background worker and waits for an answer. Doing it there means all your tabs share one cache, and the site's own security rules can't block the request.
const key = await sha256(`${platform}\n${normalizedText}`);
// 1. Local cache (chrome.storage.local, 7 days)
const cached = await getCached(key);
if (cached) return cached;
// 2. Same post already on its way? Share that request.
if (inFlight.has(key)) return inFlight.get(key);
// 3. Otherwise call the server, retrying once after 1.5s
const request = withRetryOnce(() => classifyRemote(post));
inFlight.set(key, request);The cache is in chrome.storage.local. The key is a SHA-256 hash of the site plus the text, and all that's saved is the label and a timestamp. The post text isn't kept, not even on your own machine. Entries last 7 days. Every key includes a version number, and whenever I change how posts are scored, I bump it. Old entries get cleared the next time the worker starts, so you don't see labels from an older version.
If the same post is open in three tabs, only one request goes out and all three wait on it. A failed request is retried once. Failures are never cached, because one bad hour at the model provider shouldn't leave you with a week of missing labels.
The server
On the Convex side there's one public function, classifyPost. First it checks the input. The site has to be X or LinkedIn, the install ID has to look like a UUID, and the text has to be between 1 and 3,000 characters. Anything else is rejected before it gets anywhere near the model.
Next it takes one of that install's 500 checks for the day (the count resets at midnight UTC). Cache hits never reach the server, so they don't count. In practice you won't get close to 500 unless you're scrolling all day. The slot is taken before calling the model, so firing off a burst of requests at once doesn't get around the limit. Right now the limit is per install. I'd like to tighten that later.
This is everything the server stores:
installations: {
installId, // random UUID made by the extension
firstSeen, lastSeen,
platforms: { twitter, linkedin }, // request counts
labels: { AI_SLOP, LIKELY_AI, HUMAN }, // label counts
day, dayCount, // today's requests, for the 500/day cap
}Counters, and nothing else. No post text, no authors, no links, no model output. There's a test that classifies a post and then checks the stored row doesn't contain its text. The OpenRouter key is only set on the server, so it never ships with the extension.
How a post gets judged
This is what most people want to know. There's no prompt that says “is this AI?”. Jev is what TypeSafe calls a decision model. You give it structured input and specific questions, and it gives back a scored answer for each one. Slop Signal asks five questions about every post:
- AI style, scored 0 to 3. How much does it read like machine writing? Structure counts most, since it survives editing. Think the same hook everyone uses, one sentence per line, everything in threes, “It's not X. It's Y.”, and a neat lesson at the end. Word choice and formatting count less. Emoji, lists, good grammar or a non-English post don't count as evidence by themselves.
- Template: is it following a known viral format?
- Specificity: are there details someone could actually check, like names, real numbers or an opinion someone might disagree with?
- Substance, scored 0 to 3. Is it filler, or is there something to it?
- Engagement bait: “Agree?”, “Comment YES below”, “Drop a 🔥”.
My first version asked one question with four possible answers. The model kept hedging between “AI slop” and “likely AI”, and loads of posts came back unsure. Smaller questions got much clearer answers, and the code does the combining:
// every answer normalized to 0..1
const ai = 0.6 * aiStyle + 0.25 * template + 0.15 * (1 - specificity);
const lowValue = 0.5 * (1 - substance) + 0.3 * engagementBait + 0.2 * (1 - specificity);
if (ai >= 0.6 && lowValue >= 0.5) return "AI_SLOP";
if (ai >= 0.4) return "LIKELY_AI";
return "HUMAN";The first score decides between ✓ Looks human and ◐ Likely AI. The second only matters if the post already looks AI-written. If it's also thin, the label goes up to ⚠ AI slop. So a post someone clearly drafted with AI but that still says something useful gets “Likely AI”. I think that's fair.
The version before this had an “uncertain” middle band, and 38% of posts ended up there. A label that says “no idea” on a third of your feed isn't much use. On my test set, human posts never scored above 0.32 and AI posts never scored below 0.48. So I dropped the middle band and put a single cut-off at 0.40, and it got all 30 posts right. I tuned those numbers on those same 30 posts, though, so don't read too much into the score. It'll be wrong sometimes, which is why the popup says labels are a signal, not proof.
It's also cheap. A post comes to about 1,500 input tokens, around $0.00006. A thousand posts costs about six cents.
Showing the label without breaking the page
This was the most annoying bug to track down. The first version of the label was a regular <span> dropped into the post. On X it was fine. On LinkedIn it "worked for some posts", scrolling got laggy, and labels kept vanishing. LinkedIn renders the feed on the server and then hands it to React in the browser. React found an element it didn't put there, threw hydration error #418 and re-rendered the post. That wiped the label, the extension added it back, and round it went.
The fix was to stop adding elements at all. The label is now just two attributes on the post's text, and CSS draws it:
anchor.setAttribute("data-slop-signal", "ai-slop");
anchor.setAttribute("data-slop-signal-text", "⚠ AI slop");
/* style.css */
[data-slop-signal]::before { content: attr(data-slop-signal-text); /* pill styles */ }React doesn't care about attributes it didn't set. My own MutationObserver only watches for added elements, so changing an attribute doesn't set it off again. And you can't get a duplicate label, because there's nothing to duplicate. Putting it in ::before also keeps it at the top of the post, so LinkedIn's “see more” cut-off doesn't hide it.
The colours are mid-tones that work on light and dark themes. Each label has an icon, so you don't need to tell red from orange. If your system is set to reduce motion, the fade-in is off. There's also no “checking…” state anymore. The first version had one. When a request failed, the pill just disappeared, and it looked like the extension had broken. Now the label only shows up once there's an answer.
What leaves your browser
Most of this follows from everything above, but here it is in one place.
- Sent: the text of posts of 10 or more words you scroll near, which site it's on, whether it has an image, video or link, and a random install ID.
- Never sent: who wrote it, the post link, who you follow, your messages, cookies or history.
- Kept on the server: the counters above.
- Kept in your browser: the install ID, your on/off setting, label counts, today's usage and the hashed cache. Uninstalling removes all of it.
No account, no sign-in. Apart from access to x.com, twitter.com and linkedin.com, the only permission it asks for is storage. The privacy policy has the details.
Testing and releasing
Tests run on Vitest. The extension side uses happy-dom and WXT's fake browser. It covers both sites' markup (old and new LinkedIn), text cleanup, word counts in Chinese, the scoring cut-off, cache versioning, request sharing, the queue and the “Show more” re-check. There's also a test checking that the label never adds an element. On the server side, convex-test runs with the model mocked out, to check input validation, the daily limit and that no text gets stored.
There's also an eval script that runs a labelled set of posts through the real model and prints every score. I run it before changing the questions or weights, because it's very easy to "improve" one case and quietly break three others. Shipping means bumping the VERSION file, deploying Convex, building the zip with WXT and uploading it to the Chrome Web Store.
What it doesn't do yet
- On X, long posts are judged on the preview until you open them.
- On LinkedIn, it doesn't know yet whether a post has an image or video.
- Posts over 3,000 characters are skipped for now instead of being trimmed.
- The counts in the popup are kept locally and can drift a little.
What I want to add next: a “wrong label?” button (it would only send the hash), a way to hide or dim slop, per-site switches, Firefox support, and maybe Threads, Reddit and Bluesky.
If you spot a label that's way off, please tell me. Those reports are genuinely the most useful thing for making it better. Here's how to reach me.