knack
← all posts

Claude Code Hooks: What They Are, How to Wire Them Up, and Examples That Work

Hooks fire your own code at lifecycle events like PreToolUse and PostToolUse. The event list, the config shape, real example hooks, and how to debug them.

Claude Code hooks are shell commands the runtime fires at fixed points in a session: before a tool runs, after an edit lands, when the agent stops. They run whether or not the model "remembers" to, and that is the whole reason they exist. Say you put "always run prettier after editing" in CLAUDE.md. The model will comply maybe 80% of the time. Wire that same instruction into a PostToolUse hook and it runs on every edit, because the runtime executes it rather than the LLM. The official description calls this "deterministic control over Claude Code's behavior," and I think that framing is exactly right. A prompt instruction is a suggestion. A hook is a guarantee.

What follows: the lifecycle events, the exact JSON shape the config takes, how your script reads input and returns a decision, and three example hooks I'd actually keep running. Then the two things that trip people up, which are debugging and precedence. If you came here mid-migration, the Cursor to Claude Code migration guide touches hooks in passing as a replacement for Cursor rules. This is the full version.

What a hook actually is

A hook is an entry in a settings file that says: when event X fires, and the matcher matches, run this command. When the event fires, Claude Code sends a JSON payload to your command on stdin. Your command does its work and reports back through its exit code and, optionally, a JSON object on stdout. Exit 0 means no objection. Exit 2 means block, and your stderr becomes the feedback Claude reads. If you want finer control than that, print structured JSON instead.

There is no SDK here and no plugin runtime to learn, which is the nice part. Anything you can run from a shell can be a hook: a bash script, a Python file, even a single jq pipe. The bash command validator in the Anthropic repo is a 40-line Python file, and that's a representative size.

The lifecycle events

There are a lot of events. The hooks reference lists the full set, and most of it you'll never touch. The ones you'll actually use sort into three groups by how often they fire.

Once per session: SessionStart fires when a session begins or resumes, SessionEnd when it terminates. Once per turn: UserPromptSubmit runs before Claude sees your prompt, Stop runs when Claude finishes responding, and StopFailure fires if the turn died on an API error instead. The busy ones live inside the agentic loop and fire on every tool call: PreToolUse runs before a tool executes and can block it, PostToolUse runs after the tool succeeds, PostToolUseFailure after it fails, and PermissionRequest fires when a permission dialog would appear.

Beyond those, the docs list PreCompact and PostCompact around context compaction, SubagentStart and SubagentStop for subagents, ConfigChange when a settings or skills file changes, CwdChanged when the working directory moves, FileChanged for watched files, and a dozen more covering worktrees, MCP elicitation, and notifications. Start with the seven in the previous paragraph. Reach for the rest when you have a specific reason.

Here's a detail that bites people early. Most events filter on a field via the matcher, but the field differs by event. For the tool events it's the tool name (Bash, Edit|Write, mcp__github__.*). For SessionStart it's how the session started (startup, resume, clear, compact). For Notification it's the notification type. Get the matcher target wrong and your hook silently never fires, which is the single most common failure mode.

The config shape

Hooks live under a single hooks key in a settings file. The nesting goes three deep. The event name maps to an array of groups, each group has a matcher and a list of hooks, and each hook in turn has a type and a command. Here is the minimal canonical form, an auto-formatter:

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

Read it from the inside out. type: "command" is the default and runs a shell command (the other types, http, prompt, agent, and mcp_tool, exist, but you can ignore them until you need a model to make a judgment call). The matcher of Edit|Write means this only runs after the Edit or Write tools, never after Bash or Read. Watch two things there: no space around the pipe, and matchers are case-sensitive. The command itself reads the hook's JSON on stdin, pulls the edited file path with jq, then pipes it to prettier.

If your settings file already has a hooks block, add new events as siblings rather than replacing the object. Each event name is one key inside the single hooks object.

Reading input, returning a decision

When an event fires, Claude Code writes JSON to your script's stdin. A PreToolUse hook on a Bash command receives roughly this:

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

Every event carries the common fields (session_id, cwd, hook_event_name), and each event type then adds its own. Tool events add tool_name and tool_input. UserPromptSubmit adds the prompt text. SessionStart adds the source. Your script parses out whatever it needs and ignores the rest.

You report back through two channels. The exit code is the blunt one. Exit 0 and the action proceeds (for PreToolUse this does not approve the call, the normal permission flow still runs; for SessionStart and UserPromptSubmit, anything you print to stdout gets injected into Claude's context). Exit 2 blocks the action and sends your stderr back to Claude as feedback. Any other exit code counts as a non-blocking error, so the action proceeds and the transcript shows a hook-error notice.

When you need more than block-or-allow, exit 0 and print a JSON object to stdout. A PreToolUse hook denying a command looks like this:

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

PreToolUse is the one event that puts its decision under hookSpecificOutput.permissionDecision, and it gets four values the others don't: allow, deny, ask, and defer. The rest of the events use a simpler top-level decision: "block" with a reason. The docs are firm about one rule here, so don't mix the two channels. If you exit 2, Claude Code ignores any JSON you printed.

Three hooks worth keeping

Audit log on PostToolUse. This is the cheapest useful hook you can write. Append every Bash command to a file so you have a record of what the agent ran:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
          }
        ]
      }
    ]
  }
}

One caveat the docs flag: Claude can also create files by shelling out through Bash, so a PostToolUse matcher on Edit|Write alone won't catch everything. For true audit coverage, match Bash too, or run a Stop hook that scans the working tree once per turn.

Block edits on PreToolUse. Keep the agent out of .env, lockfiles, and .git/. This one gets its own script file so the logic stays readable. Save it to .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, then register it:

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

The exit-2 stderr is what Claude reads, so write it for the model. "Blocked, edit the staging config instead" beats a bare error.

Format on save with PostToolUse. The first config block in this article already does this. Why it belongs in a hook rather than in CLAUDE.md: formatting is a hard rule with no judgment in it, and a hard rule wants the runtime to enforce it every time rather than the model to remember it most of the time.

Debugging when a hook misbehaves

The failure you'll hit most is a hook that never fires at all. Run /hooks to open the menu and confirm the hook shows up under the right event with the right matcher. Most of the time the matcher is the culprit: a typo, the wrong case, or filtering on the wrong field (SessionStart matches on startup/resume, not on a tool name).

When a hook fires but errors out, test it directly by piping in sample JSON, which is exactly what the docs recommend:

echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?

A "command not found" usually means a relative path, so use an absolute path or ${CLAUDE_PROJECT_DIR}. A "jq: command not found" means you need to install jq or parse the JSON in Python instead. There's also a genuinely sneaky one. If your shell profile prints anything unconditionally (say a "Shell ready" banner in .bashrc), that text gets prepended to your hook's stdout and Claude Code's JSON parse fails. Guard any profile output behind an interactive-shell check. For full detail, including which hooks matched and their exit codes, launch with claude --debug-file /tmp/claude.log and tail it, or run /debug mid-session.

Precedence, and the thing about deny

Two precedence questions come up. The first is where hooks live. A hook in ~/.claude/settings.json applies to all your projects. One in .claude/settings.json applies to a single project and can be committed for the team. One in .claude/settings.local.json is project-scoped but gitignored. Managed policy settings sit above all of those and let an admin force organization-wide hooks. Project settings override user settings on conflict, but they all merge at runtime rather than one wholesale replacing another.

The second question is what happens when several hooks match the same event. They all run, in parallel, to completion. One hook returning deny does not stop its siblings from executing, so don't lean on a deny to suppress another hook's side effect. After they finish, Claude Code merges the results, and for PreToolUse the most restrictive answer wins: deny beats ask beats allow.

The sharpest rule here is the one about permission modes. A PreToolUse hook fires before any permission-mode check, so a hook returning deny blocks the tool even under bypassPermissions or --dangerously-skip-permissions. That makes hooks the right tool for policy a user genuinely cannot bypass. The reverse does not hold. A hook returning allow does not override deny rules from settings, because a hook can only tighten what's permitted, never loosen it.

Hooks are the deterministic half of a good agent setup. The other half is the agent's actual procedures, and those live better in a skill than in a hook, because a skill carries judgment a shell script can't. So when you want a repeatable workflow your agent reaches for by name, rather than a guardrail it can't cross, what you want is a SKILL.md file. Knack builds one from a 20-minute interview without you writing the YAML.

My advice is to start with one hook. Make it the audit log, watch it catch something you didn't expect, and add the next one when a prompt instruction fails you for the third time.