Claude Code hooks: a practical guide with examples

How Claude Code hooks work: events, settings.json, matchers, exit codes and JSON output, plus ready hooks that format files, block risky commands and notify you.

Solviera Teknoloji 10 min read

Claude Code hooks are commands that Claude Code runs on its own at fixed points in a session: before a tool call, after a file edit, when a prompt is sent, when Claude stops or needs your attention. You define them under the hooks key in settings.json. A hook can add context, format a file, or block an action by exiting with code 2 or returning a JSON decision. Unlike instructions in CLAUDE.md, a hook always runs, because the harness runs it, not the model.

This guide covers the events that matter, the config format, matchers, exit codes and JSON output, three hooks you can copy today, and the security notes to read before you add any. The facts come from the official hooks reference and hooks guide. If you only want the short definition, see the Claude Code hooks glossary entry.

Why use hooks instead of instructions

You can write “always run Prettier after editing” in CLAUDE.md, and Claude will usually do it. Usually is the problem. Instructions are advice the model weighs against everything else in its context. Hooks are code that runs every time the event fires.

That makes hooks the right tool for three kinds of rules:

  • Things that must always happen, like formatting, linting or logging.
  • Things that must never happen, like editing .env or running rm -rf on the wrong folder.
  • Things you want to know about, like Claude waiting for a permission answer while you are in another window.

Keep style advice and project knowledge in CLAUDE.md and put hard rules in hooks.

Hook events you will actually use

The reference lists more than thirty events, and the list grows with new releases. Most real setups use a handful:

EventWhen it firesCan it block?
PreToolUseBefore a tool call runsYes, blocks the call
PostToolUseAfter a tool call succeedsNo, the tool already ran; stderr goes to Claude
UserPromptSubmitWhen you send a promptYes, the prompt never reaches Claude
NotificationWhen Claude Code sends a notification, such as a permission prompt or idle waitNo
StopWhen Claude finishes its answerYes, Claude keeps working
SubagentStart / SubagentStopWhen a subagent starts or finishesOnly SubagentStop
SessionStartOn startup, resume, /clear or compactionNo, but stdout becomes context
PreCompactBefore the context is compactedYes, blocks compaction
PermissionRequestWhen a permission dialog is about to showThrough JSON only, not exit 2
SessionEndWhen the session endsNo

PreToolUse and PostToolUse cover most needs. Notification and Stop are the ones for “tell me when it is done or stuck”.

How hooks are configured in settings.json

Hooks live in the same settings files as permissions:

FileScopeShared through git?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes
.claude/settings.local.jsonOne project, only youNo (gitignored)
Managed policy settingsWhole organizationSet by admins

Plugins can ship hooks in hooks/hooks.json, and skills and subagents can define hooks in their frontmatter.

The structure has three levels: the event, a matcher group, and a list of handlers.

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

A handler’s type can be command (a shell command), http (a POST to a URL), mcp_tool (a tool on an MCP server you have connected), prompt (a quick yes/no check by a model) or agent (an experimental subagent check). The default timeout is 600 seconds for command, HTTP and MCP tool handlers, 30 seconds for prompt hooks and 60 for agent hooks.

Every hook gets JSON on stdin. Common fields are session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name, tool_input and tool_use_id. Your script reads that JSON, usually with jq, and decides what to do. The environment variable CLAUDE_PROJECT_DIR points to the project root, which is handy for referencing scripts stored in the repo.

You can type /hooks inside Claude Code to browse what is loaded and which file each hook came from. The browser is read only, so you change hooks by editing the JSON.

Matchers: which tool calls a hook sees

The matcher decides which calls trigger a hook. For tool events it matches the tool name:

MatcherMeaning
"*", "" or no matcherEvery call
"Bash"Exactly the Bash tool
"Edit|Write"Edit or Write (a list)
"mcp__memory__.*"Every tool from the memory MCP server
"mcp__.*__write.*"Any MCP tool whose name starts with write

The rule is simple. If the matcher has only letters, digits, _, -, spaces, commas or |, it is an exact name or a list of names. Any other character turns it into a JavaScript regular expression. MCP tools are named mcp__<server>__<tool>.

Some events ignore the matcher, including UserPromptSubmit and Stop, because there is nothing to match. Others match something other than a tool name. SessionStart matches how the session started (startup, resume, clear, compact), and Notification matches the notification type, such as permission_prompt or idle_prompt.

Tool events also accept an if field with permission rule syntax, so a Bash hook can run only for, say, "Bash(git *)". The docs describe this as a best-effort filter, so keep the real check in your script.

Exit codes and JSON output

A command hook talks back to Claude Code in two ways: the exit code, and optional JSON on stdout.

Exit codeWhat happens
0Success. stdout is parsed as JSON if it is a single JSON object. For UserPromptSubmit and SessionStart, plain text on stdout is added to Claude’s context.
2Blocking error. stderr is the reason. On PreToolUse the call is cancelled, on UserPromptSubmit the prompt is rejected, on Stop Claude keeps going.
Anything else, including 1Non-blocking error. Claude Code notes it and carries on.

The trap here is exit code 1. Many scripts fail with 1 by default, and that does not block anything. If you mean “stop”, exit 2.

For finer control, exit 0 and print JSON. A PreToolUse hook can allow, deny or ask:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use rg instead of grep for better performance"
  }
}

permissionDecision takes allow, deny, ask or defer. A hook can also rewrite the call with updatedInput, or pass extra text to Claude with additionalContext. Fields that work on any event include continue: false (stop Claude entirely, with a stopReason) and systemMessage (a warning shown to you).

When several hooks match, they run in parallel, and the most restrictive answer wins: deny beats defer, defer beats ask, ask beats allow. An allow from a hook does not override a deny rule in your permissions.

Example 1: format files after every edit

This is the hook most people start with. It runs Prettier on any file Claude writes or edits:

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

Swap in ruff format, gofmt -w or rustfmt for other languages. Since the formatter runs after the edit, Claude sees the formatted file next time it reads it, and you stop seeing style-only diffs in review.

Example 2: block dangerous commands and protected files

A PreToolUse hook is the place for hard limits. This script, adapted from the official guide, blocks edits to sensitive files. Save it as .claude/hooks/protect-files.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Make it executable with chmod +x .claude/hooks/protect-files.sh, then register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

The same pattern works for shell commands. Match Bash, read .tool_input.command, and exit 2 when it contains something you never want, such as rm -rf /, git push --force or DROP TABLE. Keep in mind that a string check is easy to get around, so treat it as a seatbelt, not a lock.

Example 3: get a desktop notification

Long tasks stop and wait for you more often than you’d like. A Notification hook tells you right away. On macOS:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

On Linux, use notify-send 'Claude Code' 'Claude Code needs your attention' instead. Set the matcher to permission_prompt if you only care about permission requests, or add a Stop hook with the same command to hear when Claude finishes.

More useful patterns

  • Re-inject context after compaction. A SessionStart hook with matcher compact can echo a short reminder of your rules. Its stdout is added to the fresh context.
  • Audit config changes. A ConfigChange hook can append each settings change to a log file.
  • Quality gate on Stop. A Stop hook can run your tests and exit 2 with the failures, so Claude keeps fixing. Check the stop_hook_active field in the input to avoid an endless loop. Claude Code also overrides a Stop hook after several blocks in a row.
  • Auto-approve one safe prompt. A PermissionRequest hook can answer a specific dialog with {"behavior": "allow"} inside its decision object. Keep the matcher narrow.

Security notes before you add hooks

Hooks run as shell commands with your user’s permissions, without asking. That is the whole point, and also the risk.

  • Read hooks before you trust a repo. A project’s .claude/settings.json can ship hooks. Review them the way you would review a postinstall script.
  • Quote your variables. Hook input comes from the model, and the model reads files and web pages that may contain prompt injection. Use "$VAR", never eval input, and prefer jq over hand-rolled parsing.
  • Use absolute or project-relative paths with $CLAUDE_PROJECT_DIR, so a hook does not depend on the current folder.
  • Keep hooks fast. A slow PreToolUse hook slows every tool call. Set a timeout.
  • Hooks are not a sandbox. A deny hook blocks calls even under --dangerously-skip-permissions, which is useful, but a determined or confused agent can reach the same result another way. Combine hooks with deny rules and isolation, as covered in AI coding agent security and skipping permissions safely.
  • Know the off switches. "disableAllHooks": true turns hooks off, and admins can set allowManagedHooksOnly in managed settings to block user and project hooks.

When a hook misbehaves, start Claude Code with claude --debug, or write the log to a file with --debug-file /tmp/claude.log, to see each hook’s matcher, exit code and output.

How AgentVera uses hooks

AgentVera is a desktop app that runs Claude Code, Codex, Gemini CLI and other coding CLIs side by side. Hooks are a big part of how it works with Claude Code.

  • Your hooks stay yours. AgentVera passes its own hooks to each Claude Code process with the --settings flag. Claude Code merges them with the hooks in your settings files, and your settings.json is not edited.
  • Agent status comes from hooks. The status each agent shows in the app is read from the CLI’s own hook events, not guessed from the terminal text.
  • Smart tools are hooks. The smart tools feature adds four hooks to local Claude Code agents: it blocks repeated reads of the same file, runs only the tests an edit affects, checks for errors right after an edit, and shares results between agents. They are on by default and you can turn them off under Settings → “Token saving”.
  • No notify script needed. Notifications and approvals gather every agent waiting for approval in one queue, send desktop notifications while the app is in the background, and can ping your phone through Telegram.

Qwen Code gets AgentVera’s hooks as well, because its hook system follows Claude Code’s events and JSON format.

Wrapping up

Start with one PostToolUse formatter and one PreToolUse guard, check them with /hooks and --debug, and add more only when a rule keeps getting broken. Exit 2 to block, print JSON when you need more control, and treat every hook as code that runs with your permissions.

Read more:

To try AgentVera, start at the home page.

Questions

What are Claude Code hooks?

Hooks are shell commands, HTTP calls, MCP tools or prompts that Claude Code runs on its own at set points in a session, such as before a tool call, after a file edit, or when Claude needs your attention. You define them in settings.json.

Where do I put Claude Code hooks?

In the hooks key of a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for a project you share through git, or .claude/settings.local.json for a project only on your machine. Plugins, skills and subagents can define hooks too.

How does a hook block a tool call?

A PreToolUse hook either exits with code 2 and writes the reason to stderr, or exits 0 and prints JSON with permissionDecision set to deny. Claude Code cancels the call and shows the reason to Claude. Exit code 1 does not block.

Do hooks still run with --dangerously-skip-permissions?

Yes. A hook that returns deny blocks the tool call even in bypassPermissions mode. That makes a PreToolUse hook a useful last guard for commands you never want, though it is not a sandbox.

How do I debug a Claude Code hook that does not fire?

Open /hooks to see which hooks are loaded and where they come from, check that the matcher matches the exact tool name, and start Claude Code with --debug or --debug-file to see each hook run, its exit code and its output.

Bring your agents to one desk.

Download AgentVera for free; your installed CLIs are ready to go.

More posts