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
.envpaths in Bash commands, then block with a reason - Auto-redact tokens by rewriting the command instead of blocking it
- Hard-deny
DELETEorDROP TABLE - If the current branch is
mainand the command isgit 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.
| Hook | Best for |
|---|---|
PostToolUseFailure | Auto-retry transient failures; inject diagnostics like “Bash failed because port 3000 is taken, here’s the PID” |
PermissionDenied | Tell 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 / PostCompact | Pin facts across compaction. Probably the biggest quality win for long sessions |
SubagentStop | Aggregate subagent results into the parent context with structure |
InstructionsLoaded | Debug “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 |
Notification | Route 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 crashesSubagentStart, rarely useful alone, but pairs withSubagentStopSetup, fires on/initand maintenance. Useful if you ship/inittemplatesSessionEnd, runs outside the REPL, cannot block exit, fails silently. Do not put critical cleanup hereCwdChanged,FileChanged, building blocks for “watch this thing” workflows, mostly paired withSessionStart.watchPathsWorktreeCreate,WorktreeRemove, only fire for the worktree feature. TheworktreePathresponse lets you override where worktrees get createdConfigChange, fires when settings files change on disk. Audit-only in practiceElicitation,ElicitationResult, MCP-only, for servers that request structured forms. Useful if you have a server that asks the same question every timeTeammateIdle,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.