Journal
Tutorials

Claude Code Hooks: The Complete Guide (with Examples)

C

Clearly Team

Engineering

8 min read
Jul 10, 2026

Claude Code Hooks: The Complete Guide (with Examples)

Hooks are how you make Claude Code do your thing automatically at the right moment — run a formatter after every edit, block a dangerous command before it runs, ping you when a turn finishes. A hook is just a shell command Claude Code runs when a lifecycle event fires, with the event data piped in on stdin. Here's the whole model, with copy-paste examples.

What a hook is

A hook is a command wired to an event. When the event fires, Claude Code runs your command, pipes a JSON payload to its stdin, and reads its exit code (and optionally JSON from stdout) to decide what happens next. Hooks live in settings.json (user, project, or local), so they're deterministic automation that doesn't depend on the model remembering to do anything.

The events you'll actually use

Claude Code fires hooks on a lot of events; the ones that matter day to day:

  • SessionStart — a session begins or resumes.
  • UserPromptSubmit — you submit a prompt (can inject context, or block it).
  • PreToolUse — before a tool runs (can block it — e.g. deny a dangerous command).
  • PostToolUse — after a tool succeeds (e.g. format the file that was just edited).
  • Stop — Claude finishes responding (can notify you, or block stopping to keep it working).
  • Notification — Claude needs your attention (a permission prompt, or idle-waiting for input).
  • SubagentStop — a subagent finishes. PreCompact / SessionEnd — before compaction / at session end.

(There are more — PostToolUseFailure, PermissionRequest, and others — but those cover almost everything.)

Configuring a hook

Hooks go in settings.json under hooks, keyed by event. Tool events (PreToolUse / PostToolUse) take a matcher (the tool name or a regex); other events don't. Here's "format the file that was just edited":

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

Matchers are case-sensitive (Bash, not bash) and accept regex (mcp__.*).

What your hook receives (stdin)

Every hook gets a JSON object on stdin. The common fields:

{
  "session_id": "abc123",
  "transcript_path": "/.../transcript.jsonl",
  "cwd": "/home/you/my-project",
  "hook_event_name": "PreToolUse"
}

Tool events add tool_name and tool_input (e.g. { "command": "npm test" } for Bash, { "file_path": "..." } for Edit). Stop adds last_assistant_message and stop_hook_active. Notification adds message and notification_type.

How your hook responds (exit codes + JSON)

Two ways to talk back:

  • Exit code 0 — success. If you print JSON to stdout, Claude Code parses it for a decision or extra context.
  • Exit code 2block the action and show your stderr to Claude. This only blocks on events that can be blocked: PreToolUse, UserPromptSubmit, Stop (and a few others). On PostToolUse the tool already ran, so exit 2 just surfaces a message.
  • Any other code — non-blocking error; the first line of stderr goes to the transcript.

The richer path is JSON on stdout. A PreToolUse hook can deny a tool outright:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Blocked: rm -rf on a protected path"
  }
}

A UserPromptSubmit or Stop hook can inject text into Claude's context with additionalContext.

Example: block a dangerous command

A PreToolUse hook on Bash that refuses rm -rf / — exit 2 blocks the tool and the reason goes to Claude:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$CMD" | grep -qiE 'rm -rf /'; then
  echo "Refusing: destructive rm" >&2
  exit 2
fi
exit 0

Example: don't let it stop until tests pass

A Stop hook that blocks stopping while tests fail — with the critical loop guard (stop_hook_active) so it can't get stuck:

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then exit 0; fi
if npm test >/dev/null 2>&1; then
  exit 0
else
  echo '{"decision":"block","reason":"Tests are failing — fix them before finishing."}'
  exit 0
fi

Without the stop_hook_active check, a blocking Stop hook can loop; Claude Code overrides it after several consecutive blocks, but you should guard it yourself.

Bundling hooks as a plugin

For hooks you want to reuse across machines or share, wrap them in a plugin: a .claude-plugin/plugin.json manifest plus a hooks/hooks.json, referencing your scripts with ${CLAUDE_PLUGIN_ROOT}. Then install it from a marketplace with /plugin install name@marketplace instead of hand-editing settings.json on every machine.

A hook you'll actually keep

The single most useful starter hook: get pinged when Claude finishes or needs you, so you can tab away. We packaged exactly that as a free, standalone plugin — see Get a desktop notification when Claude Code finishes. And when you're running several sessions at once, Mwah shows them all as robots on your desktop, flagged the moment one goes stuck.


Related: Get notified when Claude Code finishes · Run multiple Claude Code sessions

#claude code#hooks#automation#developer workflow#cli