A few weeks ago I was doing final review on a draft for our engineering blog — a post about a Postgres connection pooling issue one of our backend engineers had spent a weekend chasing down. The bug hunt itself was great: a real, gnarly, three-in-the-morning kind of story. But the writeup read like it had been laundered through a corporate press release. Every paragraph was two sentences. There were em-dashes stacked up like cordwood. And it kept doing that "it's not just a connection leak — it's a symptom of a deeper architectural tension" thing, over and over, about a bug that was, in fact, just a connection leak.

I asked the engineer about it and he admitted he'd run the draft through an LLM to "tighten it up." Fair enough — I do that too, for grammar and structure. But this wasn't tightened, it was replaced. And it happened right around the time Bryan Cantrill's post about LLM-generated LinkedIn writing was making the rounds, which gave the problem a name I couldn't unsee: your intellectual fly is open. Once you notice the tells, you can't stop noticing them, and it made me actually sit down and figure out what, specifically, was setting off my radar and whether I could catch it before it went live instead of after.

This post is about what I built to do that: a small pre-publish check that scores a draft's "AI-tell density" before it goes near our CMS, plus what I learned about where that kind of automated check is genuinely useful and where it's just theater.

The real cost of AI writing tells on a technical blog isn't embarrassment — it's that readers who spot them stop trusting the technical content underneath, and bounce before your actual expertise ever gets a chance to land.

Why this is an SEO problem and not just a taste problem

My first instinct was to treat this as a style nitpick — the kind of thing you argue about in a PR review and then move on from. But the more I looked at our analytics, the more it looked like an actual traffic problem. Posts with heavy AI-tell density had noticeably shorter average time-on-page and a higher percentage of sessions that bounced before scrolling past the second heading. That's not a coincidence; it's the exact mechanism Cantrill describes when he says he stops reading the moment a post reads as generated, and I think most technical readers do the same thing without consciously deciding to.

Google's helpful content systems and E-E-A-T guidance don't penalize "AI-assisted" writing as a category — they penalize content that reads as generic, unhelpful, or disconnected from a real author's demonstrated experience. The problem is that heavily-LLM-polished technical writing tends to drift toward exactly that: it smooths over the specific, idiosyncratic details that make a debugging story credible in the first place, replacing them with generic scaffolding.

E-E-A-T stands for Experience, Expertise, Authoritativeness, and Trustworthiness — it's the framework Google's quality raters use to judge whether content demonstrates real, first-hand knowledge rather than just repackaging what's already out there.

So dwell time and bounce rate become a proxy for something the ranking systems are also trying to measure indirectly: does this content sound like it came from someone who actually did the thing? If a reader can tell within three sentences that it didn't, they leave, and so, eventually, does the algorithm's confidence in the page.

The tells I actually see in our drafts, ranked by how often they burn us

Before writing any tooling I spent an afternoon reading through six months of rejected or heavily-edited drafts, and the same handful of patterns kept showing up. None of these are exotic — they're the same tells Cantrill calls out, plus a couple that seem specific to technical writing, where an LLM will happily generate confident-sounding architecture commentary about a bug it never actually saw.

  • Em-dash overload — not one or two per post, but three or four in a single paragraph, used as a substitute for actually restructuring the sentence.
  • The "not just X, but also Y" construction — almost always signals the model reaching for false depth on a point that didn't need it.
  • Uniform paragraph length — every paragraph is two to three sentences, no matter what it's explaining, which reads as metronomic rather than reasoned.
  • Unearned architectural framing — a routine bug getting described as "a deeper symptom of X" when nobody on the team actually made that connection.
  • Hedge-everything phrasing — "this could potentially help in certain situations," applied to a fix the engineer knows works because they shipped it.

None of these, on their own, proves a draft was AI-written. Plenty of careful human writers like em-dashes and short paragraphs. What made the difference in practice was density — a post with one of these per section reads fine, a post with all five stacked in every section reads like it was generated wholesale and then barely touched.

Building a pre-publish linter instead of arguing about it in review

Arguing about "this paragraph feels off" in a PR comment is slow and it puts the reviewer in the position of policing style rather than substance, which nobody enjoys. So instead of doing that by eye every time, I wrote a small script that runs against a draft's markdown source and spits out a density score across the tells above, plus flags the specific lines that tripped it. It's deliberately crude — this isn't a classifier, it's a set of heuristics tuned against our own drafts.

Here's the core of it, scanning a markdown file for em-dash density and the "not just... but" construction, both normalized per thousand words so a short post and a long post are scored comparably:

import re
from pathlib import Path

NOT_JUST_PATTERN = re.compile(
    r"\bnot (?:just|only)\b[^.]{0,80}\bbut\b", re.IGNORECASE
)

def score_draft(markdown_path: str) -> dict:
    text = Path(markdown_path).read_text(encoding="utf-8")
    body = re.sub(r"```.*?```", "", text, flags=re.DOTALL)  # skip code fences
    word_count = max(len(body.split()), 1)

    em_dash_count = body.count("\u2014")
    not_just_hits = NOT_JUST_PATTERN.findall(body)

    paragraphs = [p for p in body.split("\n\n") if p.strip()]
    sentence_counts = [len(re.findall(r"[.!?]+", p)) for p in paragraphs]
    uniform_short = sum(1 for c in sentence_counts if c in (2, 3))
    uniformity_ratio = uniform_short / max(len(sentence_counts), 1)

    return {
        "em_dash_per_1k_words": round(em_dash_count / word_count * 1000, 2),
        "not_just_but_hits": len(not_just_hits),
        "uniform_paragraph_ratio": round(uniformity_ratio, 2),
        "word_count": word_count,
    }

if __name__ == "__main__":
    import sys
    result = score_draft(sys.argv[1])
    print(result)

The numbers that came out of running this against our worst offender were striking: 14 em-dashes per thousand words (our best human-written posts average under two), four hits on the "not just" pattern in a 1,800-word post, and a uniform-paragraph ratio over 0.7. Those thresholds — roughly 5 em-dashes per 1,000 words and a uniformity ratio above 0.5 — became the flags I actually act on, everything below that I leave alone, because plenty of good writers sit closer to those numbers than you'd think.

Why the linter catches surface tells and misses the real problem

Running the script for a month taught me something I didn't expect: it's very easy to get a clean score by just asking the LLM to avoid em-dashes and vary sentence length, and the resulting post is still hollow. The linter measures style, and style is what Cantrill's post is actually about on the surface, but the deeper complaint underneath it is about authorship — whether the person is actually telling you something they know.

"You definitely don't sound like you, so… is the actual content real?"— Bryan Cantrill, on reading obviously LLM-generated LinkedIn posts

That question doesn't have a regex. A post can pass every heuristic in my script and still be a generic wrapper around a real engineer's real work, with the specific details — the exact stack trace, the wrong theory they chased for two hours before finding the right one, the actual config value that was off — smoothed away in favor of confident, forgettable prose. The linter is a smoke detector, not a fire marshal; it tells you something's worth a second look, it doesn't tell you what to do about it.

So the actual fix, in every case where the score flagged something real, ended up being the same: I went back to the engineer and asked them to rewrite the section in their own words, out loud if it helped, and then just clean up grammar afterward. Every single time, the rewritten version had more specific, more useful, more interesting detail in it than the polished one — because it came from someone who'd actually been there.

Wiring the check into the review process without turning it into a gate

I was tempted to make this a hard CI gate that blocks a merge above a certain score, and I'd actively avoid doing that if you're setting this up yourself. A hard gate turns a useful nudge into an adversarial game where writers learn to trick the script instead of writing better, and you end up optimizing for the metric instead of the thing the metric was standing in for.

What we do instead is run it as an informational check in the pull request, similar to a code coverage delta — visible, not blocking. Here's the pre-commit hook that runs it against any changed markdown file in our blog repo:

#!/usr/bin/env bash
set -euo pipefail

changed_posts=$(git diff --cached --name-only --diff-filter=ACM -- 'content/blog/*.md')

if [ -z "$changed_posts" ]; then
  exit 0
fi

for post in $changed_posts; do
  echo "Checking AI-tell density: $post"
  python3 tools/tell_score.py "$post"
done

echo "Note: high scores are a prompt to re-read the draft, not a blocker."

It just prints the numbers into the commit output and gets out of the way. That framing matters more than the code does — the moment this becomes a gate that blocks a merge, someone will write a second script to defeat the first one, and now we've spent engineering time building an adversarial arms race against our own tooling instead of just writing better posts.

A clean linter score is not proof of authenticity — an LLM told to "avoid em-dashes and vary sentence length" will happily produce hollow, generic prose that passes every heuristic while still saying nothing the author actually knows firsthand.

The scores are worth glancing at during review, but they're a prompt to ask "did you actually write this?", not a verdict on the post's quality — treat it as informational input for a human conversation, not an automated judgment.

What I actually tell my team now

The advice I give people now is almost embarrassingly simple, and it's basically the same advice Cantrill gave on LinkedIn: use the LLM as an editor, not as an author. Dictate the post, or write it badly and fast, then hand it to the model and ask specific, narrow questions — is this paragraph confusing, is this explanation missing a step, does this section repeat itself — instead of asking it to rewrite the whole thing.

The uncomfortable part of this whole exercise was realizing that the tells aren't really about punctuation at all. They're a symptom of writers not trusting their own voice enough to put it on the page, and reaching for a tool that produces something that sounds smart instead. My linter catches the symptom well enough to be useful as a nudge, but it can't fix the actual habit, and I don't think any script will. If you're building something similar for your own team, treat it as a conversation starter with the writer, not a quality gate — the moment it becomes the latter, you've traded one form of hollow writing for a slightly different one.

Sources: Bryan Cantrill — Your intellectual fly is open