Claude Code Hooks: A Practical Deep-Dive on Deterministic Agent Behavior
You cannot instruct your way to reliability. Here is how the harness — not the model — makes a behavior happen every single time.
Here's a thing that took me embarrassingly long to accept about coding agents: you cannot instruct your way to reliability.
I had a working-memory system — a semantic-search-plus-notes MCP server I've been building, and it's the case study for this whole post — and it worked beautifully in demos. The agent would discover something, store a note, recall it later, save itself a re-read. Then I'd watch a real session and the agent would just... not recall. It had notes sitting right there, one tool call away, verbatim, and it would instead re-read the same file it had already read two sessions ago, because nothing made it check. My CLAUDE.md said "call recall at the start of every task." The model read that instruction and ignored it, the way it ignores roughly anything that competes with the task actually in front of it.
The lesson generalizes past my project. Any behavior you need to happen every single time — inject context, run a linter, block a dangerous command, snapshot state before it's destroyed — cannot depend on the model deciding to do it. The model is a probabilistic thing optimizing for the current turn. You need something outside the model, in the harness, that fires deterministically. In Claude Code, that thing is hooks.
This is the practical guide I wanted when I started: what the events are and what each is genuinely good for, the exact configuration and I/O contract (which is fiddlier than the docs make it look), and then a real production hook pipeline — mine — walked through end to end, including the design calls and the parts that bit me.
The Problem Hooks Actually Solve
Before the plumbing, the point. There is a whole class of things you want an agent to do that instructions are simply the wrong tool for. Not because the instruction is badly worded — because instructions target the model, and the model is the part of the system you don't control.
Think about what "the model complies with an instruction" actually means. On any given turn there's some probability the behavior happens, and that probability is well short of 1. It drops when the task gets absorbing, when the context is long, when an unusual prompt pulls attention elsewhere. That's fine for a preference — "prefer functional style," "keep commits small." It is a disaster for a guarantee. If the only thing standing between your agent and an rm -rf on the wrong directory is a politely worded line in a config file, you don't have a control. You have a hope.
Hooks move the decision out of the model and into the harness. The harness is deterministic: it runs code on a schedule, whether or not the model would have thought to. That single relocation — from "the model should" to "the harness will" — is the entire idea, and everything below is mechanics in service of it.
CLAUDE.md is where you put things the model should tend to do. Hooks are where you put things that must deterministically happen. Confusing the two — trying to instruction-engineer a guarantee — is how you end up with a system that works in the demo and flakes in production.
What a Hook Actually Is
A hook is a shell command — or, increasingly, an HTTP call or an MCP tool invocation — that Claude Code runs automatically when a specific event fires in the session lifecycle. The event hands your command a JSON blob on stdin describing what's happening. Your command does whatever it wants and communicates back through two channels: its exit code and its stdout. That's the entire model. It's Unix-plumbing simple, which is exactly why it's reliable — there's no LLM in the loop deciding whether to honor it.
The events cover the session from birth to death. When I first wrote my pipeline the list was short; by mid-2026 it has grown considerably — the reference now documents around thirty event types spanning session lifecycle, per-turn, per-tool-call, permissions, subagents, worktrees, and MCP elicitation. Most of them you'll never touch. The workhorses — the ones worth learning cold — are these:
| Event | Fires | What it's for |
|---|---|---|
| SessionStart | Session begins or resumes (matchers: startup, resume, clear, compact) | Inject state the agent needs before turn 1 — branch info, environment, recalled memory |
| UserPromptSubmit | Before Claude processes each user prompt | Inject per-turn context keyed to what the user just asked; can also block the prompt |
| PreToolUse | Before a tool call runs (matches on tool name) | Block dangerous calls, rewrite arguments, or surface a warning tied to the specific action |
| PostToolUse | After a tool call succeeds | React to results, replace tool output, add follow-up context |
| PreCompact | Before context compaction (matchers: manual, auto) | Persist anything that's about to be summarized away |
| Stop / SubagentStop | When the agent (or a subagent) finishes a turn | Enforce "you're not done yet" — block the stop and send it back to work |
| SessionEnd | Session terminates | Cleanup, flush, teardown |
| Notification | Claude Code emits a notification (permission prompt, idle, etc.) | Route notifications to your own channels |
The mental split that helps: session-scoped events (SessionStart, SessionEnd) bracket the whole thing; per-turn events (UserPromptSubmit, Stop) fire once per user exchange; per-tool events (PreToolUse, PostToolUse) fire around individual tool calls, potentially dozens of times a turn. Match the cadence of your hook to the cadence of the thing it's reacting to, or you'll either miss events or fire far too often.
The Configuration Surface
Hooks live in settings.json. There are three tiers, and the tier decides who the hook applies to and whether it's shared:
~/.claude/settings.json— all your projects, never checked in..claude/settings.json— one project, checked in and shared with the team..claude/settings.local.json— one project, gitignored, personal.
The shape is nested and, honestly, a little awkward until it clicks. Under a top-level hooks key, each event name maps to a list of groups. Each group has an optional matcher and a list of hooks to run:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh",
"timeout": 30
}
]
}
]
}
}
The matcher is the filter. For tool events it matches against the tool name — "Bash", "Edit|Write" for either, or a regex like "mcp__memory__.*" for a whole MCP server's tools. For non-tool events it matches against the event's reason: SessionStart takes startup|resume|clear|compact, PreCompact takes manual|auto. Omit the matcher (or use "*") to fire on everything.
The type used to be implicitly "command." It's now explicit and there are several — command (shell), http (POST to a URL), mcp_tool (invoke an already-connected MCP tool), and two LLM-in-the-loop types (prompt, and the experimental agent) that let a hook ask a model to make a yes/no call. For anything latency-sensitive you want command, because it's a local process with no network round trip. The useful knobs on a command hook are timeout (seconds; the default is generous but UserPromptSubmit is capped lower because it's on the critical path of every turn), and the ${CLAUDE_PROJECT_DIR} placeholder so your command path survives the user's working directory changing.
The I/O Contract, Which Is Where People Trip
This is the part the quickstart glosses and the part that determines whether your hook works. A hook talks back through exit code and stdout, and the two are read differently depending on the code.
Exit code 0 — success. Claude Code parses stdout looking for JSON. If it's JSON, the fields are honored; if it's not, no decision is taken and the session proceeds normally. (Two events, SessionStart and UserPromptSubmit, are more generous: they also fold plain, non-JSON stdout straight into the context. For every other event, if you want to inject something you emit the JSON form.) This is the channel you use to inject context.
Exit code 2 — blocking error. stdout's JSON is ignored; instead, stderr is read as an error message, and for blockable events (PreToolUse, UserPromptSubmit, Stop/SubagentStop) the action is blocked. This is how a PreToolUse hook vetoes an rm -rf. (For PreToolUse specifically there's now a cleaner path too: emit permissionDecision — allow, deny, or ask — inside hookSpecificOutput on exit 0, which is more expressive than the blunt exit-2 veto and lets you attach a reason the model reads. I still reach for exit 2 when I just want a hard no.)
Any other exit code — non-blocking error. The session continues; the failure is surfaced in the transcript and logged, but nothing is blocked.
The JSON you emit on stdout (with exit 0) has a couple of shapes. There's a set of top-level universal fields — continue (set false to stop Claude entirely), stopReason, suppressOutput, systemMessage. And there's an event-specific envelope, hookSpecificOutput, which is where the good stuff lives. The single field I use most is additionalContext: a string that Claude Code injects into the model's context at the point the hook fired.
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Current branch: main\nUncommitted: auth.ts, config.py"
}
}
That's the whole trick behind hook-injected memory. additionalContext on a SessionStart hook lands at the start of the conversation. The same field on UserPromptSubmit lands next to the prompt the user just submitted. On PreToolUse/PostToolUse it lands next to the tool result. The docs render it as a system reminder and advise writing it as plain factual statements rather than instructions — the model treats "the deployment target is production" better than "remember to be careful about production." There's a cap on how much you can push through it (on the order of ten thousand characters), which is less a limit than a hint: injection is not a place to dump files.
If you emit JSON on stdout with exit 0, stdout must contain only that JSON. A stray echo from your shell profile, a debug print, a warning from a Python import — any of it corrupts the JSON and your injection silently does nothing.
More than one of my early hooks failed for exactly this reason and gave no error, because a malformed stdout on exit 0 just means "no decision," not "error." Nothing tells you. The session simply proceeds as if the hook weren't there.
Pick an event, an exit code, and what your hook writes to stdout — then see what Claude Code does with it. Same stdout, different exit code, completely different outcome. Try the "stray echo" option to watch an injection turn into a silent no-op, which is the single most common way a hook quietly does nothing.
A Real Pipeline: Injecting Working Memory
Now the case study. My tool is a working-memory MCP server: the agent stores notes during a session and recalls them later. The problem from the intro was that recall is a tool the model has to choose to call, and it wouldn't, reliably. Hooks are how I took the choice away from the model and made recall happen deterministically.
When you run vectr init --hooks, the tool writes four hook groups into the project's .claude/settings.json. Every one of them calls back into the same CLI — vectr hook <event> — which owns the output contract so the settings file stays a thin, stable pointer. Here's the shape it writes (the install code is idempotent — re-running never duplicates entries and leaves any hooks you added yourself untouched):
{
"hooks": {
"SessionStart": [
{ "matcher": "startup|resume|clear|compact",
"hooks": [{ "type": "command", "command": "vectr hook session-start" }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "vectr hook user-prompt-submit" }] }
],
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "vectr hook pre-tool-use" }] }
],
"PreCompact": [
{ "matcher": "manual|auto",
"hooks": [{ "type": "command", "command": "vectr hook pre-compact" }] }
]
}
}
Four events, four jobs. Walk through why each one is the event it is.
SessionStart — the boot set
Before the agent's first turn, vectr hook session-start fires. It resolves which of my running daemons serves this workspace, asks it for the boot set — the must-see notes, meaning standing directives plus high-priority task context — and emits them as additionalContext. This is the MEMORY.md-equivalent: the handful of things that should be true in the agent's head from turn one, present with zero model agency. The matcher startup|resume|clear|compact means it fires not just on a fresh start but also after a /compact and after a /clear — precisely the moments when the agent has just lost its context and most needs the boot set re-injected.
UserPromptSubmit — per-turn recall
This is the one that fixed the original problem. Every time the user submits a prompt, vectr hook user-prompt-submit reads the prompt text off stdin, runs a semantic recall against the note store keyed to that specific prompt, and injects the top matches next to the prompt before the model ever sees it. Ask about workspace locking and the locking notes are already there. The agent doesn't decide to recall — recall already happened, invisibly, on the way in.
The tuning here matters because this hook is on the hot path of every single turn. I cap it hard: at most 3 notes, with a relevance floor (a minimum similarity of 0.35) so an off-topic prompt injects nothing rather than dragging in vaguely-related noise. And it injects the terse one-line index form of each note, not the full body — enough for the model to know the note exists and decide whether to expand it, without spending a paragraph of tokens on every turn. An injection that fires every turn has to be miserly or it becomes the context bloat it was meant to prevent.
PreToolUse (Edit|Write) — the gotcha at the moment of the edit
This one I'm quietly proud of. When the agent is about to edit or write a file, vectr hook pre-tool-use pulls the file_path out of the tool input and recalls any gotcha recorded against that exact file — then injects it right there, at the instant of the edit. "This file's config is regenerated; edit schema.ts instead." "This function looks unrelated but changing it breaks the lock invariant." Static path-scoped rules can't do this, because the gotcha is something an earlier session learned and wrote down, and it surfaces exactly when it's actionable rather than sitting in a rules file the agent skimmed once.
PreCompact — save it before it's gone
/compact replaces the conversation with a summary and, in doing so, throws away exact detail. So right before it runs, vectr hook pre-compact snapshots the working-memory store — sealing the current notes as a named checkpoint. Notably this hook injects nothing into context; compaction is about to discard context anyway, so there's no point. Its whole job is the side effect of persisting state, and the boot set gets re-injected on the other side by the SessionStart compact matcher. The two hooks are a matched pair around the compaction event: one saves, the other restores.
Why Hook-Injected Memory Beats a Recall Tool
Let me make the central design argument sharp, because it's the reason the pipeline exists in this shape.
A recall tool and a recall hook retrieve the exact same notes from the exact same store. The only difference is who pulls the trigger. With a tool, the model decides — and "the model decides" means a probability, well short of 1, that it happens on any given turn, dropping further as the task gets absorbing. With a hook, the harness decides, and the harness is deterministic. It fires every time, on schedule, whether or not the model would have thought to.
For a capability whose entire value proposition is reliability across sessions, a probabilistic trigger is a contradiction in terms. Working memory you recall 60% of the time isn't 60% as good as working memory you recall always — it's worse than that, because the times it fails are unpredictable and the agent has no way to know it's operating on a stale or empty picture. Moving the trigger from the model into the harness is the difference between a feature that demos well and one that holds up.
Give the tool version a per-turn recall probability and run a session. Watch the bottom number: the odds of getting through every turn with memory present. It's the per-turn odds multiplied by themselves N times, so it collapses fast even when each turn looks healthy. The hook row is the same store, triggered by the harness instead.
Anything that must happen every time belongs in a hook, not in an instruction. A guarantee cannot be prompt-engineered, because the thing you'd be prompting is the exact thing you don't control. Relocate the trigger, not the words.
There's a subtle second-order bug that falls out of doing this, and it's worth telling because it's the kind of thing you only find in real transcripts. Once SessionStart and UserPromptSubmit are auto-injecting notes, the model — which has also been told in CLAUDE.md to recall notes — will sometimes call the recall tool on top of the injection, paying for the same memory twice. I caught this in an eval transcript: the agent got its notes injected by the hook and then immediately called recall for the same thing. The fix is a one-line notice prepended to the injected context: "Your working-memory notes are auto-injected below — do not call recall to re-fetch them; call it only for something not shown here." It resolves the double-dip cleanly, but I'd never have known to write it without watching the failure happen.
The Three Things That Will Hurt You
Hooks are simple to write and easy to write dangerously. Three concerns dominate, and they're all about what happens when a hook misbehaves.
1. A hook must never break the session
This is the rule I hold most rigidly, and it shapes every line of my hook code. A hook runs on the critical path — UserPromptSubmit fires before every prompt the user sends. If that hook throws, hangs, or crashes, it degrades or breaks the user's session. So the hook code is paranoid by construction:
- The recall function that feeds the injection catches every exception and returns an empty string on any failure. Daemon down, slow, error, malformed response — doesn't matter, it yields nothing and the session proceeds.
- The top-level hook handler wraps its entire body in a try/except and always exits 0. There is no code path where my hook returns a non-zero exit and accidentally blocks a prompt or a tool call.
- If there's no memory to inject — a brand-new workspace with zero notes — the hook emits nothing at all, not an empty JSON envelope. A fresh project should feel exactly like no hook is installed.
The design stance is that the memory injection is a bonus, never a dependency. The session must work identically whether the daemon is up, down, or on fire. If your hook can make the agent worse when it fails, you've built a liability, not a feature.
2. Latency is a tax on every turn
UserPromptSubmit sits between the user hitting enter and the model starting to think. Whatever your hook spends there, the user waits. Claude Code caps this event's hook timeout lower than others for exactly this reason, but a timeout is a backstop, not a budget — you want to be nowhere near it. My recall is designed to return in well under 50 milliseconds, and the hook does the absolute minimum: read stdin, one local HTTP call to an already-running daemon, print, exit. No model loading, no indexing, no network beyond localhost. If your per-turn hook does anything that can take a second, move it off the hot path — make it async, or attach it to a less frequent event.
3. Hooks run arbitrary shell — treat them as such
The official docs are blunt about this and they're right: hooks execute arbitrary shell commands with your full user permissions, automatically. They can read your files and your environment variables. A malicious or careless hook in a shared .claude/settings.json is a genuine attack surface — someone commits a hook, you pull the repo, and now their command runs on your machine the next time you start a session.
Practical defenses: review hook configs before committing to shared repos, exactly as you'd review a Makefile or a git hook; keep personal hooks in the gitignored settings.local.json so they can't leak; and know that enterprise setups can lock this down with allowManagedHooksOnly. In my own design I lean on a smaller mitigation — the settings file never contains logic, only vectr hook <event>, a call into a versioned, inspectable CLI. There's no shell one-liner in the JSON to audit; the behavior lives in code you can read. And the CLI only ever talks to a localhost daemon, so a hook firing in the wrong directory can't reach across to another workspace's memory. That last point is deliberate: the resolver walks up from the current directory to find the daemon that serves this workspace and refuses to fall back to a default — because a default port could belong to an unrelated project and leak its notes into your session.
The Honest Limitations
A few things I've hit that the enthusiastic tutorials leave out.
Injected context is still context. Every note a hook injects costs tokens, every turn, forever. The UserPromptSubmit hook is genuinely helpful because it's disciplined — 3 notes, relevance floor, terse index form. An undisciplined version that injected ten full notes per turn would reintroduce the exact context bloat the memory system exists to fight. Hook injection is a budget you're spending; spend it like one. (I've written more about that tension between recall and bloat elsewhere.)
Debugging is opaque by design. Because a malformed stdout on exit 0 means "no decision" rather than "error," a broken hook fails silently. The session just proceeds as if the hook weren't there. When an injection isn't landing, my first move is always to run the exact command by hand, pipe a sample event JSON into its stdin, and stare at stdout for the one stray character breaking the JSON. There's no substitute; the harness won't tell you.
It's Claude Code-shaped. This whole mechanism is specific to one harness. My pipeline's determinism comes from Claude Code's hook system, and other agent environments have different injection points, or none. If you want the same deterministic-injection behavior elsewhere, you're re-implementing against a different (or absent) surface, and in the worst case you fall back to the very thing hooks let you escape — hoping the model calls the tool. That portability gap is real and I don't have a clean answer to it yet.
Compaction timing isn't fully in your hands. PreCompact fires before compaction, which is great, but auto-compaction triggers on the harness's schedule, near the context limit — not necessarily at a clean task boundary. My snapshot is a safety net, not a substitute for the agent proactively writing important findings to memory as it goes. The hook catches what the agent forgot to save; it works best when there's little to catch.
What Hooks Are Really For
Strip away the specifics and hooks are one idea: a place to put behavior that must not depend on the model's cooperation. Injection, enforcement, persistence, cleanup — anything where "usually" isn't good enough and you need "always." The model is brilliant at the open-ended, judgment-heavy work in the middle of a turn. It is not the thing you want deciding whether the guardrail runs.
For my working-memory tool, hooks are what turned a good idea that demoed well into something that actually holds across sessions. The notes were always there. What was missing was a guarantee that the agent would look — and that guarantee cannot come from the agent. It comes from four small shell commands wired into the right four events, each one paranoid about never breaking the session, each one doing exactly one deterministic job. That's the whole art of it: not clever hooks, but reliable ones.
Sources & Further Reading
The behavior described here traces to the official Claude Code documentation; the event list, exit-code protocol, and security notes are from the hooks reference as of July 2026. The pipeline is my own working-memory tool, covered in more depth in the companion posts below.