Blocks AI sounding text in social media writing (X replies, X posts, LinkedIn posts) and injects the writing rules.

A Claude Code mod that stops Claude from writing AI sounding posts and replies for X, LinkedIn and other social media. MIT licensed.
I let Claude write 48 replies on X for me. They read like this:
$200 MRR and 200 active subscribers as a side project is real traction. That's enough to validate the product works. Now the question is whether to double down or keep compounding on the side.
They got 2 to 7 views each. So I read 421 replies under 8 indie hacker posts and compared the ones people liked with the ones nobody touched. The liked ones asked a real question, shared the replier's own number, gave the fix, joked about one detail or pushed back in a few words. The ignored ones sounded like the reply above. rules.md is what came out of that.
| Where | What happens |
|---|---|
| System prompt | a short notice that social text follows rules.md |
| Your prompt | if it mentions tweets, X posts or replies, LinkedIn, TikTok, Reddit, captions and so on, rules.md and your voice file are attached |
| Write and Edit | .txt and .md files under a folder whose name contains social, linkedin, tweet or twitter are checked. ALL CAPS file names (README.md, PLAN.md) count as notes and are skipped |
| Bash | commands that write into those folders are checked |
| Browser | text Claude types through Claude in Chrome (type, form_input, browser_batch, scripts that insert text) is checked. Hashtags are allowed when the tab is on youtube.com (titles and descriptions there need them); all other rules still apply |
When the text breaks a rule, the tool call is blocked and Claude gets the hits:
slopless blocked this text. Rewrite it from scratch, do not reword around the rule.
- verdict: "real traction"
- advice: "double down"
- word: "compounding"
What hooks/lint.ts blocks:
| Rule | Examples |
|---|---|
| contrast | not just, rather than, instead of, matters more than, wins over |
| template | is the product, doing all the work, the hard part, where the time goes, this is why |
| verdict | real traction, real validation, smart way to, sharp framing |
| advice | double down, now figure out, the question is whether |
| word | leverage, moat, compounding, seamless, robust, crucial, ROI and about 20 more |
| format | em dashes, hyphens between words, hashtags, emoji, profanity |
| lol | more than once in a text, or in more than 1 of the last 10 texts |
The hyphen, emoji and profanity bans are my own taste. Delete those lines in hooks/lint.ts if you want them back.
Regexes only catch the obvious stuff. A moral one liner at the end of a post, or a reply that explains the post back to its author, gets through the linter. rules.md covers those, and Claude reads it before it writes.
Same prompt, run headless with Sonnet 5.5, with and without the mod: write my reply to "Huge milestone for my side project: $200 MRR and over 200 active subscribers", make it sound insightful, say this is real traction and the question is whether to double down.
Without slopless:
This is real traction. 200 people paying is a different signal from 200 people signing up. It validates the product: strangers value it enough to hand over money every month. Now the interesting question is whether to double down.
With slopless:
$200 across 200 subs is about $1 each, is that a cheap plan or mostly discounts?
And when I told it to save "This is real traction. The moat was never the code, it is the distribution. Now double down." word for word into a posts folder, the write was blocked and nothing was saved.
Optional. A markdown file with facts about you: what you built, your real numbers, what you never talk about. Claude may only claim experience from it. Point the mod at it:
echo '{"voiceFile": "/absolute/path/VOICE.md"}' | claude plugin configure slopless@slopless --values-stdin
The mod blocks claude plugin disable and uninstall for itself, edits to its own files, and settings edits that switch it off. You can still turn it off yourself with /plugin.
Inside Claude Code:
/plugin marketplace add 0xGondarxyz/slopless
/plugin install slopless@slopless
Or try it without installing:
git clone https://github.com/0xGondarxyz/slopless
claude --plugin-dir slopless
Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.
Run its tests: claude plugin test slopless
Sister mod: dashless, which rewrites em dashes out of everything Claude writes.
hooks/register.ts 245 lines1import type { EngineInterface, Register } from 'claude-code'
2import { lint, hasLol } from './lint'
3import type { Hit } from './lint'
4
5const TRIGGER =
6 /\b(tweets?|tweeting|twitter|x\.com|linkedin|instagram|tiktok|reddit|social media|socials|captions?|hashtags?)\b|\bx (post(s|ed|ing)?|repl(y|ies)|comments?|threads?)\b|\bon x\b/i
7const SOCIAL_SEGMENT = /social|linkedin|tweet|twitter/i
8const NOTE_NAME = /^(?=.*[A-Z])[A-Z0-9_]+$/
9const WRITES = />|\btee\b|\bcp\s|\bmv\s|\bsed\s+-i|<<|python/
10const ROOT_TOUCH = />|\btee\b|\bcp\s|\bmv\s|\brm\s|\bsed\s+-i|<<|python|\bchmod\b|\bln\s/
11const PLUGIN_OFF = /\bclaude\s+plugins?\s+(disable|uninstall|remove|rm)\b[^\n]*slopless/i
12const SETTINGS_OFF = /"slopless[^"]*"\s*:\s*false/
13const WRITES_TO_PAGE =
14 /insertText|execCommand|\.value\s*=|textContent\s*=|innerText\s*=|innerHTML\s*=|contenteditable|tweetTextarea|ClipboardEvent|DataTransfer/i
15const CHROME = 'mcp__claude-in-chrome__'
16const NO_OFF = 'slopless cannot be turned off by the model. Ask the user to do it with /plugin.'
17
18let blocked = 0
19let rulesShown = false
20
21const words = (s: string) => s.split(/\s+/).filter(Boolean).length
22
23function isSocialFile(path: string): boolean {
24 const parts = path.split('/')
25 const base = parts[parts.length - 1] ?? ''
26 if (!/\.(txt|md)$/i.test(base)) return false
27 if (NOTE_NAME.test(base.replace(/\.(txt|md)$/i, ''))) return false
28 return parts.some((p) => SOCIAL_SEGMENT.test(p))
29}
30
31function bashTouchesSocial(cmd: string): boolean {
32 // Quoted prose (commit messages, titles) names a platform without writing to it.
33 return cmd
34 .replace(/'[^'\n]*'|"(?:[^"\\\n]|\\.)*"/g, (q) => (/[ \t]/.test(q) ? ' ' : q))
35 .split(/\s+/)
36 .map((t) => t.replace(/^['"(]+|['"),;:.!?]+$/g, ''))
37 .filter((t) => /[/._-]/.test(t))
38 .some((t) => t.split('/').some((p) => SOCIAL_SEGMENT.test(p)))
39}
40
41function jsLiterals(code: string): string[] {
42 const out: string[] = []
43 const re = /'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"|`((?:[^`\\]|\\.)*)`/g
44 let m: RegExpExecArray | null
45 while ((m = re.exec(code))) {
46 const s = m[1] ?? m[2] ?? m[3] ?? ''
47 if (words(s) >= 3) out.push(s)
48 }
49 return out
50}
51
52type BrowserText = { text: string; tabId?: number }
53
54function browserTexts(name: string, input: Record<string, unknown>): BrowserText[] {
55 const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
56 const forAction = (tool: string, inp: Record<string, unknown>): string[] => {
57 if (tool === 'computer') {
58 const t = str(inp.text)
59 return inp.action === 'type' && t ? [t] : []
60 }
61 if (tool === 'form_input') {
62 const v = str(inp.value)
63 return v ? [v] : []
64 }
65 if (tool === 'javascript_tool') {
66 const t = str(inp.text)
67 return t && WRITES_TO_PAGE.test(t) ? jsLiterals(t) : []
68 }
69 return []
70 }
71 const out: BrowserText[] = []
72 const add = (texts: string[], inp: Record<string, unknown>) => {
73 const tabId = typeof inp.tabId === 'number' ? inp.tabId : undefined
74 for (const text of texts) out.push({ text, tabId })
75 }
76 if (name.startsWith(CHROME)) {
77 const tool = name.slice(CHROME.length)
78 if (tool === 'browser_batch') {
79 const actions = Array.isArray(input.actions) ? input.actions : []
80 for (const a of actions) {
81 if (a && typeof a === 'object') {
82 const item = a as { name?: unknown; input?: unknown }
83 if (typeof item.name === 'string' && item.input && typeof item.input === 'object') {
84 const tn = item.name.startsWith(CHROME) ? item.name.slice(CHROME.length) : item.name
85 add(forAction(tn, item.input as Record<string, unknown>), item.input as Record<string, unknown>)
86 }
87 }
88 }
89 } else {
90 add(forAction(tool, input), input)
91 }
92 } else if (name.startsWith('mcp__') && /(type|fill|insert)/i.test(name)) {
93 add([str(input.text), str(input.value)].filter((s): s is string => !!s), {})
94 }
95 return out.filter((t) => words(t.text) >= 3)
96}
97
98function isYouTubeHost(url: string): boolean {
99 try {
100 const host = new URL(url).hostname.toLowerCase()
101 return host === 'youtube.com' || host.endsWith('.youtube.com')
102 } catch {
103 return false
104 }
105}
106
107// Tab id to URL for the browser group. Fails closed: any error or bad JSON gives no tabs.
108async function tabUrls($: EngineInterface): Promise<Map<number, string>> {
109 const urls = new Map<number, string>()
110 try {
111 const res = await $.mcp.call('claude-in-chrome', 'tabs_context_mcp', {})
112 if (res.isError) return urls
113 const block = (res.content as { type?: string; text?: string }[]).find((c) => c.type === 'text' && typeof c.text === 'string')
114 if (!block?.text) return urls
115 const ctx = JSON.parse(block.text.split('\n')[0] ?? '') as { availableTabs?: { tabId?: number; url?: string }[] }
116 for (const t of ctx.availableTabs ?? []) if (typeof t.tabId === 'number' && typeof t.url === 'string') urls.set(t.tabId, t.url)
117 } catch {}
118 return urls
119}
120
121async function readRules($: EngineInterface, voiceFile: string): Promise<string[]> {
122 const parts: string[] = []
123 try {
124 parts.push(await $.fs.read(`${$.plugin.root}/rules.md`))
125 } catch {}
126 if (voiceFile) {
127 try {
128 parts.push(await $.fs.read(voiceFile))
129 } catch {}
130 }
131 return parts
132}
133
134export const register: Register = (on, options) => {
135 const voiceFile = typeof options.voiceFile === 'string' ? options.voiceFile : ''
136
137 on('prompt.compose', ($, e, next) => {
138 const text =
139 'slopless is active. Text for social media (replies, X posts, LinkedIn posts, captions) must follow the slopless rules. ' +
140 'The rules are attached to prompts about social media and to slopless messages. ' +
141 'Writes to social folders and text typed into a browser are checked and blocked when they break the rules.'
142 return (async () => {
143 const res = await next(e)
144 return { ...res, sections: [...res.sections, { id: 'slopless:notice', text, scope: 'session' as const }] }
145 })()
146 })
147
148 on('prompt.submit', async ($, e, next) => {
149 try {
150 if (TRIGGER.test(e.text)) {
151 const parts = await readRules($, voiceFile)
152 if (parts.length > 0) {
153 rulesShown = true
154 return next({ ...e, context: [...(e.context ?? []), ...parts.filter((p) => p.length > 0)] })
155 }
156 }
157 } catch {}
158 return next(e)
159 })
160
161 on('tool.call', async ($, e, next) => {
162 const name = String(e.tool)
163 const input = e as unknown as Record<string, unknown>
164 const root = $.plugin.root
165 const str = (v: unknown) => (typeof v === 'string' ? v : '')
166
167 // Self protection.
168 if (name === 'Bash') {
169 const cmd = str(input.command)
170 if (PLUGIN_OFF.test(cmd) || (root && cmd.includes(root) && ROOT_TOUCH.test(cmd))) return { deny: NO_OFF }
171 }
172 if (name === 'Write' || name === 'Edit') {
173 const path = str(input.file_path)
174 const body = name === 'Write' ? str(input.content) : str(input.new_string)
175 if (root && (path === root || path.startsWith(`${root}/`))) return { deny: NO_OFF }
176 if (/settings(\.local)?\.json$/.test(path) && SETTINGS_OFF.test(body)) return { deny: NO_OFF }
177 }
178
179 // Collect text to lint.
180 let texts: BrowserText[] = []
181 let shell = false
182 if (name === 'Write' || name === 'Edit') {
183 const path = str(input.file_path)
184 if (isSocialFile(path)) texts = [{ text: name === 'Write' ? str(input.content) : str(input.new_string) }]
185 } else if (name === 'Bash') {
186 const cmd = str(input.command)
187 if (bashTouchesSocial(cmd) && WRITES.test(cmd)) {
188 texts = [{ text: cmd }]
189 shell = true
190 }
191 } else if (name.startsWith('mcp__')) {
192 texts = browserTexts(name, input)
193 }
194 if (texts.length === 0) return next(e)
195
196 const hits: Hit[] = []
197 const lolHistory = await readHistory($)
198 let pendingLol: boolean[] = []
199 let tabs: Map<number, string> | undefined
200 for (const { text: t, tabId } of texts) {
201 let textHits = lint(t, { shell })
202 if (tabId !== undefined && textHits.some((h) => h.rule === 'hashtag')) {
203 tabs ??= await tabUrls($)
204 if (isYouTubeHost(tabs.get(tabId) ?? '')) textHits = textHits.filter((h) => h.rule !== 'hashtag')
205 }
206 const lol = hasLol(t)
207 if (lol && [...lolHistory, ...pendingLol].slice(-9).some(Boolean)) {
208 textHits.push({ rule: 'lol', match: 'lol: used in one of the last 10 texts' })
209 }
210 if (textHits.length === 0) pendingLol = [...pendingLol, lol]
211 for (const h of textHits) if (!hits.some((x) => x.rule === h.rule && x.match === h.match)) hits.push(h)
212 }
213
214 if (hits.length === 0 && rulesShown) {
215 try {
216 await $.store.set('lolHistory', [...lolHistory, ...pendingLol].slice(-10))
217 } catch {}
218 return next(e)
219 }
220
221 blocked += 1
222 $.ui.status(`slopless: ${blocked} blocked`)
223 let msg =
224 hits.length === 0
225 ? 'slopless: read these rules, then write the text again following them.'
226 : 'slopless blocked this text. Rewrite it from scratch, do not reword around the rule.\n' +
227 hits.map((h) => `- ${h.rule}: "${h.match}"`).join('\n')
228 if (!rulesShown) {
229 rulesShown = true
230 const parts = await readRules($, voiceFile)
231 if (parts.length > 0) msg += `\n\n${parts.join('\n\n')}`
232 }
233 return { deny: msg }
234 })
235}
236
237async function readHistory($: EngineInterface): Promise<boolean[]> {
238 try {
239 const v = await $.store.get('lolHistory')
240 return Array.isArray(v) ? v.map(Boolean).slice(-10) : []
241 } catch {
242 return []
243 }
244}
245hooks/lint.ts 144 lines1export type Hit = { rule: string; match: string }
2export type LintOptions = { shell?: boolean }
3
4const RULES: [string, RegExp][] = [
5 ['dash', /\u2014/],
6 ['dash', / \u2013 /],
7 ['dash', /(^|\s)--(?=\s|$)|\w--/],
8 ['hyphen', /[A-Za-z]-[A-Za-z]/],
9 ['hashtag', /(^|\s)#[A-Za-z]\w*/],
10 ['emoji', /\p{Extended_Pictographic}/u],
11 ['profanity', /\bfuck\w*/],
12 ['contrast', /\bnot (just|only|merely|simply)\b/],
13 ['contrast', /\bnot [^.,;:!?\n]{1,40}, (but|it'?s|they'?re|that'?s)\b/],
14 ['contrast', /\b(it'?s|that'?s|this is|they'?re) not [^.!?\n]{1,40}[.;] (it'?s|that'?s|this is|they'?re)\b/],
15 ['contrast', /, not (a |an |the )?[a-z]+/],
16 ['contrast', /\brather than\b/],
17 ['contrast', /\binstead of\b/],
18 ['contrast', /\bmatters? more than\b/],
19 ['contrast', /\bmore than just\b/],
20 ['contrast', /\bwins over\b/],
21 ['contrast', /\bwins because\b/],
22 ['contrast', /\b(always|still|quietly) (wins?|beats?)\b/],
23 ['contrast', /\bwill always beat\b/],
24 ['contrast', /\bbeats (giving|having|being|doing|a|an|the)\b/],
25 ['template', /\bis the product\b/],
26 ['template', /\bthe moat\b/],
27 ['template', /\bwas never (the|about)\b/],
28 ['template', /\bthat'?s what makes\b/],
29 ['template', /\bis what (keeps|makes|separates|kills|matters)\b/],
30 ['template', /\bthe real (edge|unlock|win|question|problem|work|lesson|story|moat|game)\b/],
31 ['template', /\bhere'?s the thing\b/],
32 ['template', /\bthe craft is\b/],
33 ['template', /\bthe hard part\b/],
34 ['template', /\bwhere the time goes\b/],
35 ['template', /\bheavy lifting\b/],
36 ['template', /\bdoing all the (\w+ )?work\b/],
37 ['template', /\bis carrying\b/],
38 ['template', /\bcarrying the (whole|entire)\b/],
39 ['template', /\btreat it like one\b/],
40 ['template', /\blet that sink in\b/],
41 ['template', /\bthe lesson\?/],
42 ['template', /\bkey takeaway\b/],
43 ['template', /\bhere'?s what i learned\b/],
44 ['template', /\bthe best part\?/],
45 ['template', /\bgame ?changer\b/],
46 ['template', /\bin today'?s\b/],
47 ['template', /\bit'?s worth noting\b/],
48 ['template', /\blet'?s dive\b/],
49 ['template', /\bhard to fake\b/],
50 ['template', /\bthis is why\b/],
51 ['template', /\bwins? the math\b/],
52 ['template', /\blesson for (anyone|everyone)\b/],
53 ['template', /\bthe part i('?d| would)? (like|love|copy)\b/],
54 ['template', /\bsave yourself the\b/],
55 ['template', /\bexcited to share\b/],
56 ['template', /\bhumbled\b/],
57 ['template', /(^|\n)\s*(agree|thoughts)\?\s*$/m],
58 ['verdict', /\breal traction\b/],
59 ['verdict', /\breal validation\b/],
60 ['verdict', /\bis brutal\b/],
61 ['verdict', /\bsmart (way|move)\b/],
62 ['verdict', /\bthe right (split|call|move)\b/],
63 ['verdict', /\bsharp framing\b/],
64 ['verdict', /\bno.?brainer\b/],
65 ['verdict', /\bis huge\b/],
66 ['advice', /\bnow figure out\b/],
67 ['advice', /\bthe question is whether\b/],
68 ['advice', /\bnow the question\b/],
69 ['advice', /\bdouble down\b/],
70 ['word', /\bleverage[ds]?\b/],
71 ['word', /\bcompound(s|ing|ed)?\b/],
72 ['word', /\bmoats?\b/],
73 ['word', /\btraction\b/],
74 ['word', /\bintentional(ly)?\b/],
75 ['word', /\bcraft\b/],
76 ['word', /\bdifferentiator\b/],
77 ['word', /\bfundamentally\b/],
78 ['word', /\bultimate\b/],
79 ['word', /\bquietly\b/],
80 ['word', /\blandscape\b/],
81 ['word', /\bdelve\b/],
82 ['word', /\btapestry\b/],
83 ['word', /\btestament\b/],
84 ['word', /\bpivotal\b/],
85 ['word', /\bcrucial\b/],
86 ['word', /\bseamless(ly)?\b/],
87 ['word', /\brobust\b/],
88 ['word', /\belevate\b/],
89 ['word', /\bempower\b/],
90 ['word', /\bdistribution engine\b/],
91 ['word', /\bsynergy\b/],
92 ['word', /\bat scale\b/],
93 ['word', /\bnext level\b/],
94 ['word', /\blevel up\b/],
95 ['word', /\bROI\b/],
96 ['word', /\bthe (real |biggest )?unlock\b/],
97]
98
99const LOL = /\blol\b/gi
100
101function strip(text: string, shell: boolean): string {
102 const base = text
103 .replace(/```[\s\S]*?(```|$)/g, ' ')
104 .replace(/`[^`\n]*`/g, ' ')
105 .replace(/https?:\/\/\S+/gi, ' ')
106 .replace(/\bwww\.\S+/gi, ' ')
107 .replace(/@\w+/g, ' ')
108 return base
109 .split(/(\s+)/)
110 .map((tok) => {
111 if (/^\s*$/.test(tok)) return tok
112 if (/[\/\\~]/.test(tok) || /\w+\.\w{2,}/.test(tok)) return ' '
113 // In a shell command, bare flags, file names, owner:branch and --key=value ("-p", "social-media-posts") are not prose.
114 if (shell && /^[\w.:=-]+$/.test(tok) && tok.includes('-')) return ' '
115 return tok
116 })
117 .join('')
118}
119
120export function lint(text: string, opts: LintOptions = {}): Hit[] {
121 const clean = strip(text, opts.shell === true)
122 const hits: Hit[] = []
123 const seen = new Set<string>()
124 const add = (rule: string, raw: string) => {
125 const match = raw.trim().slice(0, 60)
126 const key = `${rule}\u0000${match.toLowerCase()}`
127 if (seen.has(key)) return
128 seen.add(key)
129 hits.push({ rule, match })
130 }
131 for (const [rule, re] of RULES) {
132 const flags = re.flags.includes('i') ? re.flags : `${re.flags}i`
133 const m = new RegExp(re.source, flags).exec(clean)
134 if (m) add(rule, m[0])
135 }
136 const lols = clean.match(LOL)
137 if (lols && lols.length > 1) add('lol', `lol x${lols.length} in one text`)
138 return hits
139}
140
141export function hasLol(text: string): boolean {
142 return /\blol\b/i.test(text)
143}
144