# Claude Code hooks: PreToolUse, PostToolUse and Stop examples

> Source: <https://dev.to/mohd_yusufkhan_6c2088c04/claude-code-hooks-pretooluse-posttooluse-and-stop-examples-3460>
> Published: 2026-11-02 13:49:02+00:00

*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/<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.

*~/.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.

`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.
Hooks 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.

**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)

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