---
title: "Claude Code Hooks"
url: "https://romankryvolapov.com/en/claude-code-hooks/"
description: "A walkthrough of Claude Code hooks: every event, exit codes and JSON decisions, matcher and if, the five handler types, and three working hooks — sound, subscription limits and a work log."
language: en
updated: 2026-08-06
---
**Hi!**

You can spend a long time configuring Claude Code with text. Writing instructions into the project file, adding rules, explaining to the agent what it must not do. All of that works right up until the moment the model decides it has better things to attend to.

An instruction is a request. A hook is code that runs every time, whatever the model happens to decide.

The difference is practical. Write "format the code after edits" into the instructions and it will format sometimes. Attach a formatter to the after-edit event and formatting happens on every single edit, including the ones the agent never thought about.

This article covers hooks end to end: how they work, every event, what you can return and how that changes the agent's behaviour. Then I show three hooks I use every day: a sound, subscription limit tracking and an automatic work log.

## How a hook differs from a rule, a skill and a subagent

A hook is a command that Claude Code runs by itself at particular moments of its life. Before a tool call, after a file edit, at session start, when you submit a prompt, when the turn ends.

The command receives JSON describing the event on stdin. It answers with an exit code and, if needed, JSON of its own. That answer can stop the action, rewrite the tool's arguments, add text to the model's context, or change nothing at all — in which case the hook is simply an observer.

To keep the extension mechanisms straight:

- **Instructions and rules** are text in the context. The model reads them and usually complies, but it is text competing for attention with the rest of the task.
- **Skills** are knowledge on demand. They load when the task matches. Also text, only cheaper.
- **Subagents** are separate contexts for noisy work, so exploration doesn't clog the main conversation.
- **MCP servers** are new tools the model may use. If it wants to.
- **Hooks** are deterministic code that bypasses the model. Not "may use", but "will run".

The right pairing is almost always this: the hook supplies facts and enforces guarantees, the rule explains to the model what to do with those facts. The budget example below shows that either half alone is useless.

## Your first hook in five minutes

Start with something simple: a notification when the agent is waiting for you.

Open a settings file (global ones live in your home directory, project settings in the project itself) and add a block:

```json title="~/.claude/settings.json"
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'The agent is waiting'"
          }
        ]
      }
    ]
  }
}
```

The structure is nested and looks excessive the first time. It is logical though: `hooks` is an object whose key is the event name; inside it a list of groups; a group has a `matcher` filter and its own list of handlers. Several handlers in one group run in parallel.

On macOS `osascript` does the same job as `notify-send`, on Windows a PowerShell call.

To check that the hook is registered, run `/hooks` right in the session. It lists every configured hook grouped by event and tells you which settings file each came from. The menu is read-only, hooks are edited in JSON.

The most common first-setup mistake: the file already has a `hooks` key, and the new event gets written instead of the existing ones rather than alongside them.

## Where settings live

Where you put a hook determines its scope:

| Where | Scope | Ends up in the repository |
|---|---|---|
| User settings in the home directory | All your projects | No |
| Project settings | One project | Yes, if committed |
| Local project settings | One project | No, gitignored |
| Managed enterprise policy | The whole organization | Yes, set by an administrator |
| Plugin | While the plugin is enabled | Yes, inside the plugin |
| Skill or agent frontmatter | While the component is active | Yes, inside the component |

The levels add up rather than override each other. A hook from user settings and a hook from project settings on the same event both run. An identical handler declared in several settings files runs once: deduplication exists. Copies from plugins and skills, though, count as separate and are not deduplicated.

You can switch everything off with the `disableAllHooks` setting. It works within its own level: hooks from enterprise policy cannot be disabled that way. There is also the reverse setting, `allowManagedHooksOnly`, with which an organization forbids user, project and plugin hooks entirely.

Edits to settings files are picked up on the fly, no session restart needed. If your hook hasn't shown up in the menu after a few seconds, look for a JSON error: trailing commas and comments are not allowed there.

## How a hook talks to Claude Code

The exchange is dead simple. JSON in, an exit code and optionally JSON out.

### What arrives on stdin

On every event an object lands in the hook's standard input. The common fields are present for all events:

```json
{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/directory",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" }
}
```

Each event then adds its own. Before a tool call you get its name and arguments, on prompt submission the prompt text, at session start the launch source, at the end of a turn the assistant's last message.

Make a note of the transcript path field in particular. That file holds the whole session, one JSON line at a time. Through it a hook learns what the event itself doesn't carry: which model did the work, how many tokens the last request cost, which tools were called during the turn. Both complex hooks below rely on it.

A hook also gets environment variables, including the project root. That saves you the dance with relative paths.

### What you can return

The first way is the exit code:

- **0** — no objection, the action proceeds. For prompt submission, command expansion and session start, whatever the hook prints to standard output is added to the model's context.
- **2** — block. What the hook writes to the error stream reaches the model as an explanation, so it can correct course. Not every event can be blocked: session start and notifications, for instance, cannot, and there the message is simply shown to the user.
- **anything else** — an error, but a non-blocking one. The action goes ahead and a line about the hook failure appears in the transcript.

A minimal blocking hook in shell:

```bash title=".claude/hooks/protect-files.sh"
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

case "$FILE" in
  *.env|*package-lock.json|*.git/*)
    echo "Editing $FILE is forbidden by project policy" >&2
    exit 2
    ;;
esac
exit 0
```

The second way is a structured answer: exit with zero and print JSON. That lets you not merely forbid, but explain, escalate to the user or substitute data:

```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This project uses rg instead of grep"
  }
}
```

The decision values: `allow` skips the confirmation dialog, `deny` cancels the call and passes the reason to the model, `ask` shows the user the normal permission prompt. Non-interactive mode adds `defer`, which leaves the call suspended for an external wrapper.

The two ways don't mix. With exit code 2 your JSON is ignored entirely.

An important asymmetry: a hook can tighten the rules but cannot loosen them. A deny from settings beats an `allow` from a hook. Meanwhile a `deny` from a hook works even in the mode where confirmations are switched off completely. That is what makes hooks suitable for policy a user should not be able to sidestep by changing modes.

Adding context has its own field:

```json
{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Current branch: release-42. Deploy freeze until Friday."
  }
}
```

Here is a subtlety that can eat half a day. The field must sit inside the nested object. Written at the top level it is silently ignored: no error, simply nothing happens.

Other events use their own decision shapes. After a tool call and at the end of a turn it is the `decision` field with the value `block`, for a permission request a nested object with a behaviour field. There are shared fields too: `continue` set to false stops the agent altogether, `systemMessage` shows the user a warning, `suppressOutput` hides the hook's output from the transcript.

The answer is capped at ten thousand characters. Anything longer is saved to a file, and the context gets the beginning plus a path. So a hook that dumps a giant log will lose most of it.

## Every event

At the time of writing there are more than thirty events. A flat list is unmemorable, so here they are by group.

**Session life:**

| Event | When |
|---|---|
| `SessionStart` | A session started or resumed |
| `Setup` | One-off preparation when launched with the init flags, for CI and scripts |
| `SessionEnd` | The session ended |

**The conversation turn:**

| Event | When |
|---|---|
| `UserPromptSubmit` | The prompt was sent, the model hasn't seen it yet |
| `UserPromptExpansion` | A typed command expanded into a prompt; can be blocked |
| `Stop` | The model finished answering |
| `StopFailure` | The turn died on an API error; output and exit code are ignored |
| `MessageDisplay` | Reply text is being displayed; you can substitute the display without touching the transcript |

**The tool loop:**

| Event | When |
|---|---|
| `PreToolUse` | Before a tool call; can be blocked or its arguments rewritten |
| `PermissionRequest` | A permission decision is needed, you can answer for the user |
| `PermissionDenied` | The call was denied by the automatic classifier |
| `PostToolUse` | The tool finished successfully |
| `PostToolUseFailure` | The tool failed |
| `PostToolBatch` | A whole batch of parallel calls resolved |

**Subagents and tasks:**

| Event | When |
|---|---|
| `SubagentStart` | A subagent was spawned |
| `SubagentStop` | A subagent finished |
| `TeammateIdle` | An agent-team member is about to go idle |
| `TaskCreated` | A task is being created |
| `TaskCompleted` | A task is being marked complete |

**Files, directories and configuration:**

| Event | When |
|---|---|
| `InstructionsLoaded` | An instructions or rules file was loaded |
| `ConfigChange` | A settings file changed during the session |
| `CwdChanged` | The working directory changed |
| `DirectoryAdded` | Another directory was added to the session |
| `FileChanged` | A file you asked to watch changed |
| `WorktreeCreate` / `WorktreeRemove` | A git worktree is being created or removed |

**Context and the outside world:**

| Event | When |
|---|---|
| `PreCompact` / `PostCompact` | Before and after context compaction |
| `Notification` | Claude Code shows a notification |
| `Elicitation` / `ElicitationResult` | An MCP server asks the user for input and gets an answer |

Half of that list you will never need, and that's fine. In practice nearly everything useful is built on eight events: session start, prompt submission, before and after a tool call, notification, end of turn, subagent start and stop.

Three items from the list get overlooked, and they shouldn't be.

The configuration change event lets you log, and even forbid, edits to settings mid-session. Useful where traceability matters.

The working directory event closes an old sore spot with environment variables. Tools like direnv switch them in your shell but not in the agent's shell. This hook, together with the session start event, fixes the mismatch:

```json
{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
    ],
    "CwdChanged": [
      { "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
    ]
  }
}
```

And the instructions-loaded event gives you a spot where you can see which rules actually made it into the context. Priceless when a path-scoped rule refuses to fire for no visible reason.

## Filters: matcher and if

Without a filter a hook fires on every occurrence of its event. `matcher` narrows that at the group level:

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

The formatter runs after a file edit and stays quiet after a read or a shell call. A pipe separates alternatives; in recent versions a comma works the same way. An empty string or a missing field mean "everything".

Matching is case-sensitive. That is the most common cause of a silent hook, check it first.

Each event's matcher filters its own field. For tool events it is the tool name, for session start the launch source (plain start, resume, clear, compaction, fork), for the end of a session the reason, for a notification its type, for a subagent start the agent type, for compaction whether it was manual or automatic, for a dead turn the kind of API error. Some events support no filter at all and always fire.

MCP tools are named with a double-underscore scheme that carries the server name in the middle. A regular expression easily catches every tool of one server, or every write operation across all servers at once.

The `if` field is a finer filter, available only on tool events. It uses permission rule syntax and looks not just at the tool name but at the arguments:

```json
{
  "type": "command",
  "if": "Bash(git *)",
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}
```

The hook process is then never spawned at all if the command doesn't match the pattern. The saving is noticeable when a hook sits on every shell call. The parsing is clever, too: compound commands joined by a double ampersand are checked part by part, and so are nested substitutions.

But it is a best-effort filter by design. If the command cannot be parsed, it lets the call through to the hook just in case. So hard prohibitions belong to the permission system, not to a hook filter.

## The five handler types

Most hooks are a command. But there are five types, and the other four solve problems a command handles awkwardly.

**Command.** Runs an executable or a string in a shell. The main workhorse.

**HTTP.** Sends the same event as a POST request to a URL and reads the decision from the response body. Handy when the logic lives in a shared service, a team-wide audit of tool calls for instance:

```json
{
  "type": "http",
  "url": "http://localhost:8080/hooks/tool-use",
  "headers": { "Authorization": "Bearer $MY_TOKEN" },
  "allowedEnvVars": ["MY_TOKEN"]
}
```

Environment variable interpolation into headers works only for explicitly listed names, the rest stay empty. You cannot block an action with an HTTP status code: it takes a successful response carrying the same JSON decision a command would return.

**MCP tool.** Calls a tool on an already connected server and parses its answer as the decision.

**Prompt.** A small model makes the decision. You write the question, it receives the event, back comes a yes/no with a reason. The way out for cases that need judgement rather than a deterministic check:

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check whether every task from the request is done. If not, answer {\"ok\": false, \"reason\": \"what remains\"}."
          }
        ]
      }
    ]
  }
}
```

A negative answer at the end of a turn is returned to the model as an instruction, and it keeps working. A fast cheap model is used by default, but you can change it.

**Agent.** The same thing, except the decision comes from a full subagent with tools. It can read files, run tests and only then answer. Slower and dearer, but it checks the project's actual state:

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Verify that the unit tests pass. Run them and look at the result. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}
```

The type is experimental; for production workflows a command is safer.

The rule for choosing is simple. The event data is enough — prompt. You need to look at code and run something — agent. Everything else — command.

## How the command actually runs

Here is a fork that explains half of the strange errors on Windows.

If the handler has an `args` field, **exec form** applies. `command` is treated as a path to an executable, the arguments are passed exactly as written, and there is no shell at all. Quotes, dollars and backticks pass straight through uninterpreted.

No `args` field means **shell form**. The string goes to a shell that expands variables and handles pipes and substitutions. On macOS and Linux that is `sh`, on Windows Git Bash, and PowerShell if Git Bash is absent. You can name the shell explicitly.

On Windows exec form needs a real executable. The wrapper files npm lays down for its utilities are not executables and won't spawn directly. Either call them through `node` with the script path, or switch to shell form.

## Timeouts, background and limits

By default a command, HTTP and MCP handler gets ten minutes. But prompt submission cuts that to thirty seconds, message display to ten, and all hooks on session end share one and a half seconds between them. A prompt hook waits thirty seconds, an agent hook a minute.

Long work goes to the background with the `async` flag. Such a hook doesn't hold up the session, but it also cannot decide anything: its answer is nobody's business by then.

There is a middle option, `asyncRewake`. The hook runs in the background, and if it exits with code 2 the agent is woken and receives its message. The only way to report the failure of a long background check.

## Hook one: a sound when the agent is waiting for a human

Now three hooks from my own practice. Starting with the smallest.

The problem is mundane. The agent works for a few minutes, you switch to another window, and it stops on a question or a permission prompt. And waits until you remember about the terminal.

The fix is a sound on three events: end of turn, a turn killed by an API error, and a notification.

```json title=".claude/settings.json"
{
  "hooks": {
    "Notification": [
      { "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
    ],
    "Stop": [
      { "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
    ],
    "StopFailure": [
      { "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
    ]
  }
}
```

The player itself is a small Go program. All of the logic is in picking a playback method for the current system:

```go title="main.go"
func main() {
	defer func() {
		_ = recover()
		os.Exit(0) // a hook must never take the session down
	}()

	sound := os.Args[1]
	if _, err := os.Stat(sound); err != nil {
		return
	}

	switch runtime.GOOS {
	case "windows":
		// the built-in .NET player, nothing extra to install
		play("powershell", "-NoProfile", "-Command",
			fmt.Sprintf("(New-Object Media.SoundPlayer '%s').PlaySync()", sound))
	case "darwin":
		play("afplay", sound)
	default:
		// Linux: try the known players in order, stop at the first that works
		for _, c := range [][]string{
			{"pw-play", sound}, {"paplay", sound}, {"aplay", "-q", sound},
			{"play", "-q", sound}, {"canberra-gtk-play", "-f", sound},
		} {
			if play(c[0], c[1:]...) {
				break
			}
		}
	}
}
```

Look at the first lines. A hook has to be harmless: no sound card, no player, no file — exit quietly with zero. A crashing hook turns a small convenience into a permanent source of errors in the transcript.

If notifications feel too noisy, the event has type filters: permission prompts only, an idle session waiting for your answer only, a finished background session only.

## Hook two: live subscription limits

The most useful of the three and the most instructive. It does what the model cannot possibly know about itself.

A subscription has a five-hour window, a seven-day window and separate weekly buckets per model. Inside a session the agent doesn't see those numbers, so it cannot size its spending. It fans out ten subagents just as happily when the limit resets in four hours as when the budget is nearly gone. The result is a limit hit in the middle of a task.

The hook covers four roles at once.

**It fetches live numbers.** The client stores an OAuth token locally when you log in. The hook reads it and asks the API for current usage, no API keys to set up:

```go
func fetchUsage() json.RawMessage {
	b, _ := os.ReadFile(credPath) // the token stored at login
	var cred struct {
		ClaudeAiOauth struct {
			AccessToken string `json:"accessToken"`
		} `json:"claudeAiOauth"`
	}
	if json.Unmarshal(b, &cred) != nil || cred.ClaudeAiOauth.AccessToken == "" {
		return nil
	}
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://api.anthropic.com/api/oauth/usage", nil)
	req.Header.Set("Authorization", "Bearer "+cred.ClaudeAiOauth.AccessToken)
	// ... the answer is cached for a minute, to avoid a network call per prompt
}
```

The data is account-global: it accounts for your other sessions too, which is exactly what you want.

**It shows them in the status line.** The status line is configured in the same place as hooks and refreshes constantly. The picture stays in front of you:

![](/images/2026/08/claude-code-usage-limits-statusline.png)

The top row is how full the context window is: how many tokens the last request carried against the window size. Below are the limits: the five-hour window, the weekly one and the separate weekly bucket of the model currently doing the work.

There is a trick hidden here worth stealing regardless of Claude Code. Each limit is drawn as two rows. The upper one is how much of the budget is spent, the lower one how much of the window has already elapsed.

On its own a "35 % spent" bar says nothing. Meaning appears when the second one sits next to it. The budget bar outruns the time bar and you won't make it to the reset. It lags behind and you can work calmly. Two numbers that are meaningless apart give you a decision together.

**It puts the same numbers into the model's context.** At session start and on every prompt the hook prints the same table to standard output and exits with zero. For those events that is enough to get the text into the context, no separate JSON needed. Two rows are added that the status line doesn't carry: the burn rate with a forecast of when the budget runs out, and the current zone naming which limit is currently binding.

**It forbids fanning out agents on a thin budget.** This is what the whole thing was built for. The hook sits on the before-tool-call event with a filter on the tools that spawn subagents and workflows:

```json
{
  "PreToolUse": [
    {
      "matcher": "Agent|Task|Workflow",
      "hooks": [
        { "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/usage-limits\" --mode gate", "timeout": 20 }
      ]
    }
  ]
}
```

And in a hot zone the gate itself answers with a decision:

```go
if st.zone == "RED" || st.zone == "ORANGE" {
	decision := "ask"
	if st.zone == "RED" {
		decision = "deny" // a fan-out would burn what's left of the budget
	}
	out := map[string]any{
		"hookSpecificOutput": map[string]any{
			"hookEventName":            "PreToolUse",
			"permissionDecision":       decision,
			"permissionDecisionReason": reason, // the numbers and what to do instead go here
		},
	}
	b, _ := json.Marshal(out)
	fmt.Print(string(b))
}
```

In the red zone the subagent spawn is cancelled, in the orange one it goes to the user for confirmation. The reason reaches the model as text, and it reshapes its plan: works in one thread instead of fanning out.

One more detail you cannot get from the event itself is how full the context window is. The hook reads the tail of the transcript file, finds the last entry with token usage and adds fresh input to cache reads and writes. That gives the size of the last request. Subagent entries are skipped: they have their own context and their numbers have nothing to do with the main conversation.

**And the other half of the pairing.** The hook brings facts, but a fact by itself changes no behaviour. Next to it lies a rule explaining how to read those numbers: compare the budget bar against the time bar, size the depth of the work to the task rather than to the remaining limit, count a fan-out of N agents as N times the spend, and near a threshold narrow the scope instead of spending up to the ceiling.

Either half alone is useless. A rule without the hook rests on invention: the model doesn't know its limits and starts making them up. A hook without the rule produces numbers nobody acts on. Together you get a saving that actually works, and that, to my mind, is the main recipe in this whole article.

By the way, the rule's text here is an ordinary prompt, and it is written with the same techniques: an explicit table of thresholds instead of a vague "be economical", a described failure scenario, a checklist at the end. I go through the techniques in the article [on prompt engineering](/en/prompt-engineering/), and through how rules and skills are organized as a whole in the article [on working with Claude Code](/en/claude-code-best-practices/).

## Hook three: an automatic work log

The third hook answers the question "what actually happened in this session".

Asking the model itself is a bad idea. It will retell its work from memory, and by the end of a long session that memory is already compacted: some details are gone, others recounted optimistically. What you need is a mechanical record made by something other than the model.

The hook listens to nearly every session event: start and end, each submitted prompt, the end of a turn and its failure, subagent starts and stops, context compaction. Of the tool events it takes only questions asked of the user. It writes one file per session, filed by author and date.

It comes out roughly like this:

```text title="management/logs/roman/2026-08-06/a1b2c3d4.log"
session start: source=startup · model=claude-opus-5

════════════════════════════════════════════════════════════

prompt:
fix the infinite redirect on an expired token, BUG-003

turn: model=claude-opus-5 · effort=high · 4m 12s · tools=23
  (Read 9, Grep 5, Edit 3, Bash 6) · subagents=1

files:
  ~ apps/web/src/api/interceptors.ts  +14/-6  +[41-48,52-57] -[41-46]
  ~ apps/web/tests/auth.spec.ts       +31/-0  +[88-118]

answer:
The cause was the interceptor retrying the request with the stale token...

session end: reason=prompt_input_exit · duration=51m
```

Three tricks in this hook deserve a mention of their own: you won't derive them from the documentation.

**Subagents cannot be counted from tool calls.** A fan-out is one tool call that spawns dozens of agents. The transcript shows one, reality is thirty. So the counter is filled from subagent start events, and the turn line reads and clears it:

```go
// every SubagentStart appends one byte to the session tally; append-only,
// so agents starting at the same moment cannot overwrite each other
func noteSubagent(session8 string) {
	f, err := os.OpenFile(tallyPath(session8), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
	if err != nil {
		return
	}
	defer f.Close()
	_, _ = f.WriteString("x")
}
```

**A message typed during a turn never reaches the hook.** It isn't submitted but queued, so the prompt submission event never fires for it. The log would then show an answer to a question that isn't there. Such messages are pulled from the transcript at the end of the turn and written with their own marker, right before the turn they interrupted.

**A finished background task arrives through the same event as a user prompt.** Recording it as "the user asked" means putting words in a human's mouth. Those messages are recognized by how the text starts and written as their own line.

File changes are computed from a git diff against the last commit, down to line ranges. Git is optional though: no git, or the folder isn't a repository, and the statistics degrade to a plain list of paths while everything else is written as usual.

Every hook of this log runs in the background. They decide nothing, they only record, and there is no reason to hold up the session for them.

## Why a compiled binary rather than a script

All three hooks are Go programs launched by a thin shell shim. The choice isn't obvious, so let me explain.

A Node or Python script looks simpler right up to the moment the hook starts sitting on every submitted prompt. Interpreter startup is tens, sometimes hundreds of milliseconds per event. Plus the dependency on having the right runtime installed at the right version. A compiled binary starts instantly and depends on nothing.

The second argument is portability. Built binaries for three systems go into the repository, and the hook in the settings points at a small shim:

```sh title=".claude/hooks/usage-limits"
#!/bin/sh
case "$(uname -s 2>/dev/null)" in
	Darwin*)               name="usage-limits-darwin-arm64" ;;
	Linux*)                name="usage-limits-linux-amd64" ;;
	MINGW*|MSYS*|CYGWIN*)  name="usage-limits-windows-amd64.exe" ;;
	*)                     name="usage-limits-linux-amd64" ;;
esac

# ... find the binary next to this script and hand it the arguments and stdin
exec "$root/scripts/claude-code/$name" "$@"

# no binary found: exit quietly, a hook has no right to break the session
exit 0
```

A colleague who cloned the repository needs neither Go nor Node. Only Claude Code itself. The compiler is needed only by whoever changes the hook.

And here is the rake that cost me an evening. On Windows Claude Code runs hooks through Git Bash, and Git Bash chokes on a first line with a Windows line ending: you get "bad interpreter". The shim file must be stored with Unix line endings, and that has to be pinned in the git settings:

```text title=".gitattributes"
.claude/hooks/* text eol=lf
```

Otherwise the automatic conversion will break everything one day. Silently, and on somebody else's machine.

## Rules that save you evenings

Practice accumulated over three hooks and a dozen debugging sessions.

**A hook has no right to take the session down.** Any error means a silent exit with zero. A non-zero exit code from a helper hook turns into a stream of error messages on every turn.

**Standard output carries only what you intended.** If the hook returns JSON, not one extra line may land there. The classic case: the shell profile prints a greeting, it gets glued onto the JSON, parsing fails. Cured by an interactivity check in the profile:

```bash title="~/.bashrc"
if [[ $- == *i* ]]; then
  echo "Shell ready"
fi
```

**Debug messages to the error stream.** It doesn't interfere with parsing and shows up in the debug log.

**Speed matters more than it seems.** A hook on prompt submission sits on the critical path: every extra half-second you wait on every message. Cache network calls.

**Idempotency.** The same hook may fire twice, in parallel, from two sessions at once. Appending to the end of a file survives that, rewriting does not.

**Post-turn edits are worth checking wholesale.** The model changes files not only with edit tools but with shell commands, and a filter on edit tools won't see those changes. If you need completeness, add a working tree check at the end of the turn.

**Careful with the end-of-turn hook.** It can force the agent to keep working, and an infinite loop is easy to build on that. There is a guard, the hook is overridden after eight consecutive blocks, but better to watch the field that says the continuation was already triggered by this very hook:

```bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0   # already looping on our own demand, so step aside
fi
```

**Only one hook should rewrite call arguments.** Hooks run in parallel, and with two rewrites the last one to finish wins. The order is non-deterministic.

## Security

Let me be blunt, because hooks are a ready-made mechanism for executing arbitrary code.

A hook runs automatically, with your privileges, without any confirmation. And it receives the contents of prompts and the path to the transcript on stdin. Somebody else's configuration, found online or arriving with a pull request, is not an "editor setting" but code that will start executing on your machine at the next launch.

Several practical consequences follow. Project hooks living in the repository deserve the same review as code. Secrets don't go into hook commands: the settings file ends up in the repository, and environment variables exist for tokens. For HTTP hooks an organization gets an allowlist of URLs and a list of variables that may be interpolated into headers at all. An administrator can forbid user and project hooks entirely, leaving only the corporate ones.

Claude Code sets its own limits too: on macOS and Linux hooks run without a controlling terminal, so they cannot write straight into the interface or slip escape sequences into it.

## Debugging

First, three obvious checks that cover most of the "it doesn't work" cases.

The `/hooks` command shows whether the hook is registered and which settings file it came from. Not there — the problem is in the JSON or in the file location. There but not firing — almost certainly the filter didn't match.

A hook is convenient to test by hand, without the agent. All it needs is JSON on standard input:

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

The full picture is in the debug log: which hooks matched, what codes they exited with, what they printed. It is enabled by a launch flag or by a command mid-session.

Typical diagnoses by symptom:

- "command not found" — a relative path. Use the project root variable or switch to exec form.
- "jq: command not found" — the utility isn't installed. Either install it or parse the JSON with what you already have.
- The hook doesn't run at all on macOS or Linux — the execute bit is missing.
- A JSON parse error on knowably valid output — stray output from the shell profile.

## What belongs in a hook and what doesn't

Good candidates are everything that needs a guarantee rather than an intention:

- formatting and linting after edits;
- a ban on touching sensitive files and dangerous commands, with an explanation the model will read and take on board;
- notifications when the agent is waiting for a human;
- adding facts to the context: branch, environment, limits, the state of a staging box;
- logging and auditing, especially where traceability is required;
- restoring context after compaction;
- auto-approval of a narrow, deliberately chosen class of permission prompts.

Poor candidates:

- complex logic in a hook on every prompt — you pay for it with latency on every message;
- hard prohibitions that must be unbreakable: the permission system exists for that, while a hook's argument filter is best-effort;
- anything easier said in words: if the behaviour isn't critical and the model usually gets it right anyway, a rule is cheaper;
- broad auto-approval of permissions: an "everything" filter turns confirmations into a fiction, file writes and shell commands included.

And the main point behind all of this. Hooks are the one part of Claude Code that doesn't depend on the mood the model is in today. Whatever must happen every time should be a hook. The rest can be left to words.

For how the work around the agent is organized as a whole (project instructions, rules, skills, task tracking, context management), I have [a separate article](/en/claude-code-best-practices/).
