
Everyone who uses Claude Code has a .claude/settings.json file. Let me guess what's in yours. A PostToolUse hook that runs a formatter after every edit. Maybe a PreToolUse hook that stops Claude from running rm -rf on something you love. Possibly a desktop notification so you know when to stop scrolling and come back.
That's it, right? That's all of the hooks in the whole file.
You have a programmable, bidirectional feedback channel into an autonomous agent that writes code in your repository, and you are using it to insert semicolons. Just like the truck pictured above, you're not using the tools you have to their full potential.
I want to be clear that I'm not dunking on you specifically. Every hooks tutorial on the internet ends at "and now it runs your linter automatically!" like that's the summit. Skills got all the oxygen this year, and they earned it, they're genuinely the right abstraction for "here's how we do this thing here." But skills are instructions. Hooks are infrastructure. And nobody has really gone looking in the infrastructure.
So let's go looking.
Hooks Can Talk Back
Here's the thing that reframes the entire system, and it took me way too long to notice.
A hook is not a script that runs and dies. A hook can answer.
The Stop event fires when Claude finishes responding. If your hook returns decision: "block" with a reason, Claude doesn't stop. It keeps going, and your reason gets handed to it as the thing it now has to deal with.
The entire mechanism is about eleven lines:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/not-done-yet.sh" }
]
}
]
}
}
#!/bin/bash
input=$(cat)
# If we already blocked once, let the poor thing stop.
if [ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ]; then
exit 0
fi
if ! dotnet test --nologo -v q > /tmp/test-output.txt 2>&1; then
jq -n --arg out "$(tail -20 /tmp/test-output.txt)" '{
decision: "block",
reason: ("You are not done. The test suite is failing:\n" + $out)
}'
exit 0
fi
exit 0 # tests pass, nothing to say, go in peace
Sit with that for a second, because it's not a small feature dressed up as a big one. You have a loop. You control the exit condition. You write the next prompt. And you get to write it based on anything a script can compute: test results, git state, a coverage delta, an HTTP call to a server you built on a Saturday.
You are not writing automation. You are writing a prompt generator with a feedback channel, and the feedback can be as smart as you're willing to make it.
(Before you go build this: the hook input has a stop_hook_active field. Check it. Otherwise you will build a machine that argues with itself forever, and you will learn some interesting things about your token budget at 2 AM.)
Okay. Now the fun part.
Mutation Testing at Write Time
The standard agentic workflow: Claude writes the code, Claude writes the tests, the tests pass, everyone claps, PR merged, someone brings donuts.
The problem is that "the tests pass" and "the tests work" are completely different claims and only one of them got verified. Green checkmarks are a mood, not a measurement. A test suite that passes no matter what you do to the implementation isn't a test suite. It's a support group. It's there to make you feel supported.
So make Claude prove it. Put a PostToolUse hook on Edit|Write that takes the code that was just written and breaks it on purpose. Flip a < to a <=. Invert a boolean. Make a method return null. Then run the tests against the sabotaged version.
#!/bin/bash
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<<"$input")
cp "$file" "$file.original"
sed -i 's/ < / <= /g' "$file" # the sabotage. that's it. that's the sabotage.
if dotnet test --nologo -v q > /dev/null 2>&1; then
mv "$file.original" "$file"
echo "I flipped every < to <= in $file and the test suite still passed." >&2
echo "These tests do not test this file." >&2
exit 2 # PostToolUse can't block, but Claude reads stderr on exit 2
fi
mv "$file.original" "$file"
exit 0
Yes, sed on the whole file is a crude mutation operator. Real mutation testing tools (Stryker.NET, if you want the grown-up version) mutate one operator at a time and score you on how many mutants survived. Start crude. The crude version already catches the tests that were never going to catch anything.
If the tests still pass, the hook comes back with something Claude cannot argue with:
I changed
<to<=on line 47 ofRetryPolicy.csand your entire test suite still passed. These tests do not test this.
That's not a nudge. That's not "consider adding more test coverage," which is the kind of feedback that gets acknowledged and then completely ignored by humans and models alike. That's a cold hard fact. The tests claimed to verify a boundary condition, the boundary condition got moved, and nothing noticed. There's no room to negotiate with that.
And it lands in the same turn as the code that caused it. Not in code review. Not in a retro. Not three weeks later when the retry logic double-fires in prod and you're reading logs on a Sunday trying to reconstruct what happened.
The obvious objection is that mutation testing is slow, which is where an idea like this normally dies quietly. Except hooks have an async mode, and specifically asyncRewake: the hook runs in the background without blocking anything, and if it exits with code 2, it wakes Claude back up and hands it the failure. Your mutation suite gets to take its ninety seconds. Claude gets tapped on the shoulder when the bad news is ready. Nobody waits on anybody.
The Agent VCR
When an agent run goes sideways, the postmortem is genuinely miserable and everyone quietly agrees not to do one.
Because what is the postmortem? You scroll a transcript. Forty thousand tokens of tool calls, looking for the exact frame where things went wrong, like you're studying the Zapruder film but the film is JSON and there are twelve of them.
Meanwhile, every hook event is handed session_id, tool_use_id, cwd, the complete tool_input, and on PostToolUse, the result. Every frame of the tape is right there, structured, on stdin, and almost everybody throws it in the trash.
So record it. PreToolUse writes the input, PostToolUse writes the output, both keyed by tool_use_id. It is one line of config and one line of jq:
{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [
{ "type": "command", "command": "jq -c '{t: now, id: .tool_use_id, phase: \"in\", tool: .tool_name, input: .tool_input}' >> .claude/tape.jsonl", "async": true }
]}
],
"PostToolUse": [
{ "matcher": "*", "hooks": [
{ "type": "command", "command": "jq -c '{t: now, id: .tool_use_id, phase: \"out\", tool: .tool_name, result: .tool_response}' >> .claude/tape.jsonl", "async": true }
]}
]
}
}
Now you have a queryable record of the run instead of a wall of text you have to read like scripture. Which file did it open right before it decided to rewrite the config? What came back in that grep that sent it down the wrong path for eleven tool calls? At what point did it start going in circles?
Then the part that actually matters: replay it. Same inputs, different model. Same inputs, different CLAUDE.md. Same inputs, one more skill enabled. You can A/B a prompt change against a real, recorded failure instead of vibing your way to a fix and hoping it took.
We do this for every other system we run. We have logs, traces, metrics, replay tooling, and a whole industry of people who will sell you more of it. Then we hand an agent write access to the repository and observe it by scrolling. The agent is the only production system we run on pure vibes, and it's the one that writes the code.
Turn Your Team Into a MUD
This started as a bit and then I couldn't stop thinking about it, which is usually how the good ones go.

Hooks support an http type. Instead of running a shell script, the event gets POSTed to a URL you own. There is no client to write. The whole client is this:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "http", "url": "https://mud.internal/room", "timeout": 5 }
]}
],
"SessionStart": [
{ "hooks": [ { "type": "http", "url": "https://mud.internal/enter" } ] }
]
}
}
Point everyone at that server and suddenly every agent on the team is reporting into a shared room:
> Bob's agent has entered Program.cs
> Sally's agent has entered Program.cs
> Ted's agent picked up a template DLL (1.04 GB). It is very heavy.
> Bob's agent has been idle in RetryPolicy.ts for 11 minutes. It may be lost.
Is this funny? Obviously. That's most of why I want it.
But look at line two. Bob and Sally are both in Program.cs. That's a merge conflict that hasn't happened yet, and under the current arrangement the way you find out is when it happens, at the worst possible moment, to whoever pushes second. Here it's a line in a channel that costs nobody anything to notice.
The events are all there, too. SessionStart gives you the arrivals. Tool events give you movement. TeammateIdle is a real event that fires when an agent teammate is about to go idle, so "it may be lost" is not a joke, it's telemetry with a personality.
And the version I actually want most: the room event. Someone's agent touches the production config, and the server broadcasts it to the whole channel with an @everyone.
No approval workflow. No policy document that lives in a Confluence page nobody has opened since onboarding. No permissions matrix that someone has to maintain forever. Just eleven of your coworkers watching in real time. Social pressure is a shockingly effective access control layer, it deploys in an afternoon, and unlike your permissions matrix it does not require a meeting to update.
Haunt Your Codebase
Every repo that's been alive for more than two years has cursed lines in it.
The retry count that is 7. The Thread.Sleep(250) that has to be there. The cast that looks obviously removable and is not obviously removable. Nobody knows why. The person who knew why works at Datadog now. There is no comment, because the person who wrote it fully intended to add one.
So that knowledge lives in exactly one senior engineer's head, and gets painfully rediscovered every eight months when somebody new decides to clean it up. This is a tradition. We do it every year, like a fire drill nobody scheduled.
So haunt them. Keep a file of cursed line ranges:
[
{
"file": "src/Payments/RetryPolicy.cs",
"lines": [44, 52],
"ghost": "I don't know why the retry count is 7. When I changed it to 5, 20 tests failed. I put it back. Don't do this."
}
]
And a PreToolUse hook that checks whether the incoming edit touches one:
#!/bin/bash
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<<"$input")
ghost=$(jq -r --arg f "$file" '.[] | select($f | endswith(.file)) | .ghost' \
"$CLAUDE_PROJECT_DIR/.claude/haunted.json")
if [ -n "$ghost" ]; then
jq -n --arg g "$ghost" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: $g
}
}'
fi
exit 0
And the ghost speaks:
I don't know why the retry count is 7. When I changed it to 5, 20 tests failed. I put it back. Don't do this.
The hook can deny the edit outright, or return ask and bounce it to you, or just inject the warning as context and let Claude proceed with information it did not previously have. Your codebase, your poltergeist, your call on how aggressive it gets.
One thing worth knowing before you build it, because it's a genuinely interesting constraint: injected context has to read like a statement about the project, not like an order from the system. Text shaped like an out-of-band instruction can trip Claude's prompt-injection defenses, and instead of acting on it, Claude flags it to you as suspicious. Which is correct behavior. Which also means your ghost has to sound like a coworker who left a note rather than a system directive shouting from the void.
Fine by me. A ghost that sounds like a tired coworker is scarier anyway.
"But What About..."
"This is going to cost me a fortune in tokens." The mutation hook, maybe, if you point it at every file in a monorepo. Scope it. The if field takes permission-rule syntax, so Edit(src/**/*.cs) narrows it to the code you actually care about breaking. The VCR and the MUD cost approximately nothing, because they're writing to a log and hitting an endpoint.
"Slow hooks will make everything painful." They will, if you write them blocking. Almost nothing here needs to be. async for fire-and-forget, asyncRewake when you need Claude to hear about the result eventually. The blocking path is for decisions, not for work.
"My team will never adopt this." They'll adopt the MUD in about a day, because it's funny, and then they'll keep it because it turns out knowing where everyone's agent is genuinely helps. That's the whole adoption strategy. Trojan horse it with a bit.
"Isn't this what skills are for?" No, and this is the actual distinction. Skills tell the model what to do. Hooks decide what the model experiences: what it gets told, when it gets interrupted, what it's allowed to touch, what it finds out after the fact. A skill is advice. A hook is physics.
Stop Making Your Hooks Run Prettier
None of this is hard. That's the frustrating part.
The mutation hook is a string replace and a shell-out to dotnet test. The VCR is two log writes and whatever query tool you already like. The MUD is an endpoint and a webhook. The haunting is a JSON file and a line-range check. Not one of these is a weekend project. Several are a lunch break.
They're just not linting, and linting is where the entire conversation has parked itself.
So next time you're in your settings file adding one more formatter invocation, stop and ask a better question. Not "what task can I automate here." Ask: what should this thing find out, and when should it find out about it?
Then go build the ghost. Tell me how it goes.
Full event list and schemas: Claude Code hooks reference.