Blog1 October 20268 min readby Madhur

Claude Code Stop hooks: making the agent finish its homework

How a Stop hook works, one you can copy, the loop trap everyone falls into once, and the bug in my own hook that kept asking for homework on a brief I had only pasted in to talk about.

A white card on a whiteboard headed “Not done yet”: save what you did, what you decided and what’s still open, then stop, once. Beside it a terminal shows the Stop hook’s block decision and “stop_hook_active? let it go.”, and a marker arrow says “only this turn!”.

Every coding agent I use has the same habit. It does the work, writes a lovely summary, and stops. The summary lives in the chat. The chat scrolls away. The NOTES.md I asked it to keep up to date hasn't been touched since Tuesday.

You can ask nicely in CLAUDE.md, and it will mostly listen. "Mostly" is doing a lot of work in that sentence.

Claude Code has a firmer tool for this: a Stop hook, a script that runs when the agent is about to finish and is allowed to say "not yet". I shipped one in Deiko this week, then shipped a fix for it the same day. The fix taught me the one rule I'd now give anyone writing a Stop hook, so that's where this post is heading. Basics first.

What a Stop hook is, and when it fires

A Stop hook runs when the main agent has finished responding. It doesn't run when you interrupt with Esc. An API error fires a separate StopFailure event, and subagents get their own SubagentStop.

Hooks live in settings.json: ~/.claude/settings.json for all your projects, .claude/settings.json for one repo. There are three levels: the event, a group, and the handlers inside the group. Stop has no matcher (if you add one, it's silently ignored), so the group is just a wrapper:

{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "node",
                    "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notes-guard.mjs"] }] }
    ]
  }
}

Setting args runs the command directly, without a shell, which is what the hooks reference recommends whenever you use a path placeholder like ${CLAUDE_PROJECT_DIR}.

The script gets a JSON object on stdin. For Stop, the parts you'll use look like this:

{
  "session_id": "abc123",
  "transcript_path": "~/.claude/projects/.../00893aaf-....jsonl",
  "cwd": "/Users/you/project",
  "hook_event_name": "Stop",
  "stop_hook_active": false,
  "last_assistant_message": "I've finished the refactor. Here's what changed..."
}

There are a few more fields (permission_mode, background_tasks, session_crons), but these are the ones that matter here.

How "block" keeps the agent going

Print nothing and exit 0, and the agent stops as normal. To keep it working, exit 0 and print this on stdout:

{ "decision": "block", "reason": "Run the tests before you finish." }

reason is required when you block, and it's what Claude reads as its next instruction. You can also block by exiting 2, in which case your stderr becomes the reason. Pick one style per hook.

The trap here is exit 1. Every Unix instinct says 1 means "no", but for a Stop hook it's a non-blocking error: you get a "hook error" notice and the agent stops anyway. Same if your script path is mistyped. A guard with a typo in settings.json doesn't guard anything, so watch the first run.

Also: stdout has to be only the JSON. A shell profile that prints a greeting can land in front of it, and then your block is quietly ignored.

A Stop hook example you can copy

Here's one I'd actually use: don't finish a turn without touching NOTES.md. The interesting part is "a turn". The hook needs to know when this turn started, and Claude Code gives you a documented way to find out: a UserPromptSubmit hook fires the moment you send a prompt. So one script handles both events. On a prompt, it writes down the time. On Stop, it checks whether NOTES.md changed since then.

#!/usr/bin/env node
// .claude/hooks/notes-guard.mjs: runs on UserPromptSubmit and Stop.
import { readFileSync, writeFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

try {
  const input = JSON.parse(readFileSync(0, "utf8"));
  const mark = join(tmpdir(), `notes-guard-${input.session_id}`);

  if (input.hook_event_name === "UserPromptSubmit") {
    writeFileSync(mark, String(Date.now())); // when this turn began
  } else if (input.hook_event_name === "Stop" && !input.stop_hook_active) {
    const began = Number(readFileSync(mark, "utf8"));
    const notes = join(process.env.CLAUDE_PROJECT_DIR ?? input.cwd, "NOTES.md");
    let edited = 0;
    try { edited = statSync(notes).mtimeMs; } catch {}
    if (edited < began) {
      console.log(JSON.stringify({
        decision: "block",
        reason: "Before you stop: add a line to NOTES.md saying what you changed this turn and why.",
      }));
    }
  }
} catch {} // anything odd: let the agent stop

Register the same command under both events:

{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notes-guard.mjs"] }] }],
    "Stop": [{ "hooks": [{ "type": "command", "command": "node",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notes-guard.mjs"] }] }]
  }
}

It's strict: every turn has to end with a note, including "what does this function do?" turns. That's fine for a long autonomous session and annoying for a chatty one. Swap the condition for whatever "done" means to you: a test run's exit code, a changelog line, a file under docs/. Keep the shape.

If you don't need a script at all, Claude Code also supports prompt and agent hooks on Stop, where a model decides whether the work is finished, and a /goal command that does the same for one session. For a fuzzy condition like "are all the tasks done?", that's the better pick. For anything you can check with a file timestamp or an exit code, a script is cheaper and gives the same answer every time.

The infinite-loop trap

Write a Stop hook that blocks whenever a condition isn't met, and sooner or later the condition can't be met. The tests need a database that isn't running. The agent has no write access to NOTES.md. Your hook blocks, the agent tries, fails, stops, your hook blocks again.

That's what stop_hook_active is for. It's true when Claude is already continuing because a Stop hook blocked. Check it first, and let the agent go: you asked once, it tried, move on. Claude Code also has a backstop: after eight blocks in a row it overrides the hook and ends the turn (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP changes the number). Eight rounds of an agent being nagged is still eight rounds of tokens, though. Check the flag.

The example above checks it in the if, and everything else lives inside a try that falls through to "let it stop". A missing timestamp file, a broken stdin, an unreadable path: all of them end the turn normally. A guard that fails closed turns one bad file into an agent that can't finish anything.

A real one: Deiko's report-back hook

Quick context if you're new here (or start with giving Claude Code a memory between sessions): Deiko is a Mac app where you point at things on screen and talk, and it turns that into a brief for your coding agent. Every brief is filed into a task, and each task keeps a note: where it stands, what was decided, what agents reported back. That last part only works if the agent actually reports back, through a save_outcome tool on Deiko's local memory server or by writing an outcome.md file the brief names. The post on building memory has the long version.

Agents forget. So in 0.5.8, connecting memory in Settings also adds a Stop hook to Claude Code. The whole thing is about 70 lines of Node with no dependencies, and it makes four decisions:

  1. Only act on a Deiko brief. Every brief contains one fixed sentence asking for a report, which names the brief's id and the path of its outcome.md. The hook looks for that sentence with a regular expression. No sentence, no opinion.
  2. Check the file, not the conversation. save_outcome writes the same outcome.md the brief points at, so one check covers both routes: was that file modified after the brief arrived? The arrival time comes from the brief's timestamp in the transcript; the other side is the file's mtime.
  3. Block once. If there's no report, it blocks with a reason naming the brief and both ways to save. If stop_hook_active is true, it does nothing.
  4. Never stand in the agent's way. Bad JSON, a missing transcript, an unreadable file: the hook prints nothing and exits 0. That comment is in the code, word for word.

The decision is one function that returns either null or the block object, with file access passed in so tests can fake it:

export function decide(input, { read = (p) => readFileSync(p, "utf8"), mtime = (p) => statSync(p).mtimeMs } = {}) {
  if (!input || input.stop_hook_active || !input.transcript_path) return null;
  let transcript;
  try { transcript = read(input.transcript_path); } catch { return null; }
  const brief = latestBrief(transcript);
  if (!brief) return null;
  let saved = 0;
  try { saved = mtime(brief.outcome); } catch { /* not written yet */ }
  if (saved >= brief.at) return null;
  return { decision: "block", reason: `Before you finish: save what you did for Deiko brief ${brief.id} ...` };
}

Installing it is the boring part, and I wanted it boring. The Mac app reads ~/.claude/settings.json and refuses to touch it if it won't parse. It finds its own entry by the script's file name, so a moved app replaces its old entry instead of adding a second, and your other hooks stay exactly where they were. It keeps one backup, writes through a temp file and a rename, then reads the file back to confirm. Disconnecting removes only Deiko's entry. That code is here if you're writing an installer of your own.

The bug: homework that was never set

Version one looked for the latest brief anywhere in the transcript.

That sounds right until you have a long working session. I'd pasted a brief into Claude Code early on, not to work on it but to talk about it. We talked, then moved on to other things. Lots of other things.

At the end of every single turn after that, the hook asked for a report on that brief.

Each turn it blocked only once, so nothing looped. But stop_hook_active resets with every new prompt, and the brief was still sitting in the transcript with no outcome written after it. So: block, every turn, forever. The best case is an agent wasting a turn explaining it wasn't working on that. The worst case is an agent that obligingly invents a report for work it never did and files it into that task's memory.

The fix shipped in 0.5.10, on 30 September. Here's the heart of the diff:

// 0.5.8: remember the last brief seen anywhere
const m = MARKER.exec(text(entry.message?.content));
if (m) found = { id: m[1], outcome: m[2], at: Date.parse(entry.timestamp) || 0 };

// 0.5.10: every real prompt resets it, so only the latest one counts
if (entry.type !== "user" || entry.isMeta) continue;
const body = text(entry.message?.content);
if (!body || body.startsWith("Stop hook feedback")) continue;
const m = MARKER.exec(body);
latest = m ? { id: m[1], outcome: m[2], at: Date.parse(entry.timestamp) || 0 } : null;

The new rule: the hook only cares about the latest thing you typed. If that's a brief, check for a report. If it's anything else, the brief isn't this turn's work and the hook stays out of it.

The skips matter as much as the rule. In Claude Code's transcript, a lot of entries are marked as coming from the user that you never typed: tool results, meta entries like system reminders, and the hook's own feedback. Once "latest wins", every one of those would wipe out the brief. A tool result arriving mid-turn would make the hook think you'd moved on. So tool results drop out (only text content counts), meta entries drop out, and anything starting with "Stop hook feedback" drops out.

Scope it to the current turn

Here's the general version, for any Stop hook: a Stop hook should judge this turn, not the whole session.

"Were the tests run?" means since the last prompt, not ever. "Was NOTES.md updated?" means since this turn started, not since Tuesday. Whatever condition you check, anchor it to the moment the current prompt arrived, and make sure the anchor is a prompt a person actually sent.

There are two ways to find that moment. The documented one is a UserPromptSubmit hook that records it, like the example above. It also receives the prompt text, so you can decide right there whether this turn is one you care about. Deiko's hook reads the transcript instead, which keeps it to a single entry in settings.json. The cost is depending on the transcript's format, which isn't a published API, and the docs warn the file can lag behind the conversation. The prompt that started the turn is written early, so it's there by the time Stop fires. I'd still parse it defensively, which is why every bad line is skipped rather than fatal.

How to test a Stop hook

Keep the decision in a pure function and the stdin plumbing in a few lines at the bottom. Then you can test the function with a fake transcript, built in the test as a few JSON lines:

test("a brief pasted earlier and then talked about does not nag later turns", () => {
  const t = (...entries) => entries.map((e) => JSON.stringify(e)).join("\n");
  assert.equal(latestBrief(t(brief1, later)), null);
  assert.equal(latestBrief(t(brief1, toolResult, meta, feedback)).id, "20260929-100000");
});

Deiko's six tests use node:test and nothing else. They cover blocking once and never twice, a fresh report against a stale one, a chat with no brief, garbage on stdin, and the bug above. Write the bug's test before the fix. Watching it go red is oddly satisfying.

Then do one end-to-end check with the real CLI, because a perfect function behind a mistyped path is a disabled hook. In a scratch folder, save the settings from the example as hook-settings.json and run:

claude -p "Add a comment to the top of app.js" \
  --settings hook-settings.json \
  --permission-mode acceptEdits \
  --debug-file hook.log

-p runs one prompt without the interactive UI, --settings loads your hooks for just this run, acceptEdits lets the agent write files without asking, and the debug log records which hooks matched, their exit codes and their output. Pass if NOTES.md exists afterwards and hook.log shows your Stop hook ran. All four flags are in the CLI reference.

If you've written a Stop hook that went wrong in an interesting way, I'd like to hear it. I'm @Deiko_App on X.

Stop hook not working? The usual causes

Most broken Stop hooks fail in one of five ways. Start Claude Code with claude --debug and the log at ~/.claude/debug/<session-id>.txt shows what each hook printed and how it was read.

All of this is in the official hooks troubleshooting guide, which is worth a bookmark.

tl;dr Print {"decision":"block","reason":"..."} to keep the agent going. Check stop_hook_active so you ask once. Let every failure end the turn. And judge only the current turn: anchor your check to the latest prompt a person actually sent.

Questions people ask

How do I stop a Claude Code Stop hook from looping forever?

Read stop_hook_active from the JSON on stdin and let the agent stop when it is true. It is true when Claude is already continuing because a Stop hook blocked. Claude Code also overrides a Stop hook after eight blocks in a row, but you don't want to rely on that.

Should a Stop hook exit 2 or print JSON?

Either blocks the stop. Exit 2 uses your stderr as the reason. Exit 0 with {"decision": "block", "reason": "..."} on stdout gives you structured control. Pick one per hook. Exit 1 does not block: Claude Code treats it as a non-blocking error and the agent stops.

Does a Stop hook run when I press Esc?

No. Stop runs when the main agent finishes responding, not when you interrupt it. API errors fire a separate StopFailure event, and subagents fire SubagentStop.

Do I need a script, or can a prompt decide?

Claude Code also supports prompt and agent hooks on Stop, where a model decides whether the work is done, and a /goal command for one session. Use a command script when the check is deterministic, like a file's modification time or a test exit code.

Why is my Claude Code Stop hook not working?

Run /hooks to check it is registered under Stop, make sure the settings file is valid JSON in the right place, and start Claude Code with claude --debug to see what the hook printed. The most common silent cause is extra output, such as a shell-profile echo, before the JSON.

What does “Stop hook error: JSON validation failed” mean?

Your hook printed a JSON object that does not match the Stop hook schema, such as a misplaced field or a decision other than "block". The agent stops anyway. To block, print {"decision":"block","reason":"..."}; to let it stop, print nothing.