Skip to content
Download .md

This is an Advanced page, so it's hidden from your sidebar. You don't need it to build your app, but you're welcome to read it.

Hooks: Rules Claude Can't Forget ​

TLDR ​

CLAUDE.md and memory are advice. Claude reads them, then decides. A hook is a guard. It runs a command of yours at a set moment, such as just before Claude runs a terminal command. It can stop the action before it happens. Claude doesn't get a vote.

Make one when you've corrected the same thing three times. The three I'd make first:

  1. Block the commands that have bitten you. In my repos that's git stash, git checkout -- and a force push.
  2. Keep Claude out of generated files. Build output, lock files, anything a tool writes.
  3. Run the check after every edit. The type checker or linter, so Claude sees the errors while the change is fresh.

A fourth, for long projects: a hook that reads your handoff file into every new session, so /clear never starts from nothing.

None of this is needed to build a NodeGX app. NodeGX already checks every change Claude makes before it is saved. Hooks are for the rules of your own that Claude keeps forgetting.


What a hook is ​

A hook has three parts. The event is when it fires. The matcher is which tool it applies to. The command is what runs. The command gets the details of what Claude is about to do as JSON on its standard input. It answers with an exit code. Zero means carry on. Two means stop, and whatever the command printed to standard error goes back to Claude as the reason.

The events you'll use:

EventFiresCan block?
PreToolUseJust before Claude uses a tool (Bash, Edit, Write, and so on)Yes
PostToolUseJust after a tool has runNo, but it can hand Claude an error
SessionStartWhen a session starts, resumes, or clearsNo, and its output becomes context
PreCompactBefore a long conversation is squashed into a summaryYes
StopWhen Claude finishes a turnYes

There are about thirty events in all. These five cover everything on this page.

Hooks live in the same settings files as everything else:

FileApplies to
~/.claude/settings.jsonYou, in every project
.claude/settings.jsonEveryone on this project (it's committed)
.claude/settings.local.jsonYou, on this project only (not committed)

The /hooks command shows every hook that's active and where it came from. Plugins can ship hooks too, which is the main reason to read a plugin before you install it. See Staying safe.


Recipe 1: block the commands that bite ​

Every project has a short list of commands that have cost you an afternoon. Mine, on a shared checkout, are git stash, git checkout -- file and git add -A. The first two threw away a colleague's uncommitted work. The third swept in files that weren't mine. I had each one written in memory. Claude still ran them, because a memory is something it can forget.

Make a folder .claude/hooks/ in the project and put this in guard-git.js:

js
// Blocks git commands that have bitten this project. Exit 2 = stop, with the reason on stderr.
let s = '';
process.stdin.on('data', d => (s += d));
process.stdin.on('end', () => {
  const cmd = JSON.parse(s).tool_input?.command || '';
  const rules = [
    [/\bgit\s+stash\b/, 'git stash is banned here: it takes other people\'s uncommitted work. Commit by pathspec instead.'],
    [/\bgit\s+checkout\s+--/, 'git checkout -- discards edits that may not be yours. Restore one file by name, with git restore, after checking who edited it.'],
    [/\bgit\s+add\s+-A\b/, 'git add -A sweeps in files you did not touch. Add files by name.'],
    [/\bgit\s+push\b.*--force\b/, 'No force pushes on this repo.'],
  ];
  for (const [re, why] of rules) {
    if (re.test(cmd)) { process.stderr.write(why); process.exit(2); }
  }
});

Then register it in .claude/settings.json:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.js\"" }
        ]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIR is the folder the session started in, so the path works for everyone who clones the repo. Test it by asking Claude to run git stash. It should report that the hook stopped it, with your reason.

Write the reason as the instruction you'd give a person. Claude reads it and does the right thing next instead. A bare "blocked" sends it round in circles.


Recipe 2: keep Claude out of generated files ​

A site I work on builds into dist/. Claude edited a file in there twice. The build overwrote it each time. The "fix" vanished. This hook stops any edit under dist/ before it happens. It's the same shape as recipe 1 with a different matcher, because Edit and Write put the path at tool_input.file_path.

js
// .claude/hooks/no-generated.js
let s = '';
process.stdin.on('data', d => (s += d));
process.stdin.on('end', () => {
  const p = JSON.parse(s).tool_input?.file_path || '';
  if (/\/(dist|build|node_modules)\//.test(p) || /package-lock\.json$/.test(p)) {
    process.stderr.write(`${p} is generated. Edit the source it comes from, then rebuild.`);
    process.exit(2);
  }
});
json
"PreToolUse": [
  { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/no-generated.js\"" } ] }
]

The matcher Edit|Write is a list, not a regex. A matcher with only letters, digits and | is read as tool names; anything else is read as a regular expression.

The same recipe protects your checks. Add jest.config, tsconfig, .eslintrc and .github/workflows to the pattern, and Claude can no longer make a failing test disappear by loosening the test. That's a thing it will do when it's stuck. With the hook in place it has to fix the code, or ask you to change the config yourself.


Recipe 3: run the check after every edit ​

After an edit, run the type checker and feed any errors straight back. PostToolUse can't undo the edit. What it does is show Claude the errors while it still remembers what it changed, rather than at the end of the task when the cause is three steps back.

json
"PostToolUse": [
  {
    "matcher": "Edit|Write",
    "hooks": [
      { "type": "command", "command": "npx tsc --noEmit --pretty false 2>&1 | head -20 >&2; exit 0", "timeout": 60 }
    ]
  }
]

Anything written to standard error reaches Claude. Keep the output short or it fills the conversation. That is what the head -20 is for. If your project's check takes more than a few seconds, put it on Stop instead, so it runs once per turn.

Hooks run with your permissions, every time

A hook is just a command on your computer. A slow one slows every edit. A wrong one fires on every edit. Keep them few, keep them fast, and keep each one's reason to one sentence. If Claude keeps hitting a hook on something that should be allowed, fix the pattern. Don't delete the hook.


Recipe 4: a session that starts where the last one stopped ​

If you use a handoff file (see One job per conversation), this hook reads it into every new session, including after /clear. Whatever a SessionStart hook prints becomes context Claude starts with.

json
"SessionStart": [
  {
    "matcher": "startup|clear|compact",
    "hooks": [
      { "type": "command", "command": "cat \"$CLAUDE_PROJECT_DIR/NEXT-SESSION-PROMPT.md\" 2>/dev/null || true" }
    ]
  }
]

The matcher names when it fires. compact is the one people forget. After a long conversation gets squashed, the handoff comes back in whole. Nothing in it is lost to the summary.


What not to hook ​

A hook matches a pattern. It can't tell whether this git stash is the one bad case or the one good one. So hooks are for rules with no exceptions. Anything that needs judgement stays in CLAUDE.md or memory, where Claude can weigh it. Claude Code in depth has the test for which is which.


Quick reference ​

  • A hook is a guard: a command that runs at an event and can stop the action with exit code 2.
  • Reason on standard error. Write it as the instruction you'd give a person.
  • PreToolUse on Bash reads tool_input.command. On Edit|Write it reads tool_input.file_path.
  • Scripts in .claude/hooks/, registered in .claude/settings.json, path via $CLAUDE_PROJECT_DIR.
  • /hooks lists what's active. Test a hook by asking Claude to do the thing it blocks.
  • Few, fast, no exceptions. Judgement calls stay in CLAUDE.md.

Written by Richard Osborne, main developer of NodeGX · nodegx.io