I read every Claude Code hook so you don’t have to

When building AI DevKit, I had to look much deeper into different coding agent harnesses. Each one has its own structure, workflow, and mental model.

In this post, I’ll share what I learned from digging into Claude Code hooks.

Claude Code has 27 hook events. I went through all of them.

Most are not worth your time, a handful are useful, one or two can change how you use the tool.

The important thing to note is that hooks let you stop relying on the model to remember your rules. Instead, you can put those rules around the agent.

How hooks work

You put something like this in .claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/path/to/hook.sh", "timeout": 30 }
]
}
]
}
}

When the event fires, Claude Code runs your command, pipes JSON to stdin, and reads JSON back on stdout.

The input includes the event name, session id, transcript path, cwd, and event-specific fields.

Your stdout decides what happens next. You can abort with continue: false, deny the tool with decision: "block", or inject text into the next model turn with hookSpecificOutput.additionalContext.

Exit 0 with empty stdout is a no-op. Non-zero exits show up in the transcript as a hook error.

That’s it.

What I actually use

PreToolUse

Fires before every tool call. It can approve, block, or rewrite the input via updatedInput.

This is the one that matters most, because rewriting input is different from just observing. You can strip secrets out of commands before they run. You can refuse to run terraform apply outside an allowed directory.

A few examples:

  • Regex scan for credentials or .env paths in Bash commands, then block with a reason
  • Auto-redact tokens by rewriting the command instead of blocking it
  • Hard-deny DELETE or DROP TABLE
  • If the current branch is main and the command is git push, block

UserPromptSubmit

Fires when you hit enter. It returns additionalContext, which gets injected as a system message for the next turn.

This is basically a dynamic version of CLAUDE.md. Per-turn, conditional on whatever your script can compute.

Some ideas that work:

  • Compliance reminders when the prompt mentions “user data” or “PII”
  • A quick grep over the prompt that injects “relevant files: A, B, C”
  • Refuse to submit if today’s API spend has crossed a threshold

PostToolUse

Fires after a tool call succeeds. It can return additionalContext for the next turn, or updatedMCPToolOutput to rewrite what the model sees.

This is a good place to enforce “always run test after an Edit”.

Small thing, but it removes a recurring class of stale-context bugs.

It is also useful for redacting secrets from Bash output before the model sees them. The model does not need to know your access tokens to keep working.

SessionStart

Fires on startup, resume, clear, and after compact. It can return additionalContext, initialUserMessage, or watchPaths.

The source field tells you what kind of start it is, so you can do different things on resume vs fresh boot.

watchPaths is the underrated part. It lets you register paths outside cwd so FileChanged fires for them. That is handy when a shared config repo lives somewhere else.

PermissionRequest

Fires when a tool call is about to ask you for permission. It can allow, deny, rewrite the input, or update persistent permissions.

This gets me off the “yolo allow everything” reflex without going full bypassPermissions.

Auto-allow ls, cat, grep, git status, etc. Defer everything else to the UI. In work repos, deny anything that writes outside the repo. In scratch repos, allow more.

The value is encoding the policy in code instead of clicking through the same prompts forever.

Useful in specific situations

These are not always wired up for me, but I reach for them when the situation calls.

HookBest for
PostToolUseFailureAuto-retry transient failures; inject diagnostics like “Bash failed because port 3000 is taken, here’s the PID”
PermissionDeniedTell the model why denial happened via additionalContext, or set retry: true with a hint
Stop“Are you really done?” loops. Inject a checklist and let the model decide if it should keep going
PreCompact / PostCompactPin facts across compaction. Probably the biggest quality win for long sessions
SubagentStopAggregate subagent results into the parent context with structure
InstructionsLoadedDebug “where did this rule come from?” The load_reason field tells you whether it came from session-start, nested-traversal, glob-match, include, or compact-restore
NotificationRoute Claude’s idle or awaiting-input alerts through notification, Slack or different channels

PreCompact and PostCompact deserve a callout.

Long sessions get worse over time because compaction throws away things you wanted to keep. Pinning the current task and recent decisions across compaction is a small change, but it makes a visible difference if you do a lot of long-running work.

The rest

These exist. Most people will never wire them.

  • StopFailure, audit-only, for when stop handling itself crashes
  • SubagentStart, rarely useful alone, but pairs with SubagentStop
  • Setup, fires on /init and maintenance. Useful if you ship /init templates
  • SessionEnd, runs outside the REPL, cannot block exit, fails silently. Do not put critical cleanup here
  • CwdChanged, FileChanged, building blocks for “watch this thing” workflows, mostly paired with SessionStart.watchPaths
  • WorktreeCreate, WorktreeRemove, only fire for the worktree feature. The worktreePath response lets you override where worktrees get created
  • ConfigChange, fires when settings files change on disk. Audit-only in practice
  • Elicitation, ElicitationResult, MCP-only, for servers that request structured forms. Useful if you have a server that asks the same question every time
  • TeammateIdle, TaskCreated, TaskCompleted, only relevant if you use Claude Code’s team features

Things that bit me

hookSpecificOutput is where the structured fields go. Older examples, and a lot of posts still floating around, put additionalContext, updatedInput, and watchPaths at the top level of the response. The schema moved. If your hook is not doing anything, check that first. Print the actual stdout and look at the shape.

Every PreToolUse adds latency to every tool call. Spawn cost on macOS is around 10 to 50ms. In a turn that makes 30 tool calls, that adds up. For logging hooks where you do not care about the response, set async: true and an asyncTimeout so the tool does not wait.

Hook commands run with your full shell. A project’s .claude/settings.json can register a SessionStart hook. Claude Code prompts you before trusting new project hooks, which is good. But it is still worth reading the hook before saying yes. If you ever clone an untrusted repo, look at its .claude/ before opening it.

If my sharing resonates with you, subscribe to my blog. I share what I learn while building real systems with AI in the loop. You can also follow me on X or Threads for more thoughts and ongoing experiments.


Discover more from Codeaholicguy

Subscribe to get the latest posts sent to your email.

Comment