{"slug": "claude-code-hooks-pretooluse-posttooluse-and-stop-examples", "title": "Claude Code hooks: PreToolUse, PostToolUse and Stop examples", "summary": "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.", "body_md": "*Originally published on [Homingo Learn](https://learn.homingo.co.in/guides/claude-code-hooks-examples).*\n\n*CLAUDE.md asks; a hook makes sure. How hooks work, the JSON they read and return, and five examples to adapt today.*\n\nA **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.\n\nThe events you will use most:\n\n`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.\nHooks 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.\n\n*.claude/settings.json*\n\n```\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [\n          { \"type\": \"command\", \"command\": \"jq -r '.tool_input.file_path' | xargs npx prettier --write\", \"timeout\": 30 }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThat 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.\n\nClaude 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:\n\n`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.\nHooks 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.\n\n*.claude/hooks/protect_files.py*\n\n``` python\nimport json\nimport sys\nfrom pathlib import PurePosixPath\n\ndata = json.load(sys.stdin)\npath = PurePosixPath(data.get(\"tool_input\", {}).get(\"file_path\", \"\").replace(\"\\\\\", \"/\"))\nname, parts = path.name, set(path.parts)\nblocked = (\n    (name == \".env\" or (name.startswith(\".env.\") and name != \".env.example\"))\n    or name == \"package-lock.json\"\n    or \".git\" in parts\n    or \"secrets\" in parts\n)\nif blocked:\n    print(f\"Blocked: {path} is protected. Ask the user to change it.\", file=sys.stderr)\n    sys.exit(2)  # exit 2 = block the call; stderr goes to Claude as the reason\nsys.exit(0)\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [{ \"type\": \"command\", \"command\": \"python \\\"$CLAUDE_PROJECT_DIR/.claude/hooks/protect_files.py\\\"\" }]\n      }\n    ]\n  }\n}\n```\n\n`$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`).\n\nIn 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:\n\n*.claude/agents/file-qa.md, frontmatter*\n\n```\n---\nname: file-qa\ndescription: \"Technical QA of a rendered vertical video file. Use after every render, before posting.\"\ntools: Bash\nmodel: haiku\nhooks:\n  PreToolUse:\n    - matcher: \"Bash\"\n      hooks:\n        - type: command\n          command: 'python \"$CLAUDE_PROJECT_DIR/.claude/hooks/only_ffprobe.py\"'\n---\n```\n\n*.claude/hooks/only_ffprobe.py*\n\n``` python\nimport json\nimport shlex\nimport sys\n\ndata = json.load(sys.stdin)\ncommand = data.get(\"tool_input\", {}).get(\"command\", \"\")\n\ndef decide(decision, reason):\n    print(json.dumps({\"hookSpecificOutput\": {\n        \"hookEventName\": \"PreToolUse\",\n        \"permissionDecision\": decision,\n        \"permissionDecisionReason\": reason,\n    }}))\n    sys.exit(0)\n\nif any(ch in command for ch in \";&|<>`$\\n\\r{}*?[]~#()\"):\n    decide(\"deny\", \"One plain ffprobe command only: no pipes, redirects, chaining, substitutions, globs or braces.\")\ntry:\n    args = shlex.split(command)\nexcept ValueError:\n    decide(\"deny\", \"Could not parse the command.\")\nif not args or args[0] != \"ffprobe\":\n    decide(\"deny\", \"This agent may run ffprobe only.\")\nif any(a in (\"-o\", \"-report\") for a in args[1:]):\n    decide(\"deny\", \"ffprobe may not write files here (-o and -report are blocked).\")\ndecide(\"allow\", \"Plain ffprobe probe.\")\n```\n\nRejecting 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.\n\n*.claude/hooks/tests_before_stop.py*\n\n``` python\nimport json\nimport os\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nMAX_TRIES = 3\ndata = json.load(sys.stdin)\ncounter = Path(tempfile.gettempdir()) / f\"claude-stop-{data.get('session_id', 'default')}.txt\"\ntries = int(counter.read_text()) if counter.exists() else 0\n\nproject = os.environ.get(\"CLAUDE_PROJECT_DIR\") or data.get(\"cwd\")\nresult = subprocess.run(\n    \"npm test --silent\", shell=True, capture_output=True, text=True,\n    cwd=project, env={**os.environ, \"CI\": \"true\"},  # CI=true keeps test runners out of watch mode\n)\nif result.returncode == 0 or tries >= MAX_TRIES:\n    counter.unlink(missing_ok=True)  # tests pass, or out of tries: let Claude stop\n    sys.exit(0)\ncounter.write_text(str(tries + 1))\ntail = (result.stdout + result.stderr)[-1500:]\nreason = f\"Tests fail (try {tries + 1} of {MAX_TRIES}). Fix them before finishing:\\n{tail}\"\nprint(json.dumps({\"decision\": \"block\", \"reason\": reason}))\n```\n\nWhen 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):\n\n```\n{\n  \"hooks\": {\n    \"Stop\": [\n      { \"hooks\": [{ \"type\": \"command\", \"command\": \"python \\\"$CLAUDE_PROJECT_DIR/.claude/hooks/tests_before_stop.py\\\"\", \"timeout\": 300 }] }\n    ]\n  }\n}\n```\n\nA `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:\n\n```\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [{ \"type\": \"command\", \"command\": \"python \\\"$CLAUDE_PROJECT_DIR/.claude/hooks/generated_note.py\\\"\" }]\n      }\n    ]\n  }\n}\n```\n\n*.claude/hooks/generated_note.py*\n\n``` python\nimport json\nimport sys\n\ndata = json.load(sys.stdin)\npath = data.get(\"tool_input\", {}).get(\"file_path\", \"\").replace(\"\\\\\", \"/\")\nif \"/src/generated/\" in path:\n    print(json.dumps({\"hookSpecificOutput\": {\n        \"hookEventName\": \"PostToolUse\",\n        \"additionalContext\": \"This file is generated from the episode spec and is overwritten on the next render. \"\n                             \"Make the change in episodes/<id>/spec.json instead.\",\n    }}))\n```\n\nTo 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.\n\n*~/.claude/settings.json (Linux)*\n\n```\n{\n  \"hooks\": {\n    \"Notification\": [\n      { \"hooks\": [{ \"type\": \"command\", \"command\": \"notify-send 'Claude Code' 'Claude Code needs your attention'\" }] }\n    ]\n  }\n}\n```\n\nPut 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.\n\n`permissionDecision` sits inside `hookSpecificOutput`; `decision` for Stop and PostToolUse is top level.`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.\nHooks complete the set: [CLAUDE.md](https://learn.homingo.co.in/guides/claude-md-best-practices) holds the project rules, a [skill](https://learn.homingo.co.in/guides/how-to-write-skill-md-claude-code) says how to do a job, [subagents](https://learn.homingo.co.in/guides/claude-code-subagents-guide) 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.\n\n**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](https://learn.homingo.co.in/skill?utm_source=devto&utm_medium=article&utm_campaign=claude-code-hooks-examples)\n\n*Disclosure: this guide is by Homingo, the team behind the skill linked above. Everything in it works without it.*", "url": "https://wpnews.pro/news/claude-code-hooks-pretooluse-posttooluse-and-stop-examples", "canonical_source": "https://dev.to/mohd_yusufkhan_6c2088c04/claude-code-hooks-pretooluse-posttooluse-and-stop-examples-3460", "published_at": "2026-11-02 13:49:02+00:00", "updated_at": "2026-11-02 13:53:01.973059+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Claude Code", "Anthropic", "Prettier", "Homingo Learn"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-hooks-pretooluse-posttooluse-and-stop-examples", "markdown": "https://wpnews.pro/news/claude-code-hooks-pretooluse-posttooluse-and-stop-examples.md", "text": "https://wpnews.pro/news/claude-code-hooks-pretooluse-posttooluse-and-stop-examples.txt", "jsonld": "https://wpnews.pro/news/claude-code-hooks-pretooluse-posttooluse-and-stop-examples.jsonld"}}