Claude Code hooks: PreToolUse, PostToolUse and Stop examples A developer documented how Claude Code hooks work, describing lifecycle events including PreToolUse, PostToolUse, Stop, SessionStart and PreCompact that run commands at fixed points regardless of what the model decides. The writeup covers the JSON payloads hooks receive on stdin, the exit-code and permissionDecision conventions for allowing, denying or blocking tool calls, and provides Python-based examples such as a script that blocks edits to .env files, package-lock.json, .git and secrets directories. The author notes hooks change quickly between Claude Code versions and that the examples follow the official reference as of November 2026. Originally published on Homingo Learn https://learn.homingo.co.in/guides/claude-code-hooks-examples . CLAUDE.md asks; a hook makes sure. How hooks work, the JSON they read and return, and five examples to adapt today. 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. 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. 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: 0 = carry on. 2 = block: in 1 blocks nothing. hookSpecificOutput.permissionDecision allow , deny or ask plus a reason; "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 https://code.claude.com/docs/en/hooks 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. .claude/hooks/protect files.py python 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 { "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 . In our subagents guide https://learn.homingo.co.in/guides/claude-code-subagents-guide team 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 python 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. .claude/hooks/tests before stop.py python 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 : { "hooks": { "Stop": { "hooks": { "type": "command", "command": "python \"$CLAUDE PROJECT DIR/.claude/hooks/tests before stop.py\"", "timeout": 300 } } } } 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: { "hooks": { "PostToolUse": { "matcher": "Edit|Write", "hooks": { "type": "command", "command": "python \"$CLAUDE PROJECT DIR/.claude/hooks/generated note.py\"" } } } } .claude/hooks/generated note.py python 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/