Free Guide · Claude Code hooks

Claude Code Hooks

Claude decides what it wants to do. Hooks decide what is allowed to happen around it.

You “Fix the login bug”
Claude decides to edit .env
PreToolUse hook “Is this allowed?”
Denied Claude is told .env is protected, and works around it

That is the entire concept. Everything below is detail on how each box works.

01 · The moment you need them

You ask Claude to fix a login bug. It reads the code, forms a theory, and decides the fastest fix is to change a value in .env. That is a reasonable engineering instinct. It is also the one file you never want an agent to touch, because it holds production credentials and it is not in version control, so a bad edit is not recoverable with git checkout.

You could write “never edit .env” in CLAUDE.md. That usually works. But it is an instruction competing with every other instruction in context, and it is advisory: nothing enforces it.

Instructions tell Claude what it should do. Hooks let your system react to what Claude actually does.

One is a request. The other is your code, running on your machine, at a fixed point in the agent's loop — with the power to say no.

02 · The analogy that makes it click

Think of Claude as a competent new employee in your office building, and hooks as the building's security system.

Claude

The employee. Capable, well-intentioned, allowed to move around and get work done.

the agent
Tools

The doors. Reading a file, writing a file, running a command — each one is a door into a room.

Read · Edit · Write · Bash
Hooks

Security and sensors. They sit at the doors, not inside the employee's head.

your scripts

From there the three hooks you will use most explain themselves:

03 · Where hooks sit in a run

A Claude Code session is a loop, and hook events are named points along it. Here is the path a single tool call takes:

Scroll the row sideways if it is cut off. The important shape: PreToolUse is the only point that can stop an action before it happens. Everything after it is reacting to something that already ran.

Show the full lifecycle (33 events)

You do not need these to start, and memorising them is not the point. They exist so that almost anything the agent does can be observed or gated — subagents, tasks, compaction, model switches, config changes and worktrees all have their own events.

SessionStartSetupUserPromptSubmitUserPromptExpansionPreToolUsePermissionRequestPermissionDeniedPostToolUsePostToolUseFailurePostToolBatchNotificationMessageDisplaySubagentStartSubagentStopTaskCreatedTaskCompletedStopStopFailureTeammateIdleInstructionsLoadedConfigChangeCwdChangedDirectoryAddedFileChangedWorktreeCreateWorktreeRemovePreCompactPostCompactPreModelSwitchPostModelSwitchElicitationElicitationResultSessionEnd

04 · The anatomy of a hook

Every hook is the same three decisions, no matter how complex it gets:

HOOK = EVENT + MATCHER + ACTION

When should this fire · which calls do I care about · what do I run.

The event

The point in the loop where your code runs. PreToolUse means “before the tool executes”, which is the only place a decision can still change the outcome.

The matcher

A regular expression against the tool name, so the hook only wakes for calls it cares about. Edit|Write means file-writing tools and nothing else — a Read or a Bash call never reaches this script.

The handler

A command you own. Claude Code runs it and writes a JSON payload to its stdin, describing the call: tool_name, tool_input, cwd, session_id and more.

The result

Your script answers on stdout. Returning permissionDecision: "deny" blocks the call and hands your reason back to Claude as feedback. Printing nothing means “no opinion” and the normal permission flow continues.

Written out, that is this — and this shape is every hook you will ever write:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.mjs"
          }
        ]
      }
    ]
  }
}

05 · Your first hook: format on every edit

The smallest useful hook does not block anything. It runs Prettier over whatever file Claude just wrote, so formatting stops being something either of you thinks about.

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

The command reads the same JSON payload on stdin, pulls one field out of it with jq, and passes it to Prettier.

Why PostToolUse and not PreToolUse?

Because formatting is something you do to a file that exists. Before the tool runs, the change has not happened yet — there is nothing on disk to format. Pick the event by asking whether you need to influence the action or react to it.

06 · The one that says no

Now the .env problem from the top. This is a real hook, not pseudocode: it reads the payload on stdin, compares the target path against a protected list, and answers.

.claude/hooks/protect-files.mjs
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import path from "node:path";

const PROTECTED = [
  /(^|\/)\.env(\..+)?$/,   // .env, .env.local, .env.production
  /(^|\/)\.git\//,         // anything inside .git
  /(^|\/)\.ssh\//,         // keys
  /(^|\/)credentials\.json$/,
];

// Claude Code writes the call description to stdin as JSON.
const payload = JSON.parse(readFileSync(0, "utf8"));
const target = payload.tool_input?.file_path ?? "";
const rel = path.relative(payload.cwd ?? process.cwd(), target) || target;

if (PROTECTED.some((re) => re.test(rel))) {
  process.stdout.write(JSON.stringify({
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason:
        `${rel} is protected. Change it by hand, or ask a human.`,
    },
  }));
}

// Print nothing = no opinion. The normal permission flow continues.
process.exit(0);

Two things worth noticing. It says why, not just no — that reason goes back to Claude, which is the difference between it getting stuck and it finding another route. And silence is a valid answer: a hook that has no opinion should print nothing rather than approving everything.

Run something through it

Pick what Claude tries to do and watch the hook decide. The rules below are the ones in the script above.

Claude wants to:

07 · Hooks are not only about blocking

Blocking is the loudest use, not the most common one. Six things people actually wire up:

Observe

Write every tool call to a log so you can see afterwards what the agent actually did.

PostToolUse → append to a file
Automate

Run the formatter, regenerate types, restart a dev server after a file changes.

PostToolUse → prettier
Protect

Refuse edits to credentials, migrations, or anything with no undo.

PreToolUse → deny
Enrich

Inject context at the start of a session: the current branch, the ticket, today's deploy state.

SessionStart → stdout becomes context
Notify

Ping you when a long run finishes or the agent is waiting on a decision.

Notification · Stop
Verify

Refuse to let the session end while the tests are red, and say so.

Stop → continue: true

The Enrich row is worth a second look: on a few events — SessionStart, UserPromptSubmit among them — whatever your script prints on stdout is added to the context Claude can see. A three-line shell script can tell it which branch you are on.

08 · Rules versus judgement

A hook does not have to be a script. Alongside command, a hook can be a prompt or an agent — Claude evaluating the situation instead of your regex.

Deterministic

type: "command" — your code, same answer every time.

  • Fast and free
  • Testable like any other script
  • Cannot be talked out of a decision
  • Only as good as the pattern you wrote

Judgement

type: "prompt" or "agent" — a model weighs it up.

  • Handles cases you did not enumerate
  • Reads intent, not just strings
  • Costs tokens and time on every call
  • Not perfectly repeatable; agent hooks are experimental

Rule of thumb: if you can express the rule with an if, write the if. Reach for a prompt or agent hook only when the decision genuinely needs understanding — “is this commit message describing what actually changed?” is judgement; “is this file .env?” is not.

09 · What it looks like assembled

Put four small hooks together and the loop stops being a chat and starts being a workflow with checks in it:

SessionStart injects the branch and ticket into context
PreToolUse protected paths denied, everything else passes
PostToolUse Prettier runs, the call is logged
Stop tests red? sends Claude back instead of finishing

None of those four is clever on its own. Together they mean the agent cannot touch your secrets, cannot leave the codebase badly formatted, cannot quietly finish on a broken build, and cannot claim it did not know which branch it was on. That is the whole pitch: same model, bounded system.

10 · A hook is code. Treat it like code.

A command hook runs as a shell command with your permissions. Not Claude's permissions, not a sandbox — yours. It can modify or delete anything your user account can reach, and it runs automatically, without asking, on every matching call.

The failure mode to avoid is copying a .claude/settings.json from a random repository and letting it run. You would not curl | bash a stranger's script. A hook config is the same thing with extra steps: read it before it runs.

Hook event reference

The ones worth knowing by name, and what each is for.

EventFiresUse it to
SessionStartsession opensinject context — stdout is added to what Claude sees
UserPromptSubmityou send a promptadd context, or reject the prompt
PreToolUsebefore a tool runsallow, deny, or rewrite the tool input
PermissionRequesta call needs approvalauto-approve known-safe calls
PostToolUseafter a tool succeedsformat, log, regenerate
PostToolUseFailureafter a tool errorsreact to failures, add a hint
StopClaude is about to finishsend it back if the work is not verified
SubagentStopa subagent finishescheck a delegated result
NotificationClaude needs yousend yourself a desktop or phone ping
PreCompactbefore context compactionpersist anything you do not want summarised away
SessionEndsession closesclean up, write a summary

Behaviour and event names verified against the official Claude Code hooks reference, September 2026. Hooks are evolving quickly — check the official reference before relying on a detail here.

What's next