Claude Code Hooks · Part 3 of 5

The Hook That Won't Let the Agent Say "Done"

Abstract series cover: a stream of luminous tokens reaching a gate stamped with a glowing checkmark of proof, where work without evidence is held back and only verified work passes through - a Stop hook guarding the exit
TL;DR
  • An AI agent says "done" the way it says everything else: confident, fluent, and often without having checked. The word is cheap to produce and expensive to trust.
  • A line in the prompt asking it to verify is a request. A hook on Stop is enforcement. The hook runs in the harness and the model gets no vote.
  • The Stop hook can't read the model's intent, so it reads evidence instead: it checks the session transcript, or a small proof file the agent has to write, before it's allowed to end its turn.
  • stop_hook_active is the escape hatch. Without it, a gate that can never be satisfied loops forever. The hook stands down after forcing one continuation.
  • A second gate on PostToolUse grades the code the agent just wrote and feeds the verdict back. That one only injects an opinion; the edit already ran. Only the Stop hook guards the exit.

"Done." "It works." "Perfect, all tests pass." Those are the most expensive words an AI agent will ever say to you, because most of the time it hasn't checked. It pattern-matched its way to a confident sentence and stopped. The code might be fine. It might also be broken in a way that costs you an hour to find, precisely because you trusted the word "done" and moved on to the next thing.

This is part 3 of my series on Claude Code hooks. Part 1 was about context rot, the slow decay of a session that's been running too long. Part 2 was guardrails, catching one dangerous command before it runs. This one is about the gap between an agent saying it finished and the work actually being finished. The hook that closes that gap fires on Stop.

A prompt asks. A hook enforces.

I used to handle this with a line in the system prompt. Something like "always run the tests before you report success, and if you can't test, say so." It helps. It's also the first thing to evaporate when the context gets long or the task gets hard. A prompt is a request. The model is free to forget it, reinterpret it, or quietly decide this particular case is the exception. Under pressure it usually does exactly that.

A hook works at a different layer. It runs in the harness, not inside the model, so the model doesn't get a vote. When the agent tries to end its turn, my code runs first and decides whether that's allowed. That's the whole reason this lives in a hook instead of a hopeful paragraph of good intentions.

What the Stop hook sees

The Stop hook fires the moment the agent decides it's done talking. Claude Code hands it a small JSON blob on stdin:

{
  "hook_event_name": "Stop",
  "transcript_path": "/path/to/session.jsonl",
  "stop_hook_active": false
}

Two fields carry the weight. transcript_path points at the full session log, so the hook can look back at what actually happened in the turn. stop_hook_active tells me whether I've already forced a continuation once. I'll come back to that one, because it's what keeps this whole thing from turning into an infinite loop.

The decision is top-level. To keep the agent working, the hook prints a block:

{
  "decision": "block",
  "reason": "No test run found this session. Run the suite and report the result before you stop."
}

This is one of the spots where it's easy to invent a field that doesn't exist. The Stop decision sits at the top level of the JSON. Other hooks tuck their output inside a hookSpecificOutput object; this one does not. That reason string goes straight back to the agent as its next instruction. If you'd rather not assemble JSON, exit 2 with a single line on stderr does the same job. Either way the session doesn't end. The agent reads the reason and keeps going.

How the hook knows the work was verified

The interesting question is how a shell script decides whether anything was actually checked. It can't see the model's intent. So it goes looking for evidence instead. Two mechanisms, and you only need one:

I lean on the marker. Grepping a transcript for "did a test really pass here" gets fuzzy fast, and fuzzy is the enemy of a gate. A file that says "ran X, got Y" is a thing I can check in one line and trust.

The agent tries to stop. The Stop hook checks for proof of a test run. With no proof it blocks with decision block and the reason returns to the agent, which runs the suite and tries again. With proof present, the session ends. Agent says done "tests pass" Stop hook proof of a test? none found BLOCK · keep going reason returns to the agent ALLOW session ends agent runs the suite, writes proof
The exit gate: the agent only gets to stop once there's proof a test actually ran. Otherwise the Stop hook hands back a reason and the session keeps going.

The infinite loop problem

Here's the failure mode that bites everyone who builds one of these. The agent says done. The hook blocks: no proof. The agent has no way to produce proof, because there are no tests, or the task genuinely had nothing to run. It says done again. The hook blocks again. You've built a machine that can never stop, burning tokens in a circle.

That's what stop_hook_active is for. The first time my hook blocks, the harness flips that flag on the next Stop. When I see it's already true, I know I forced a continuation once and the agent still came back empty-handed. So the hook stands down and lets the session end, usually after logging a quiet note that the gate was bypassed this time. A gate that can never be satisfied protects nothing and just hangs the session. The escape hatch has to be in the design from the first line. You learn that the hard way once, when your terminal locks up grinding out the same refusal over and over.

Any hook that blocks an exit needs an answer to "what if the block is impossible to clear?" Without one, the first un-runnable task turns your quality gate into an infinite loop. stop_hook_active is the harness handing you that answer for free.

Grading the code it just wrote

The Stop hook guards the exit. A second gate works further upstream, on PostToolUse, firing right after the agent edits a file. This one can't block, because the edit already happened by the time the hook runs, but it can talk back. After a write, the hook runs the changed file through a handful of cheap, mechanical checks: how deeply nested the functions are, whether there are bare magic numbers with no name, whether the type hints quietly disappeared, whether a 200-line function is doing six jobs while pretending to do one. Each principle gets a rough score.

Then it does the one thing that makes this worth running. It feeds the verdict back to the agent as additionalContext, parked right next to the tool result:

print(json.dumps({
    "hookSpecificOutput": {
        "hookEventName": "PostToolUse",
        "additionalContext": "Quality check: nesting 4 deep in handle(); 2 unnamed constants. Tidy before moving on."
    }
}))

Note where additionalContext lives here: inside hookSpecificOutput. That's the opposite of the Stop hook's top-level decision, and mixing them up is the quickest way to write a hook that silently does nothing. The agent sees "nesting is four deep, the constant on line 12 has no name" while the change is still warm, and it tends to clean things up on the next pass without being asked. The same checks lean on old, boring ideas like keeping it simple, which a tired model forgets the second the task gets interesting.

Worth being precise about the limit. This gate has no veto. The file is already on disk by the time the hook runs. All it can do is hand back an opinion. But an opinion delivered at the right moment, before the agent has moved on, lands far better than a code review three days later when nobody remembers why the function looks like that.

Forcing the recon before the writing

The gates so far catch trouble after it exists. The cheapest bug is the one the agent never writes, and that means getting it to look before it leaps. A UserPromptSubmit hook fires when I send a message, before the model sees it, and can staple a short instruction onto the front of my prompt through additionalContext.

For anything that smells like an implementation task, it injects a tiny checklist: read the files you're about to change first, find the pattern that's already there and match it, state your plan in one line before you touch anything. It's the same advice I'd give a junior in their first week, except it arrives every single time instead of whenever I remember to say it. An agent that reads the surrounding code before writing produces code that looks like it belongs. One that skips that step writes a lonely island of a function in its own private style, and you pay for it at review.

Closing the loop on todos

The last gate is small and a little dumb and I'd never give it up. When the agent opens a todo list, three steps or five, it has a habit of doing the first two, declaring victory, and quietly abandoning the rest. So a hook watches for started-but-unclosed work and, on Stop, treats an open todo the same as a missing test. You said you'd do five things, four are done, you don't get to stop yet.

Half-finished is the worst state to leave a task in, because it wears the costume of finished. The code runs, the obvious path works, and the thing you actually asked for, the edge case buried in step five, is silently missing. A todo-enforcer turns "I'll get to it" into "you'll get to it now, before this session ends."

Where this stops working

None of this verifies that the code is correct. It verifies that the agent did the checking it claimed to do. A test suite that's all green and tests nothing will sail straight through. A marker file that says "ran the tests" when the tests are hollow gets believed all the same. These gates raise the floor. They make "done" mean "I at least ran something and it didn't blow up" instead of "I have a good feeling about this." They don't raise the ceiling. That part is still on me and on how good the tests are in the first place.

But the floor is where the cheap, embarrassing failures live. The ones where the agent confidently ships code that doesn't even import. Catching those automatically, on every single turn, is worth a small stack of hooks that each do one boring thing well.

Build your own gate

Don't copy my checklist. Your idea of "proof" won't be mine. Maybe it's a passing test, maybe a clean type-check, maybe a screenshot of the thing actually rendering. Copy the shape instead:

  1. Read the JSON on stdin and pull out transcript_path and stop_hook_active.
  2. Decide what counts as proof, then go looking for it. Scan the transcript for a check that passed, or require a fresh marker file the agent had to write before stopping.
  3. No proof? Block. Print a top-level {"decision":"block","reason":"..."}, or exit 2 with one line on stderr. Make the reason say exactly what to run, so the agent acts instead of just retrying the same dead end.
  4. Proof is there? exit 0 and let the turn end.
  5. Always check stop_hook_active first. If it's already true, stand down and let the session close. That single line is what separates a quality gate from an infinite loop.

Five lines of logic, one boring job. The same skeleton, pointed at whatever "done" is supposed to mean on your project.

Series: Claude Code hooks (5 parts)
  1. Context rot + handoff - how I stop sessions from rotting
  2. Guardrails - hooks that won't let the AI shoot me in the foot
  3. Quality gates - forcing the agent to verify before it says "done" (you're here)
  4. The prompt layer - a local LLM that grades my prompt before it hits Claude
  5. Ambient automation - TTS, status, sessions talking to each other

Links to the remaining parts will appear as they go live.

Takeaway

"Done" is a feeling the model has. It says nothing about whether the code actually works. A prompt asking it to verify is a request it's free to drop the instant the task gets hard. A hook that won't let the session end without proof isn't asking anything. That's the whole difference, and under pressure it's the only difference that holds.