Roman Kryvolapov Engineering Blog

Claude Code Hooks

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:

~/.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:

WhereScopeEnds up in the repository
User settings in the home directoryAll your projectsNo
Project settingsOne projectYes, if committed
Local project settingsOne projectNo, gitignored
Managed enterprise policyThe whole organizationYes, set by an administrator
PluginWhile the plugin is enabledYes, inside the plugin
Skill or agent frontmatterWhile the component is activeYes, 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:

{
"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:

.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:

{
"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:

{
"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:

EventWhen
SessionStartA session started or resumed
SetupOne-off preparation when launched with the init flags, for CI and scripts
SessionEndThe session ended

The conversation turn:

EventWhen
UserPromptSubmitThe prompt was sent, the model hasn't seen it yet
UserPromptExpansionA typed command expanded into a prompt; can be blocked
StopThe model finished answering
StopFailureThe turn died on an API error; output and exit code are ignored
MessageDisplayReply text is being displayed; you can substitute the display without touching the transcript

The tool loop:

EventWhen
PreToolUseBefore a tool call; can be blocked or its arguments rewritten
PermissionRequestA permission decision is needed, you can answer for the user
PermissionDeniedThe call was denied by the automatic classifier
PostToolUseThe tool finished successfully
PostToolUseFailureThe tool failed
PostToolBatchA whole batch of parallel calls resolved

Subagents and tasks:

EventWhen
SubagentStartA subagent was spawned
SubagentStopA subagent finished
TeammateIdleAn agent-team member is about to go idle
TaskCreatedA task is being created
TaskCompletedA task is being marked complete

Files, directories and configuration:

EventWhen
InstructionsLoadedAn instructions or rules file was loaded
ConfigChangeA settings file changed during the session
CwdChangedThe working directory changed
DirectoryAddedAnother directory was added to the session
FileChangedA file you asked to watch changed
WorktreeCreate / WorktreeRemoveA git worktree is being created or removed

Context and the outside world:

EventWhen
PreCompact / PostCompactBefore and after context compaction
NotificationClaude Code shows a notification
Elicitation / ElicitationResultAn 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:

{
"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:

{
"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:

{
"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:

{
"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:

{
"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:

{
"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.

.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:

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:

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:

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:

{
"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:

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, and through how rules and skills are organized as a whole in the article on working with Claude Code.

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:

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:

// 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:

.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:

.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:

~/.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:

Terminal window
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:

Terminal window
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.