Claude Code hooks are shell commands, HTTP calls, MCP tools, prompts or subagents that Claude Code runs automatically at fixed points in a session: before a tool call, after a file edit, when Claude finishes its reply. They run every time instead of when the model remembers to, which the guide calls deterministic control, and that makes them the place for rules an agent must not skip (hooks guide). Below is how I would wire one around the content linter this blog already has, with every detail checked against the hooks reference.
Why a rule in a prompt is not enough
Claude Code's docs say Claude treats CLAUDE.md "as context, not enforced configuration", and that an instruction which must run after each file edit belongs in a hook (memory docs). Lauren Tan, an engineer at Cursor, makes the same point in a recorded talk: she layers rules, skills and a review bot but does not rely on them alone, because they are soft and agents can still forget them. She treats every rule a person has to enforce by commenting on a pull request as a code smell: it should become a lint rule or a CI failure instead.
That is what happened on this blog. Rules that used to live only in prompts now live in scripts/content_lint.py, which fails the run. One run here used 32 agents; a rule that depends on each of them remembering to run a script is 32 chances to skip it. A hook removes the remembering.
The events that can enforce a rule
| Event | When it fires | What exit code 2 does |
|---|---|---|
PreToolUse | Before a tool call executes | Blocks the tool call |
PostToolUse | After a tool call succeeds | Shows stderr to Claude; the tool already ran |
Stop | When Claude finishes responding | Prevents Claude from stopping, continues the conversation |
SubagentStop | When a subagent finishes | Prevents the subagent from stopping |
UserPromptSubmit | When a prompt is submitted, before Claude processes it | Blocks the prompt |
Wording is quoted from the reference's event table and its exit code 2 table. PreToolUse stops an action before it happens. A linter belongs on PostToolUse and Stop: the file exists, the script reads it, and Claude gets the errors back.
Set it up
Check 1: Does your rule exit non-zero when it fails?
python3 scripts/content_lint.py content/blog/some-post.mdx
echo $?0 errors and exit code 0 on a clean one. Mine fails on em dashes, links to removed or missing pages, over-long titles and descriptions, unknown categories, repeated FAQ questions and banned phrasing. A baseline file holds known errors in frozen pages as warnings until their dated fix.Check 2: Is the hook in the right settings file?
.claude/settings.json, which can be committed so every session in the repo gets them. .claude/settings.local.json keeps a hook to one project on your machine, and ~/.claude/settings.json applies it to all your projects (hook locations).Check 3: Does the JSON have event, matcher and handler?
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-mdx.sh",
"args": []
}
]
}
]
}
}Edit|Write matches exactly those two tools.edit matches nothing. Keep "args": []: it switches the hook to exec form, which the reference recommends for any hook that references a path placeholder.Edit.* also matches NotebookEdit.Check 4: Does the script read the path and exit 2?
chmod +x .claude/hooks/lint-mdx.sh:#!/bin/bash
# .claude/hooks/lint-mdx.sh: lint every MDX file Claude writes or edits
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.mdx) ;;
*) exit 0 ;;
esac
if ! OUT=$(python3 "$CLAUDE_PROJECT_DIR/scripts/content_lint.py" "$FILE"); then
echo "$OUT" >&2
exit 2
fi
exit 0tool_input.file_path is always absolute. Other files exit 0 untouched; a failing MDX file sends the linter's output to stderr and exits 2.PostToolUse, exit 2 is what shows your stderr to Claude.PostToolUse cannot undo the edit; the file is already written. Claude gets the errors with the tool result and can fix the file in its next step.Check 5: Does the script work outside Claude Code?
export CLAUDE_PROJECT_DIR="$PWD"
echo '{"tool_name":"Edit","tool_input":{"file_path":"/full/path/to/bad.mdx"}}' | .claude/hooks/lint-mdx.sh
echo $?2. A clean file prints nothing, then 0. A test file with one em dash prints ERROR em dash and exits 2.Check 6: Does Claude Code list the hook?
/hooks in a session started in the project, then ask Claude to put an em dash into a draft.PostToolUse, labeled with where it comes from, such as project settings. The edit lands and the linter's error comes back to Claude.Check 7: Can Claude finish with a failing file?
PostToolUse hook matching Edit|Write does not fire when a Bash command rewrites a file (PostToolUse), so this one lints every changed MDX file before Claude can stop:#!/bin/bash
# .claude/hooks/lint-before-stop.sh: no "done" while a changed MDX file fails
INPUT=$(cat)
[ "$(jq -r '.stop_hook_active' <<<"$INPUT")" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
FILES=$(git diff --name-only --diff-filter=d HEAD -- '*.mdx'
git ls-files --others --exclude-standard -- '*.mdx')
[ -z "$FILES" ] && exit 0
if ! OUT=$(python3 scripts/content_lint.py $FILES); then
echo "$OUT" >&2
exit 2
fi
exit 0Register it next to PostToolUse. Stop takes no matcher:
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-before-stop.sh",
"args": []
}
]
}
]stop_hook_active line lets Claude stop on its second try, which the guide recommends so an unfixable rule does not loop. Without it, Claude Code still ends the turn after eight blocks in a row.Stop fires for the main agent only. A subagent finishing fires SubagentStop, and its Edit and Write calls already hit the hook from check 3.What I keep out of hooks
Hooks guard the files, not the release. I approve every commit and push myself, and no agent pushes on its own. If you want that enforced rather than agreed, the reference says the if filter is best-effort and points to the permission system for a hard allow or deny: a deny rule Bash(git push *) refuses commands that begin with git push, though a push written another way, such as git -C . push, isn't matched.
Where this fits
The linter holds the rules and the hook makes sure it runs. Neither replaces the fact-checker agent that rereads the vendor's help pages, because no script knows whether a claim about Google Ads is true. How the hard checks and the softer layers fit together is in the guardrails I put around agents that write for this site.
Tags
Frequently asked questions
Where do I put Claude Code hooks?
In a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for one project and shareable through the repo, or .claude/settings.local.json for one project on your machine only. Plugins, skills and subagents can carry hooks too. Entries from different levels add up instead of replacing each other, and /hooks lists what loaded and where it came from.
Why does my Claude Code hook not block anything?
Check the exit code first. For most events only exit code 2 blocks through the code alone; exit 1 is a non-blocking error and the action proceeds. Other causes: a matcher that does not match the tool name exactly, since matchers are case-sensitive, a script that is not executable, or a mistyped path, which leaves the gate silently disabled.
Do Claude Code hooks run inside subagents?
Hooks from settings files, managed settings and plugins do. When a subagent calls a tool, PreToolUse and PostToolUse fire the same hooks as in the main conversation, and the input carries agent_id and agent_type so a script can tell them apart. A subagent finishing fires SubagentStop, not Stop.
Can a PostToolUse hook undo an edit?
No. PostToolUse runs after the tool succeeded, so the file is already written. Exit 2 shows your stderr to Claude, which can then fix the file. To stop a write before it happens, use PreToolUse, which can deny the call.