Claude Code Hooks: Automate Actions Before and After Tools

Claude Code Hooks: Automate Actions Before and After Tools

Claude Code hooks are small shell commands that run on their own at set moments in a session, like right before Claude edits a file or right after it finishes. Because they are plain scripts, they run every single time, instead of depending on Claude to remember a rule. You can use them to block risky edits, log changes, format code or feed Claude extra context.

In this guide we build three real hooks in a throwaway demo project. One blocks edits to a secrets file, one logs every file Claude changes, and one hands Claude a team rule when the session starts.

We also hit a real gap along the way, and the fix teaches the most important lesson about hooks. Every screenshot comes from a real session.

This is Part 30 of 53 in our Claude Code tutorials series. In the previous part we built specialist subagents; today we add automatic guardrails around everything Claude does.

Tested with Claude Code v2.1.310 on October 25, 2026, on macOS, in short sessions inside a demo folder.

Quick Summary: Claude Code Hooks at a Glance

QuestionShort answer
What is a hook?A command that runs automatically when a lifecycle event fires
Where do hooks go?In a settings file, under a “hooks” key
Which events matter most at first?PreToolUse, PostToolUse, SessionStart and Stop
How does a hook block something?It exits with code 2 and writes the reason to stderr
What does the hook receive?JSON about the event on stdin, such as tool_name and tool_input
How do I see my hooks?Type /hooks for a read-only list
How many events exist?33 in v2.1.310
Can I turn them all off?Yes, set “disableAllHooks”: true

What Claude Code Hooks Are and Why They Exist

You can tell Claude “never touch the .env file” in a prompt or in CLAUDE.md. Most of the time it listens. But a language model can still forget, misread or find another way around a rule.

Hooks fix that by moving the rule out of the conversation and into code. The official hooks guide describes this as deterministic control: certain actions always happen, rather than relying on the model to choose them.

A prompt is a request. A hook is a rule that runs whether Claude remembers it or not.

Every hook has three parts. The event is the moment it fires, the matcher narrows which cases count, and the handler is what actually runs. Most handlers are shell commands, though Claude Code also supports HTTP, MCP tool, prompt and agent handlers.

Here are the events most people start with:

  • SessionStart fires when a session begins or resumes. Text your command prints becomes context for Claude.
  • UserPromptSubmit fires when you send a prompt, before Claude reads it.
  • PreToolUse fires before a tool call runs, and it can block the call.
  • PostToolUse fires after a tool call succeeds, which is perfect for formatting or logging.
  • Stop fires when Claude finishes its reply.
Diagram showing when Claude Code hooks fire: SessionStart, UserPromptSubmit, then PreToolUse, tool run and PostToolUse for every tool call, then Stop
When the main hook events fire during a session

Step 1: Add Hooks to Your Settings File

Hooks live in the same JSON files you met in our guide to settings files. The location decides who gets the hook:

FileScopeShared with your team?
~/.claude/settings.jsonAll your projectsNo, only on your machine
.claude/settings.jsonOne projectYes, you can commit it
.claude/settings.local.jsonOne projectNo, kept out of git
Managed policy settingsWhole organizationYes, set by admins
Plugin, skill or subagentWhile that piece is activeYes, bundled with it

For our demo we used the project file, .claude/settings.json, inside a tiny folder with one JavaScript file and a fake .env file. It registers three hooks, one per event.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          { "type": "command", "command": "echo 'Team rule: comment every function.'" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r .tool_input.file_path >> \"$CLAUDE_PROJECT_DIR\"/edits.log" }
        ]
      }
    ]
  }
}
Terminal showing a .claude/settings.json file with SessionStart, PreToolUse and PostToolUse hooks
Our demo settings file: one hook for session start, one before edits and one after edits

Notice the nesting. Each event name holds a list of matcher groups, and each group holds a list of handlers. If your file already has a “hooks” key, add new events next to the existing ones instead of replacing the whole object.

The variable $CLAUDE_PROJECT_DIR points at the project root, so the script path works no matter which folder Claude is in. The docs recommend wrapping it in double quotes, just like we did.

Write the Blocking Script

The PreToolUse hook calls a short script based on the example in the official guide. It reads the JSON that Claude Code sends on stdin, pulls out the file path with jq, and exits with code 2 if the path matches a protected pattern.

#!/bin/bash
# Blocks edits to sensitive files (PreToolUse on Edit|Write)
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

for pattern in ".env" "package-lock.json" ".git/"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done
exit 0

Save it as .claude/hooks/protect-files.sh and make it executable with chmod +x. On macOS you may need to install jq first with Homebrew.

Step 2: Check Your Hooks With /hooks

Start Claude Code in the folder and accept the trust dialog. That step matters: in an interactive session, Claude Code holds back hooks from settings files until you trust the folder.

Then type /hooks. You get a read-only list of every hook, grouped by event, with a [Project] tag showing where each one came from.

The /hooks menu in Claude Code listing three project hooks on PreToolUse, PostToolUse and SessionStart
The /hooks menu found all three hooks from our project settings

The menu says it plainly: it is read-only. To add or change a hook you edit settings.json or ask Claude to do it. Press Enter on a hook to see its full command and the file that defines it.

Claude Code hook detail view showing PreToolUse Edit|Write, the project settings source and the script command
The detail view shows the event, the matcher, the source file and the exact command

At the bottom of the list, “All events” opens every event you can hook, even empty ones. In our version it showed 33 events, with 3 in use.

Claude Code Hook events list showing 33 events with 3 that have hooks
All events: a quick way to discover what else you can hook into

Step 3: Test the Script Before Claude Uses It

A tip from the troubleshooting docs saves a lot of guessing. You can feed your script fake JSON by hand and check its exit code, without starting Claude at all.

echo '{"tool_name":"Edit","tool_input":{"file_path":".env"}}' | .claude/hooks/protect-files.sh; echo "exit code: $?"
Terminal piping sample JSON into hook scripts: .env returns exit code 2 and src/greet.js returns exit code 0
Testing by hand: .env gets exit code 2 (block), while src/greet.js gets 0 (no objection)

The exit code is the language your hook speaks. Here is what each one means for a PreToolUse hook:

Diagram of hook exit codes: exit 0 means no objection, exit 0 with JSON gives a structured decision, exit 2 blocks, any other code is a non-blocking error
How Claude Code reads your hook’s exit code

One detail surprises many people. Exit 0 does not approve a tool call, it only means “no objection”, so the normal permission prompt still appears. To approve, deny or ask with a reason, print JSON with a permissionDecision field inside hookSpecificOutput instead.

Step 4: The Gap We Found, and How to Close It

In our first live test we asked Claude to “Add the line DEBUG=true to .env.” We expected the hook to stop it. It did not.

Claude chose the Bash tool instead of Edit and appended the line with a shell command. Claude Code showed its normal permission prompt for that command, and once it was approved, the file changed. Our hook only watched Edit and Write, so it never saw that command.

Claude Code running a Bash command that appends DEBUG=true to .env, bypassing an Edit|Write hook
Our first run: Claude used Bash to change .env, and the Edit|Write hook never fired

This is exactly the warning in the official docs. Claude can create or change files through shell commands, so a hook that must see every change needs more than an Edit|Write matcher.

We added a second PreToolUse group that matches Bash and runs a guard script. It blocks any shell command that mentions .env.

PreToolUse hooks with a new Bash matcher and a guard script that exits 2 when a command touches .env
The fix: a second matcher group for Bash with its own guard script

Then we started a fresh session and tried again with “Add the line DEBUG=true to .env. If it is blocked, stop and tell me why.” This time the Edit hook caught the attempt right away.

Claude Code showing a PreToolUse Edit hook error that blocked a change to .env, and Claude explaining why
The block in action: the script’s stderr message reached Claude, and the file stayed unchanged

Look at what happened after the block. Claude read the stderr message, explained the protected pattern and said it would not work around the hook. That feedback loop is the point of exit code 2.

Keep in mind that our Bash guard is a simple string check, fine for a demo but easy to fool. For hard limits, pair hooks with deny rules, as the docs advise.

Step 5: Watch PostToolUse and SessionStart Work

Next we asked Claude to “Add a one-line comment above the greet function in src/greet.js.” The edit went through the normal approval prompt, and then the PostToolUse hook quietly wrote the file path to edits.log.

When a hook succeeds, Claude Code shows nothing in the chat. To confirm it ran, press Ctrl+O to open the detailed transcript.

Claude Code detailed transcript showing an Update to src/greet.js followed by the line 1 PostToolUse hook ran
Ctrl+O reveals the quiet success: “1 PostToolUse hook ran” under the edit

After we exited, the log file held one line, written by the hook, not by Claude.

Terminal showing edits.log with the path of src/greet.js and the file with its new comment
edits.log, filled in by the PostToolUse hook, plus the updated greet.js

Finally, the SessionStart hook. Its echo command printed a team rule, and Claude Code added that text to Claude’s context. When we asked “What team rule were you given when this session started?”, Claude quoted it back word for word.

Claude Code answering that the SessionStart hook output said Team rule: comment every function
Text printed by a SessionStart hook becomes context Claude can see

For rules that apply to every session, a CLAUDE.md file is often simpler. SessionStart shines when the text is dynamic, like the output of git log, or when you pair it with the compact matcher to restore notes after compaction.

Real Examples of Claude Code Hooks

The official guide ships several ready-made recipes. These are the ones most worth copying:

GoalEvent and matcherWhat the handler does
Desktop alert when Claude needs youNotification, empty matcherosascript on macOS, notify-send on Linux
Auto-format after editsPostToolUse, Edit|WriteRuns Prettier on the edited file
Protect sensitive filesPreToolUse, Edit|WriteExits 2 for .env, lock files and .git
Restore notes after compactionSessionStart, compactEchoes key project reminders
Log every shell commandPostToolUse, BashAppends tool_input.command to a file
Skip the plan approval promptPermissionRequest, ExitPlanModePrints JSON with behavior “allow”

Here is the auto-format recipe from the docs. It needs jq and Prettier in your project:

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

For judgment calls, the docs also describe prompt hooks, which ask a Claude model to return {“ok”: true} or {“ok”: false, “reason”: “…”}. A Stop prompt hook, for example, can check whether every task is done and keep Claude working if not. The full schemas are in the hooks reference.

Try It Yourself

Make a new empty folder with one small file in it, start Claude Code there, and let Claude write the hook for you. Then read what it wrote before you trust it.

Create a project hook in .claude/settings.json that runs before any Edit or Write
and blocks changes to files named secrets.txt by exiting with code 2 and a clear
stderr message. Put the script in .claude/hooks/, make it executable, and show me
how to test it by piping sample JSON into it.

Run /hooks to confirm it shows up, test the script by hand, then ask Claude to edit secrets.txt. Bonus round: ask it to change the file with a shell command and see whether your hook catches that too.

Common Mistakes With Claude Code Hooks

  • Matching only Edit|Write and assuming files are safe. As we saw, Bash can change files too.
  • Expecting exit 0 to approve a tool call. It only means no objection; the permission prompt still runs.
  • Forgetting chmod +x on the script, so it never runs.
  • Writing “bash” or “edit” in a matcher. Matchers are case-sensitive, so use Bash and Edit.
  • Putting permissionDecision or additionalContext at the top level of the JSON. Both belong inside hookSpecificOutput, or Claude Code ignores them.
  • Leaving a trailing comma in settings.json. Invalid JSON means no hooks at all.
  • A shell profile that prints text, which breaks a hook’s JSON output.

Pro Tips

  • Use the if field to narrow a hook further, such as “if”: “Bash(git *)”, so your script spawns only for git commands.
  • Run claude –debug when a hook misbehaves. The debug log shows each hook’s exit code, stdout and stderr.
  • Set “disableAllHooks”: true to pause every hook, or pass it with –settings for a single run.
  • Give a slow hook a statusMessage so you see a spinner label while it runs.
  • Read every hook in a repo you did not write. Command hooks run with your full user permissions.
  • Remember that a hook’s deny holds even in bypass mode, which makes PreToolUse great for team policy.

Claude Code Hooks Cheat Sheet

Claude Code hooks cheat sheet with settings file locations, the /hooks menu, main events and exit codes
Save this cheat sheet: where hooks live, the main events and what exit codes mean

Frequently Asked Questions

What is the difference between a hook and a skill?

A skill gives Claude instructions that it chooses to follow when the skill is relevant. A hook is a script that Claude Code itself runs at a fixed moment, every time. Use custom skills for know-how and hooks for rules that must never be skipped.

Do hooks cost tokens?

Command hooks are plain shell scripts, so running them uses no model calls. Text a hook adds to context, like SessionStart output, does take up some of the context window. Prompt and agent hooks call a model, so they do use tokens.

Why is my hook not firing?

Run /hooks and check that it appears under the right event. Then check the matcher spelling and case, the JSON syntax, the script’s execute permission, and that you accepted the folder trust dialog.

Can a PostToolUse hook undo an action?

No. The tool has already run by then. Exit code 2 from PostToolUse shows your message to Claude so it can react, but to stop an action you need PreToolUse.

Are hooks safe to use in shared repos?

They can be, but they run shell commands with your permissions. Review a project’s .claude/settings.json before you trust a folder, and be extra careful with claude -p, which treats the folder as trusted and runs committed hooks.

Conclusion

Claude Code hooks turn the rules you care about into scripts that run every time, before or after Claude acts. Put them in a settings file, use PreToolUse with exit code 2 to block, PostToolUse to log or format, and SessionStart to hand Claude fresh context, then check your work with /hooks and Ctrl+O. Test each script by hand, remember that Bash can change files too, and pair hooks with deny rules for real protection. To learn more about the assistant behind all of this, see our Claude AI overview.

Previous in the series: Part 29, Claude Code Subagents: Build Your Own Specialist Helpers

Next in the series: Part 31: MCP in Claude Code: Connect Claude to Your Tools.

Newsletter

New AI tools in your inbox

The best new tools and the AI news that matters, in one short email. Unsubscribe any time.

Free · No spam · Unsubscribe any time

✓ Looks like you’re subscribed

Loading Google’s sign-up form…

Looks like you’re already subscribed

Thanks for reading! New AI tools and the AI news that matters will land in your inbox.

Not subscribed yet? Refresh the page to load the sign-up form.

The sign-up form couldn’t load. If you use an ad or content blocker, allow news.google.com and reload the page.

Free instant check before you pay. Takes a few seconds.

Scroll to Top