Skip to main content

Claude Code hooks: PreToolUse, PostToolUse and Stop examples

Updated 10 min readMarkdown

Originally published on Homingo Learn.

CLAUDE.md asks; a hook makes sure. How hooks work, the JSON they read and return, and five examples to adapt today.

What are Claude Code hooks?

A hook is a command that Claude Code runs at a fixed point in its lifecycle: before a tool call (PreToolUse), after one succeeds (PostToolUse), when Claude finishes its reply (Stop), when a session starts, and many more. Instructions in CLAUDE.md are context that Claude tries to follow; a hook runs every time, whatever the model decides. Use CLAUDE.md for judgement and hooks for anything that must always happen or must never happen.

The events you will use most:

  • PreToolUse: before a tool call; can allow, deny or ask, or rewrite the input.
  • PostToolUse: after a tool call succeeds (PostToolUseFailure when it fails); can add context for Claude.
  • UserPromptSubmit: when you send a prompt, before Claude sees it.
  • Stop and SubagentStop: when Claude or a subagent finishes its reply; can send it back to work.
  • SessionStart and SessionEnd: at the start (including after /compact) and end of a session.
  • Notification and PermissionRequest: when Claude needs your attention or is about to ask for permission.
  • PreCompact: before the conversation is compacted.

Where hooks live: settings.json and frontmatter

Hooks go in a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for one project (commit it to share with the team) or .claude/settings.local.json for yourself only. Subagents and skills can also carry hooks in their frontmatter, active only while they run. The structure has three levels: the event, a matcher group, and the commands to run.

.claude/settings.json

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write", "timeout": 30 }
        ]
      }
    ]
  }
}

That one is the official auto-format example: after every Edit or Write, Prettier formats the file Claude touched. The matcher is a tool name, a list like Edit|Write, or a regular expression (mcp__github__.* matches every tool of an MCP server called github); * or an empty matcher means every tool. Type /hooks in a session to see what is configured and where each hook comes from. This one needs jq and breaks on paths with spaces; the Python hooks below avoid both.

Hook input JSON, exit codes and JSON output

Claude Code sends the event as JSON on the hook’s standard input. For tool events it includes session_id, cwd, hook_event_name, tool_name and tool_input (for Edit and Write, tool_input.file_path is always absolute); PostToolUse adds tool_response. The hook answers in one of two ways:

  • Exit codes. 0 = carry on. 2 = block: in PreToolUse the tool call does not run and your stderr text goes to Claude as the reason; in PostToolUse the tool already ran, so stderr reaches Claude as a warning. Any other code is a non-blocking error: the action proceeds. A policy hook that exits 1 blocks nothing.
  • JSON on stdout (with exit 0) for finer control. PreToolUse returns hookSpecificOutput.permissionDecision (allow, deny or ask) plus a reason; PostToolUse and Stop use a top-level "decision": "block" with a reason; hookSpecificOutput.additionalContext (with hookEventName) adds a note for Claude.

Hooks change quickly between Claude Code versions. Everything here follows the official hooks reference as of November 2026; check it before relying on a rarer event or field. We write the scripts below in Python so they run the same on macOS, Linux and Windows, with no jq needed.

Example 1: block edits to protected files (PreToolUse)

.claude/hooks/protect_files.py

import json
import sys
from pathlib import PurePosixPath

data = json.load(sys.stdin)
path = PurePosixPath(data.get("tool_input", {}).get("file_path", "").replace("\\", "/"))
name, parts = path.name, set(path.parts)
blocked = (
    (name == ".env" or (name.startswith(".env.") and name != ".env.example"))
    or name == "package-lock.json"
    or ".git" in parts
    or "secrets" in parts
)
if blocked:
    print(f"Blocked: {path} is protected. Ask the user to change it.", file=sys.stderr)
    sys.exit(2)  # exit 2 = block the call; stderr goes to Claude as the reason
sys.exit(0)

.claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect_files.py\"" }]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIR is the project root, so the hook works whatever folder Claude is in. Use python3 on macOS or Linux if that is your interpreter’s name, or py on Windows. If your settings file already has a "hooks" object, add the event inside it rather than a second "hooks" key. Test it by asking Claude to add a comment to .env: the edit is refused and Claude sees the Blocked: line. A hook only guards the tools it matches, and this one stops edits only. Add Read(./.env) and Edit(./.env) (and the same for ./secrets/**) to permissions.deny in your settings, so Claude can neither read the secrets into its context nor change them with its file tools. Bash remains the gap: narrow it with Bash deny rules and Claude Code’s Bash sandboxing (/sandbox).

Example 2: restrict a subagent's Bash commands (PreToolUse allow-list)

In our subagents guide a file-qa agent measures a rendered vertical video with ffprobe. Its prompt says “run only ffprobe”, but a prompt is a request. A hook in the agent’s own frontmatter turns it into a rule, active only while that agent runs:

.claude/agents/file-qa.md, frontmatter

---
name: file-qa
description: Technical QA of a rendered vertical video file. Use after every render, before posting.
tools: Bash
model: haiku
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: 'python "$CLAUDE_PROJECT_DIR/.claude/hooks/only_ffprobe.py"'
---

.claude/hooks/only_ffprobe.py

import json
import shlex
import sys

data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")


def decide(decision, reason):
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": decision,
        "permissionDecisionReason": reason,
    }}))
    sys.exit(0)


if any(ch in command for ch in ";&|<>`$\n\r{}*?[]~#()"):
    decide("deny", "One plain ffprobe command only: no pipes, redirects, chaining, substitutions, globs or braces.")
try:
    args = shlex.split(command)
except ValueError:
    decide("deny", "Could not parse the command.")
if not args or args[0] != "ffprobe":
    decide("deny", "This agent may run ffprobe only.")
if any(a in ("-o", "-report") for a in args[1:]):
    decide("deny", "ffprobe may not write files here (-o and -report are blocked).")
decide("allow", "Plain ffprobe probe.")

Rejecting shell metacharacters first matters: ffprobe x.mp4; rm -rf renders starts with ffprobe too. So does ffprobe -o, which writes its output to a file, hence the last check. Braces matter too: ffprobe x.mp4 {-o,out.txt} expands to -o out.txt before ffprobe runs, so they are refused with the other shell characters. A deny from a hook holds in every permission mode, even bypassPermissions. An allow skips the permission prompt but never overrides a deny rule in your settings.

Example 3: send failing tests back to Claude before it stops (Stop)

.claude/hooks/tests_before_stop.py

import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

MAX_TRIES = 3
data = json.load(sys.stdin)
counter = Path(tempfile.gettempdir()) / f"claude-stop-{data.get('session_id', 'default')}.txt"
tries = int(counter.read_text()) if counter.exists() else 0

project = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd")
result = subprocess.run(
    "npm test --silent", shell=True, capture_output=True, text=True,
    cwd=project, env={**os.environ, "CI": "true"},  # CI=true keeps test runners out of watch mode
)
if result.returncode == 0 or tries >= MAX_TRIES:
    counter.unlink(missing_ok=True)  # tests pass, or out of tries: let Claude stop
    sys.exit(0)
counter.write_text(str(tries + 1))
tail = (result.stdout + result.stderr)[-1500:]
reason = f"Tests fail (try {tries + 1} of {MAX_TRIES}). Fix them before finishing:\n{tail}"
print(json.dumps({"decision": "block", "reason": reason}))

When the suite fails, Claude gets the failing output as its next instruction instead of ending the turn, up to three times in a row; after that Claude may stop, the counter resets and you decide. The official docs suggest checking the stop_hook_active input field to avoid endless loops, but that allows only one retry; a small counter allows a few. Claude Code also overrides a Stop hook that blocks eight times in a row with no tool call in between. Stop fires whenever Claude finishes a reply, so keep the suite fast or run only the tests for what changed. Register it with a timeout longer than your suite takes (in seconds; Stop needs no matcher):

.claude/settings.json

{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/tests_before_stop.py\"", "timeout": 300 }] }
    ]
  }
}

Example 4: add context after a tool runs (PostToolUse)

A PostToolUse hook cannot undo anything, but it can tell Claude something. This one, registered for Edit|Write, warns Claude when it edits a generated file so the next change goes to the spec instead:

.claude/settings.json

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/generated_note.py\"" }]
      }
    ]
  }
}

.claude/hooks/generated_note.py

import json
import sys

data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "").replace("\\", "/")
if "/src/generated/" in path:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PostToolUse",
        "additionalContext": "This file is generated from the episode spec and is overwritten on the next render. "
                             "Make the change in episodes/<id>/spec.json instead.",
    }}))

To re-inject key rules after the conversation is compacted, a SessionStart hook with the matcher compact can print them; plain stdout from SessionStart goes straight into Claude’s context.

Example 5: a desktop alert when Claude needs you (Notification)

~/.claude/settings.json (Linux)

{
  "hooks": {
    "Notification": [
      { "hooks": [{ "type": "command", "command": "notify-send 'Claude Code' 'Claude Code needs your attention'" }] }
    ]
  }
}

Put it in your user settings so it works in every project. On macOS, use osascript -e 'display notification "Claude Code needs your attention" with title "Claude Code"' as the command; the official hooks guide also has a PowerShell version for Windows.

Debugging hooks and common mistakes

  • Exit 1 for a policy. It only logs an error. Block with exit 2 or a deny decision.
  • JSON that never parses. Stdout must be a single JSON object. A shell profile that echoes text on startup breaks it; so does building JSON by string concatenation. Use a JSON encoder.
  • Fields at the wrong level. permissionDecision sits inside hookSpecificOutput; decision for Stop and PostToolUse is top level.
  • Scripts that are not executable. On macOS and Linux a script called directly needs chmod +x; calling it as python script.py avoids that. On Windows, Claude Code runs command hooks through Git Bash; if it cannot find Git Bash, the hook fails.
  • Slow hooks on hot events. PostToolUse runs after every matching call, in parallel when Claude makes parallel calls. Keep it to milliseconds or narrow the matcher.
  • Treating hooks as a sandbox. A hook sees only the tools it matches, and string checks on commands can be bypassed. For hard limits, use permission rules and the Bash sandbox as well.

Hooks complete the set: CLAUDE.md holds the project rules, a skill says how to do a job, subagents check it with a fresh context, and hooks enforce the lines nobody may cross. If you make short vertical videos with Claude Code, the skill below packages the spec-first workflow that Examples 2 and 4 come from.

Animated horror reels skill: Our whole playbook as a Claude Code agent skill: the hook-first story method, the episode spec template, the camera, light and grade system with a palette builder, a render-ready Remotion starter and the QA checks before you post. Make reels like ours. See the skill


Disclosure: this guide is by Homingo, the team behind the skill linked above. Everything in it works without it.

About the author

  • Homingo Learn publishes practical, project-based guides and courses on building with AI: Claude Code skills, agents, MCP and automation. https://learn.homingo.co.in

No comments yet. Be the first to comment.