Claude Code Hooks: Events, Exit Codes, JSON Output and 7 Recipes You Can Copy

Updated 2026-10-15 · by Murilo Prataviera, maker of Aki

You told Claude three times not to touch .env. It's in CLAUDE.md too. On the fourth task it edits .env anyway, because instructions are suggestions to a model. A hook is not. It's a command that Claude Code runs at a fixed point, every time, and it can say no. This guide covers how hooks work today and gives you seven recipes you can paste and test.

Quick answer

  • Hooks are commands Claude Code runs at fixed moments: before a tool runs (PreToolUse), after it runs (PostToolUse), when Claude needs you (Notification), when it finishes (Stop), when a session starts (SessionStart) and about 30 other events.
  • They live in settings files: ~/.claude/settings.json (you), .claude/settings.json (project, shared), .claude/settings.local.json (project, private), managed policy, plugins, and skill or subagent frontmatter.

  • Input is JSON on stdin. Output is the exit code, stdout and stderr.

  • Exit 0 = no objection. Exit 2 = block, and stderr is the reason. Other codes are non-blocking errors.

  • For finer control, exit 0 and print JSON, such as permissionDecision: "deny" on PreToolUse or decision: "block" on Stop.

  • /hooks shows every hook that's configured and where it came from.

  • Hooks run with your user's permissions. Read them before you add them.

What hooks are

A hook is a handler attached to a lifecycle event. When the event fires, Claude Code runs every matching hook in parallel, waits for them, and merges the results. Most hooks are shell commands ("type": "command"). There are four other types:

The point is determinism. A formatter you put in CLAUDE.md runs when the model remembers. A formatter in a PostToolUse hook runs after every edit.

Where hooks live

Location Scope Shared?
~/.claude/settings.json All your projects No
.claude/settings.json One project Yes, commit it
.claude/settings.local.json One project No, gitignored
Managed policy settings Whole organization Admin-controlled
Plugin hooks/hooks.json While the plugin is enabled Yes
Skill frontmatter Rest of the session after the skill runs Yes
Subagent frontmatter While that subagent runs Yes

Hooks from different levels add up. They don't replace each other. To turn them all off without deleting anything, set "disableAllHooks": true.

The shape is always the same: hooks → event name → list of matcher groups → each group has a matcher and a hooks list.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check.sh", "timeout": 30 }
        ]
      }
    ]
  }
}

If you edit the file while a session is running, Claude Code normally picks up the change by itself.

The events

These are the ones you'll use most:

Event Fires Matcher filters on Can block?
SessionStart Session starts or resumes startup, resume, clear, compact, fork No
UserPromptSubmit You send a prompt, before Claude sees it (none) Yes
PreToolUse Before a tool call Tool name Yes
PermissionRequest Claude Code is about to ask you for permission Tool name Via JSON
PostToolUse After a tool call succeeds Tool name Feedback only
Notification Claude needs you, and other alerts permission_prompt, idle_prompt, agent_needs_input… No
Stop Claude finishes responding (none) Yes, keeps it working
SubagentStop A subagent finishes Agent type Yes
PreCompact / PostCompact Around compaction manual, auto Pre only
SessionEnd Session ends clear, resume, logout, prompt_input_exit, other No

The full list today also has Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, MessageDisplay, SubagentStart, TaskCreated, TaskCompleted, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, PreModelSwitch, PostModelSwitch, Elicitation and ElicitationResult. New ones show up often, so check the reference when you need something unusual.

Matchers and the if field

For tool events, the if field narrows by arguments too, using permission-rule syntax: "if": "Bash(git *)" runs the hook only when a subcommand is git. It's best-effort, so the docs tell you to use permission rules, not hooks, for a hard allow or deny.

Input: JSON on stdin

Every hook gets common fields: session_id, transcript_path, cwd, hook_event_name, permission_mode, and a few more. Each event adds its own. A PreToolUse for a Bash call looks like this:

{
  "session_id": "abc123",
  "cwd": "/Users/sarah/myproject",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" }
}

Other events you'll meet in the recipes: PostToolUse adds tool_response, Notification has message, title and notification_type, Stop has stop_hook_active and last_assistant_message, UserPromptSubmit has prompt, SessionStart has source.

Output: exit codes

Output: JSON

Exit 0 and print one JSON object for finer control. Use either exit 2 or JSON in a hook, not both.

Two traps. A "deny" from a hook blocks even in bypassPermissions mode, but an "allow" never overrides your deny rules. And if your shell profile prints anything, your JSON no longer starts with { and is read as plain text.

The /hooks command

Type /hooks in Claude Code. It's a read-only browser: each hook is labeled with its source (user, project, local, plugin, session), and selecting one shows the full command and the file that defines it. If a hook doesn't seem to fire, start there. Then claude --debug-file /tmp/claude.log and tail -f the log.

7 recipes

All Bash recipes use jq (brew install jq). Scripts go in .claude/hooks/ and need chmod +x. Test any script before wiring it up:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | .claude/hooks/block-rm-rf.sh; echo "exit: $?"

1. macOS notification that says which project needs you

The parallel sessions guide has the one-line version. This one uses the event's own message and the folder name, so with five sessions you know which one is asking. Save as ~/.claude/hooks/notify.sh:

#!/bin/bash
INPUT=$(cat)
MSG=$(echo "$INPUT" | jq -r '.message // "Claude Code needs you"')
PROJECT=$(basename "$(echo "$INPUT" | jq -r '.cwd')")
osascript -e 'on run argv' \
  -e 'display notification (item 1 of argv) with title (item 2 of argv) sound name "Glass"' \
  -e 'end run' "$MSG" "Claude Code · $PROJECT"
{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/notify.sh" }]
      }
    ]
  }
}

Passing the text as argv avoids quoting bugs when the message has quotes in it. If nothing appears, run osascript -e 'display notification "test"' once and allow Script Editor in System Settings → Notifications.

2. Run Prettier after every edit

Project .claude/settings.json:

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

--ignore-unknown keeps Prettier quiet on files it can't format. Files Claude changes through Bash won't pass through this hook. For those, use a FileChanged hook.

3. Block edits to .env and other protected files

.claude/hooks/protect-files.sh:

#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
for pattern in ".env" ".git/" "package-lock.json"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH is protected ($pattern). Ask the user to change it." >&2
    exit 2
  fi
done
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }]
      }
    ]
  }
}

Claude reads the stderr and changes course. This only guards the edit tools. Add a Read(./.env) / Edit(./.env) deny rule in permissions if you also want reads blocked.

4. Block rm -rf, with a JSON reason

This one uses JSON instead of exit 2. .claude/hooks/block-rm-rf.sh:

#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eiq '(^|[;&|[:space:]])rm[[:space:]]+-[a-zA-Z]*(rf|fr)'; then
  jq -n --arg cmd "$CMD" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: ("rm -rf is blocked by a hook: " + $cmd + ". Delete specific files instead.")
    }
  }'
fi
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" }]
      }
    ]
  }
}

A regex won't catch every variant (rm -r -f, a script that deletes). Treat it as a seatbelt and keep real deny rules in permissions.

5. Log every shell command

~/.claude/settings.json, one JSON line per command:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "jq -c '{time: (now | todate), session: .session_id, cwd: .cwd, cmd: .tool_input.command}' >> ~/.claude/bash-log.jsonl" }
        ]
      }
    ]
  }
}

tail -f ~/.claude/bash-log.jsonl shows what every session is running. Use PreToolUse instead if you also want commands that were then blocked.

6. Don't let Claude stop while tests fail

.claude/hooks/tests-on-stop.sh:

#!/bin/bash
INPUT=$(cat)
# Already continuing because of this hook? Let it stop.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! OUTPUT=$(npm test 2>&1); then
  jq -n --arg out "$(echo "$OUTPUT" | tail -n 30)" \
    '{decision: "block", reason: ("Tests are failing. Fix them before finishing:\n" + $out)}'
fi
exit 0
{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-on-stop.sh", "timeout": 300 }] }
    ]
  }
}

Stop fires after every response, not only at the end of a task, so keep the test command fast. The stop_hook_active check matters: Claude Code overrides a Stop hook after eight blocks in a row anyway, but you don't want to get there.

7. Give Claude the repo state at session start

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|compact",
        "hooks": [
          { "type": "command", "command": "echo \"Branch: $(git branch --show-current)\"; echo 'Uncommitted:'; git status --short | head -20; echo 'Recent commits:'; git log --oneline -5" }
        ]
      }
    ]
  }
}

Plain stdout from SessionStart goes straight into Claude's context. The compact matcher re-injects it after compaction, when details tend to get lost. For static rules, CLAUDE.md is still the right place.

Security: hooks run as you

The reference is blunt: command hooks run with your full user permissions and can read, change or delete anything your account can. Practical rules:

Hooks in Codex

Codex has hooks now, with a very similar format. They're on by default (turn them off with hooks = false under [features] in config.toml). They live in ~/.codex/hooks.json or config.toml, the same files under <repo>/.codex/, managed requirements.toml, and plugins. Events: SessionStart, SessionEnd, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, UserPromptSubmit, SubagentStart, SubagentStop, Stop and Interrupt.

Differences worth knowing: new or changed hooks must be reviewed and trusted in /hooks before they run (trust is tied to a hash of the hook). PreToolUse doesn't support ask yet. Prompt and agent handlers are parsed but skipped. Exit 2 blocks on PreToolUse, UserPromptSubmit and Stop, as in Claude Code.

Where Aki fits

The most common hook is "tell me when a session needs me". If you'd rather see it than get a notification, Aki shows it without any hook. It's a free, source-available macOS app (>github.com/muriloprataviera/aki) with a sidebar on the edge of the screen: one ring per Claude Code or Codex session, showing its state, context use and pending marks, and a ✓ when the agent resolved everything. You see which session is waiting at a glance.

Its main job is the other direction. You point at something on screen (an element in Chrome, an area, a terminal error), write what should change, and Aki sends it to the right session. A page on localhost:3000 goes to the worktree running port 3000. Mac only (Apple Silicon, macOS 14+), no account. Hooks and Aki work fine together.

FAQ

What are Claude Code hooks?

Commands, HTTP calls, MCP tool calls or model checks that Claude Code runs automatically at fixed points, such as before a tool runs, after a file edit, or when Claude finishes. They can block actions and add context.

Where do I put Claude Code hooks?

In a settings file: ~/.claude/settings.json for all projects, .claude/settings.json for a shared project config, or .claude/settings.local.json for a private one. Plugins, skills and subagents can also define them.

What does exit code 2 do in a hook?

It blocks the action and uses stderr as the reason. On PreToolUse the tool call is cancelled, on Stop Claude keeps working, on UserPromptSubmit the prompt is blocked. Exit 1 does not block.

How do I see which hooks are active?

Type /hooks in Claude Code. It lists every configured hook with its source and the full command.

Why isn't my hook firing?

Check /hooks first. Then check that the matcher matches the tool name exactly (it's case-sensitive), that the script is executable, and that your JSON has no trailing commas. Run with claude --debug-file /tmp/claude.log to see each run.

Can a hook approve a tool call automatically?

Yes. permissionDecision: "allow" on PreToolUse or decision.behavior: "allow" on PermissionRequest. Deny rules in your settings still win.

Does Codex support hooks?

Yes. Codex reads hooks.json or config.toml with a similar event list, and asks you to trust new hooks in /hooks first.

Sources