Skip to main content
Claude Code tips · Lesson 4 of 6 · 12 min

Claude Code Hooks Examples: Guardrails That Always Run

Claude Code hooks examples from a real repo: events, matchers, the JSON on stdin, exit codes and JSON output, and when a hook should block or only advise.

By Matthews WongReviewed on Checked against Claude Code v2.1.296

Key takeaway

A Claude Code hook is a command, or another handler, that Claude Code runs automatically at a fixed point in its loop, such as before or after every tool call. It receives the event as JSON on stdin and answers with an exit code or JSON. Unlike CLAUDE.md, which Claude may or may not follow, a hook runs every time its event matches.

You will be able to

  • Pick the event and matcher that fire a hook exactly when you mean it to.
  • Read the JSON on stdin and answer with the right exit code or JSON output.
  • Decide whether a hook should block an action or only advise Claude.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

A hook firing around one tool call

Claude calls Edit; PostToolUse matches Write|Edit and pipes JSON to the review script, which answers with additionalContext and exit 0; a PreToolUse hook exiting 2 would block the call instead.

Reviewed on 13 October 2026 · Claude Code v2.1.296

Step 1 of 7. Hooks are commands Claude Code runs at fixed points, whatever Claude decides. Two events surround every tool call: PreToolUse before it runs, PostToolUse after it succeeds.

Keys: ← → step · Home / End · K play or pause

  1. Hooks are commands Claude Code runs at fixed points, whatever Claude decides. Two events surround every tool call: PreToolUse before it runs, PostToolUse after it succeeds.

  2. Claude calls Edit on a blog post. PreToolUse fires first, but this repo registers no hook there, so the call goes straight to the tool and the file changes.

  3. The edit succeeded, so PostToolUse fires. Its matcher, Write|Edit, is an exact list of tool names: Write and Edit run this hook, nothing else does.

    "matcher": "Write|Edit",
  4. Claude Code pipes the event to the script as JSON on stdin. The script reads tool_input.file_path and reviews only the post that was touched.

    file = payload.tool_input?.file_path ?? payload.tool_response?.filePath ?? "";Predict the next step The script finds a figure problem. What happens to the edit? (It is undone, and Claude retries · It stands; Claude gets the findings · Claude Code asks you first)
  5. It prints one JSON object with additionalContext and exits 0. Claude reads the findings beside the tool result. The edit stands: this hook advises, never blocks.

    hookSpecificOutput.additionalContext … --hook 2>/dev/null || true
  6. Exit 2 blocks only before a tool runs: a PreToolUse hook that exits 2 cancels the call, and Claude reads its stderr. After the tool, a hook can only report.

    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2 exit 2Predict the next step Claude rewrites the same file with a Bash command. Does the hook fire? (Yes, the file still changed · No, Bash is not Write or Edit)
  7. A Bash command that rewrites the same file never fires an Edit|Write hook. This repo merges posts with a script, so a separate audit stays the real gate.

    # via Bash: no Edit|Write hook fires node scripts/merge-blog-staging.mjs # the real gate node scripts/audit-blog-figures.mjs

Which events can a Claude Code hook listen to, and how does a matcher pick?

Hooks live in settings files under a hooks key, nested three levels deep: an event such as PreToolUse, a matcher group that filters it, and one or more handlers that run. Events come in cadences. SessionStart and SessionEnd fire once per session, UserPromptSubmit and Stop once per turn, and PreToolUse and PostToolUse on every tool call: before the tool runs, and after it succeeds. Others, such as PreCompact, SubagentStop and FileChanged, cover the rest of the lifecycle.

Read more (2)

For tool events the matcher is tested against the tool name. A matcher made only of letters, digits, underscores, hyphens, spaces, commas and | is an exact list, so Edit|Write matches those two tools and nothing else. Any other character turns it into an unanchored JavaScript regular expression: Edit.* also matches NotebookEdit, so write ^Edit$ when you mean one tool. Matchers are case-sensitive.

Most handlers are type command, a shell command; the others are http, mcp_tool, prompt and agent. A command hook times out after 600 seconds unless you set timeout. Hooks in the shared .claude/settings.json reach everyone who clones the repository, and hooks from settings files also fire on tool calls made inside subagents, so this repo's blog-writing agent gets the same figure review as the main session.

What does a Claude Code hook receive, and how does it answer?

A command hook gets the event as one JSON object on stdin. Every event carries fields such as session_id, cwd and hook_event_name. Tool events add tool_name and tool_input, and PostToolUse also gets tool_response, the result the tool returned. Paths that file tools put in tool_input always arrive absolute.

Read more (2)

It answers first with its exit code. Exit 0 is success, and Claude Code then reads any JSON the hook printed. Exit 2 is a blocking error on events that can block, and the hook's stderr becomes the reason. Any other code, 1 included, is a non-blocking error: the action goes ahead. A policy hook that exits 1 enforces nothing, and Claude never sees stderr from a hook that exits 0.

For finer control, exit 0 and print exactly one JSON object. Event-specific fields go inside hookSpecificOutput, which needs a hookEventName. There, additionalContext adds a note Claude reads next to the tool result, and a PreToolUse hook can return permissionDecision allow, deny, ask or defer. Print nothing else to stdout: a line echoed by your shell profile ahead of the JSON makes Claude Code read the whole output as plain text, and no error appears.

Should a Claude Code hook block, or only advise?

It depends on when the event fires. Exit 2 on PreToolUse stops the tool call before anything happens. Exit 2 on PostToolUse only shows stderr to Claude, because the tool already ran; a PostToolUse hook can't undo an edit. A Stop hook that exits 2 keeps Claude working, so it should exit early when stop_hook_active is true: Claude Code overrides a Stop hook that blocks eight times in a row.

Read more (2)

Block when an action must never happen, such as an edit to .env or a destructive command. Advise when the right answer needs judgement, or when the thing being checked is legitimately inconsistent in the middle of a task. This repo's figure hook advises on purpose: a figure is often half-wired while a post is being written, and its script says a gate that blocked that would make the work harder rather than the result better.

Either way, a hook is deterministic in a way CLAUDE.md is not. The docs describe CLAUDE.md as context, not enforced configuration: Claude reads it and tries to follow it. A hook fires on every matching event whatever Claude decides, which is why the docs put it plainly: if a rule must hold every time, make it a hook rather than a prompt instruction.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.claude/settings.json:1-17
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node \"${CLAUDE_PROJECT_DIR:-.}/scripts/review-blog-figures.mjs\" --hook 2>/dev/null || true",
            "timeout": 30,
            "statusMessage": "Checking whether the figures fit"
          }
        ]
      }
    ]
  }
}

This repo's entire shared hook config. The exact matcher Write|Edit runs the figure review after every Write or Edit and never for Bash. It uses shell form rather than exec form because it needs the shell's 2>/dev/null || true, and it wraps the path placeholder in double quotes, as the docs ask for shell form. timeout: 30 replaces the 600-second default; statusMessage is the text in the spinner while it runs.

scripts/review-blog-figures.mjs:267-283
if (HOOK_MODE) {
  let file = "";
  try {
    const payload = JSON.parse((await readStdin()) || "{}");
    file =
      payload.tool_input?.file_path ?? payload.tool_response?.filePath ?? "";
  } catch {
    process.exit(0); // Not our payload; a hook must never break the edit.
  }
  const component = WATCHED.component.exec(file);
  if (component) only = component[1];
  checkPixels = WATCHED.registry.test(file) || WATCHED.inline.test(file);
  // Editing anything else — a page, a script, a message file for a post we
  // cannot identify — is not a figure change worth a report.
  if (!component && !checkPixels && !WATCHED.messages.test(file))
    process.exit(0);
}

The input side. It parses the payload from stdin and takes tool_input.file_path, falling back to tool_response.filePath. A payload it can't parse, or a file that isn't a blog component, the figure registry, an inline image or a message file, exits 0 at once, so most edits cost one Node start and nothing else.

scripts/review-blog-figures.mjs:495-517
    const shown = ordered.slice(0, HOOK_MAX_FINDINGS);
    const omitted = ordered.length - shown.length;
    const lines = shown.map(
      (f) => `- ${f.component} [${f.check}] ${f.message}`,
    );
    if (omitted)
      lines.push(
        `- …and ${omitted} more not listed here (run the script for all)`,
      );
    process.stdout.write(
      JSON.stringify({
        hookSpecificOutput: {
          hookEventName: "PostToolUse",
          additionalContext:
            `Figure-fitness review (${scope}) — ADVISORY, nothing is broken:\n${lines.join("\n")}\n` +
            `Look at each image before deciding: does its subject match the section's subject? ` +
            `If the post is about experience or methodology, draw a diagram (scripts/generate-blog-diagram.mjs) ` +
            `instead of borrowing a photograph. Run node scripts/review-blog-figures.mjs for the full report.`,
        },
      }),
    );
  }
  process.exit(0);

The answer side. One JSON object with hookSpecificOutput, hookEventName and additionalContext, and always exit 0. Findings are capped at HOOK_MAX_FINDINGS, which is 12, so a noisy check can't flood the context it is advising, and the text opens by stating plainly that it is advisory. With no findings it prints nothing at all.

What breaks

Mistakes that cost real time

  • exit 1 used to enforce a policy

    Without valid JSON, exit 1 is a non-blocking error and the action goes ahead. Use exit 2 to block. If a crashing or missing script must also block, set onFailure: "block" on the handler, which needs Claude Code v2.1.295 or later.

  • A regex matcher that matches too much

    Edit.* is a regular expression, tested anywhere in the name, so it also fires for NotebookEdit. Use the exact list Edit|Write, or anchor it as ^Edit$.

  • Hook JSON that does nothing

    Two silent causes. Something printed before the JSON, often an echo in your shell profile, so the output no longer starts with {. Or a field at the wrong level, such as additionalContext outside hookSpecificOutput. Start claude --debug and search the log for "Hook output does not start with {" or "Hook JSON output had unrecognized keys".

  • Files changed through Bash never fire Edit|Write

    Claude Code doesn't run a PostToolUse hook matching Edit|Write when a Bash command or an outside process rewrites the file. This repo merges new posts with scripts/merge-blog-staging.mjs through Bash, so the hook never sees them, and scripts/audit-blog-figures.mjs stays a separate gate. The FileChanged event exists for watching a file whatever wrote it.

  • What I'd change here: a crash looks like silence

    2>/dev/null || true turns a crash into an empty answer. Claude never sees stderr from a hook that exits 0, and with stderr thrown away it doesn't reach the debug log either, so a broken script looks exactly like "no findings". Dropping 2>/dev/null would keep the exit code at 0 through || true and still leave the error in the debug log.

What each answer does, before and after the tool runs
exit 0, nothing printedNormal permission flowNothing is added
exit 0 + additionalContextNormal flow; Claude gets the noteClaude reads the note beside the result
exit 2 + stderrBlocked; stderr is the reasonClaude reads stderr; the edit stays
exit 1, no JSONNon-blocking error; the call goes aheadNon-blocking error; the edit stays
Try it yourself

Do it on your own machine

You need Claude Code, jq and a scratch git repository. Build one advisory hook and one blocking gate, test both by hand, then watch them in the debug log.

  1. Save an advisory hook as .claude/hooks/todo-note.sh. It reads the edited path from stdin and, if the file still has TODO markers, prints one JSON object.

    #!/bin/bash
    FILE=$(jq -r '.tool_input.file_path // empty')
    if [ -n "$FILE" ] && grep -q TODO "$FILE"; then
      jq -nc '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: "The edited file still contains TODO markers."}}'
    fi
    exit 0
  2. Save a gate as .claude/hooks/protect-generated.sh. It exits 2 with a reason on stderr when the path is under a generated folder.

    #!/bin/bash
    FILE=$(jq -r '.tool_input.file_path // empty')
    if [[ "$FILE" == */generated/* ]]; then
      echo "Blocked: files under generated/ are built by a script" >&2
      exit 2
    fi
    exit 0
  3. Make both executable and register them in .claude/settings.json: the note after Edit and Write, the gate before them.

    chmod +x .claude/hooks/*.sh
    
    {
      "hooks": {
        "PostToolUse": [
          { "matcher": "Edit|Write",
            "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/todo-note.sh" }] }
        ],
        "PreToolUse": [
          { "matcher": "Edit|Write",
            "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-generated.sh" }] }
        ]
      }
    }
  4. Test the gate before Claude ever runs it, by piping sample JSON in and printing the exit code.

    echo '{"tool_input":{"file_path":"src/generated/api.ts"}}' | .claude/hooks/protect-generated.sh
    echo "exit $?"
  5. Start Claude Code with a debug log, check that /hooks lists both, then ask Claude to edit a file containing TODO and a file under generated/. Read what each hook did in the log.

    claude --debug-file ./hooks.log
    > /hooks
    
    # in another terminal
    tail -f hooks.log
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.Your PreToolUse hook should stop edits to .env. It spots one, prints a reason to stderr and exits 1. What happens to the edit?
  2. 2.You register a PostToolUse hook with the matcher Edit.* to format files after edits. Which tools fire it?
  3. 3.A PostToolUse hook with the matcher Write|Edit lints every changed file. Claude then rewrites a file with sed -i through Bash. Does the hook run?
Think it through

Answer in your head, then check

  1. 1.A PostToolUse hook exits 2. Is the edit undone?Show the answer

    Answer:No. The tool already ran. Exit 2 on PostToolUse only shows the hook's stderr to Claude so it can react. To stop an edit, block it in PreToolUse.

  2. 2.Your hook prints valid JSON, but nothing happens and no error appears. What do you check?Show the answer

    Answer:Whether anything reached stdout before the JSON, such as an echo in your shell profile, since the output must start with {. Then whether additionalContext or permissionDecision sits inside hookSpecificOutput. claude --debug names both problems in the log.

  3. 3.How does this repo make sure its hook can never block an edit?Show the answer

    Answer:It is a PostToolUse hook, which runs after the tool and can't stop it. The command ends in 2>/dev/null || true, so it always exits 0, and the script exits 0 at once for any payload it doesn't recognise. The 30-second timeout caps how long it can hold the turn.

Go deeper

Related deep dives on this site