← Back to the Build Log Building with AI

I stopped asking Claude Code not to use em dashes and wrote a hook

D
Daniel · May 3, 2026 · 6 min read
A flat illustration of a chat reply stopped at a glowing teal barrier before it reaches a phone, in navy and teal

I run Claude Code on a Linux box at home and it answers me on Telegram. Every reply I get is the model calling a reply tool, not text scrolling past in a terminal I'll never read. That one detail is the whole reason this story has an ending: a tool call is something you can stand in front of.

Somewhere in its memory I'd written the rules I actually care about. No em dashes. A short list of words I'm tired of reading. No "great question" before it answers me. Plain text, because Telegram turns markdown into a mess on a phone. All of it sitting right there in the file the model loads every session.

And it followed the rules. For a while. Then a few replies later an em dash would slip back in, then a stray "Let's dive in", and I'd be reminding it of a rule it already had written down. The rule never went anywhere. It was one line in a long context, and after a few tool calls and a summary or two, that line quietly lost to the model's training. Asking nicely works right up until the context gets long, and then it stops.

The rule was never gone, it just stopped winning

Here's the thing I keep relearning. Instructions in a prompt, notes in memory, all of it is a preference. It leans the model in a direction, it doesn't bind it. For anything where "usually" is good enough, that's all you need, and it works great. But "no em dashes" is not a usually. It's a yes or a no. And a yes-or-no rule should not depend on whether the model happens to be paying attention to that one line this turn.

So how do you turn a preference into a rule? You stop asking the model, and you check the output yourself, in code, at the last moment before it leaves the building.

A vote, right before the tool runs

Claude Code has hooks. A hook is just a command the harness runs at a fixed point in the loop. The one I wanted is the PreToolUse hook: it fires after the model has decided to call a tool, but before the tool actually runs. It hands your script the tool name and the arguments on standard input, and your script gets a vote on whether the call goes through.

So I pointed a PreToolUse hook at the Telegram reply tool. Two pieces. First the wiring, in settings.json:

"PreToolUse": [
  {
    "matcher": "mcp__plugin_telegram_telegram__reply",
    "hooks": [
      { "type": "command",
        "command": "~/.claude/hooks/telegram-reply-style-check.sh",
        "timeout": 5 }
    ]
  }
]

The matcher is the name of that one tool, so the script runs on Telegram replies and nothing else. The rest of what Claude Code does, it never sees.

Then the script. It reads the JSON off standard input, pulls out the reply text, and runs a few checks. Plain bash and grep, no model anywhere in it:

TEXT="$(jq -r '.tool_input.text // empty' <<<"$INPUT")"
issues=()

# the one it breaks most
[[ "$TEXT" == *"—"* ]] && issues+=("em dash present. Use a period or comma.")

# words I'm tired of seeing
banned='\b(crucial|robust|seamless|leverage|delve|underscore|...)\b'
hits="$(grep -oiE "$banned" <<<"$TEXT" | sort -u | tr '\n' ',')"
[[ -n "$hits" ]] && issues+=("banned vocab: $hits")

# (also: en dashes, bold/italic markdown, "great question" openers)

(( ${#issues[@]} == 0 )) && exit 0

printf 'Reply blocked. Fix and resend:\n' >&2
for i in "${issues[@]}"; do printf '  - %s\n' "$i" >&2; done
exit 2

The line that does the actual work is the last one. exit 2. In a PreToolUse hook that exact code is the contract with Claude Code: it blocks the tool call and feeds whatever the script printed to standard error back to the model as the reason. Not exit 1, which gets treated as a soft error that lets the call sail through anyway. I got that wrong the first time and spent a while puzzled about why my "blocked" message wasn't blocking a thing:) So: 2 blocks, 0 allows, and the text on standard error is the part the model reads and acts on.

What it looks like from the model's side

This is the part I like. The model writes a reply with an em dash in it. The send never happens. Instead it gets back "em dash present, use a period or comma," it rewrites the line, and the clean version goes out. I never see the first one. The model still does all the writing. The script just refuses to pass anything that breaks a rule I said wasn't up for debate, and it hands back the reason so the rewrite is aimed instead of a guess.

I could strip the em dash out in the script myself and be done with it. But the banned words don't have a clean one-to-one swap, and I'd rather the model pick the plainer word than have me guess it for it. So the script says no, and the model does the fixing.

And since it's regex over a string, nothing grades the model except a few patterns. No second model in the loop, no extra tokens, no latency you could measure. The cheapest possible check, sitting at the exact spot where it matters.

Does it work? The little log the hook keeps has the answer. Since early May it has stopped a reply 95 times. The runaway most common catch is the em dash, 62 of them, more than everything else put together. Sixty-two times the model tried to send me the one character I told it never to use, and sixty-two times a little shell script turned it into a comma before it reached my phone. The rule was always in memory. Memory just wasn't the thing that stopped it.

Two layers

The reason I trust this more than a better-worded memory note is that it moved the rule from a place where it gets summarized away to a place where it can't. Someone in a thread about this put it as the model being the orchestrator and real code being the verifier, and a post they linked, Don't let the LLM verify, make it build the verifier, argues it better than I will here. The model can write the check. The model should not be the only thing running it.

So I've ended up with two layers, and I think it's where most agent setups land once a long run has burned them at least once. Hard rules live at the tool boundary, in code, where they're deterministic and boring. Everything fuzzy stays in memory and prompt text, where it's quick to change and where "mostly right" is genuinely fine. Tone and taste, which way to lean when it's a real judgment call, that's memory's job and it does it well. Whether an em dash made it into a message is not a judgment call, so it doesn't get to live where judgment is involved.

The quick test for which layer a thing belongs in: can you write the check without a model? If yes, write it, and stop hoping. If no, a clear instruction and a little trust is the best tool you've got, and that's fine too.

This one is tiny. A few regexes in a shell script, pointed at one tool. But the shape fits anything binary at the edge of an agent: a payload that has to match a schema, a link that has to resolve, a file that must never be written outside one directory, a command that must never run at all. If it truly cannot ship broken, check it in code at the boundary, not in the prompt and not in a hope.

When a block does fire and I want to see what tripped it, I'll open TerminalNexus, SSH across to the box, and tail that log. The script and the rules behind it are still where the real work happens. The hook just makes sure the model can't talk its way past them on an off day.

Comments