The Hook Lifecycle: Everyone Uses Two Events Out of Thirty
A check that belongs once per session ends up running on every tool call, where it is slower and harder to write.
Two events out of thirty-one
Nearly every hook configuration in the wild fires on one of two events: before a tool call, or after one. Those two are genuinely useful and they are a small slice of the lifecycle, which currently spans on the order of thirty distinct events.
The consequence of only knowing two is that rules get attached to the wrong moment. A check that logically belongs once per session ends up running on every tool call, where it is both slower and harder to write. A reaction to a failure ends up inside a handler that also runs on success, full of conditionals that are really two handlers wearing one coat.
Knowing the map is most of the skill. What follows is the shape of it, and five events that repay learning.
The map, roughly
Session boundaries: start and end, plus a setup event. Turn boundaries: the prompt being submitted, its expansion, and the various ways a turn stops. Tool interaction: before, after, after a failure, and after a batch. Permissions: a request being made, and one being denied. Delegation: subagents starting and stopping, tasks being created and completed, a teammate going idle. Environment: instructions loaded, configuration changed, working directory changed, a directory added, a file changed on disk, worktrees created and removed. Context management: before and after compaction. And interaction: notifications, message display, elicitation and its result.
Read that list once and several rules you have been implementing awkwardly will relocate themselves.
Session start: assert the environment before anything happens
The best home for everything about whether this session can be trusted to produce valid results. Toolchain versions, whether the working tree is clean, whether a virtual environment is active, whether the schema file is current, whether a production credential is loaded when it should not be.
Running once per session costs nothing and the output frames everything that follows. It is also the natural place to fail loudly: a session that starts against a dirty tree or a stale dependency install is one whose test results will mislead you later, for the reasons in local execution and reproducibility.
#!/usr/bin/env bash
# Once per session. Cheap, and it prevents a class of wasted hour.
warn=""
[ -n "$(git status --porcelain)" ] && warn="$warn
- working tree is dirty; test results may not reflect committed code"
want=$(cat .node-version 2>/dev/null || echo "")
have=$(node --version 2>/dev/null | tr -d v)
[ -n "$want" ] && [ "$want" != "$have" ] && warn="$warn
- node $have active, repo pins $want"
[ -n "${AWS_PROFILE:-}" ] && case "$AWS_PROFILE" in *prod*)
warn="$warn
- AWS_PROFILE is $AWS_PROFILE — a production profile is loaded" ;; esac
[ -n "$warn" ] && jq -n --arg w "$warn" \
'{hookSpecificOutput: {hookEventName: "SessionStart",
additionalContext: ("Environment warnings:\($w)")}}' && exit 0
echo '{}'
Prompt submission: inject the state that changed
The event that solves a problem people usually attack with better instructions. Repository state moves during a session — someone pushes, you edit a file in your editor, a dependency is installed — and the agent's picture is from whenever it last looked.
A hook on prompt submission can attach the current state to every turn: the branch, the head commit, the dirty files, the last CI result. Small, cheap, always current, and it removes an entire category of confidently wrong edits made against a stale view.
Before compaction: save the constraints
The most valuable event almost nobody uses. When a long session is summarised to fit the context window, what gets lost is disproportionately the constraint material — the thing you said not to touch, the approach already ruled out, the correction from an hour ago.
A hook firing before compaction can write those to a file and ensure they are reintroduced afterwards, which directly addresses the drift described in long-horizon runs. If you run long sessions and have ever watched an agent violate an instruction it followed perfectly two hours earlier, this is the event you wanted.
Tool failure: separate the handler from the success path
A dedicated failure event means retry and diagnosis logic lives apart from the ordinary post-tool handler. That matters because the useful reactions to failure are specific: annotate a permission error with which rule denied it, add the likely cause of a common build failure, or record a pattern of repeated failures against the same target.
Doing that inside a general post-tool hook means every successful call pays the cost of a branch it never takes, and the code is harder to read for the same reason.
Subagent and task events: aggregate rather than interleave
When work is delegated, the interesting moments are the boundaries. A hook on subagent completion can record what was delegated and what came back, which is the only convenient way to see the shape of a session that fanned out.
It is also where cost attribution becomes possible: delegated work is frequently the majority of a session's consumption, and without a boundary event it is invisible in aggregate. The general case for recording this is in auditing agent tool calls.
File changed, and working directory changed
Two environment events worth knowing about. A file-change event lets you react to edits including ones the agent did not make — a colleague's push, your own editor — which is the trigger for invalidating whatever the agent believes about that file.
A working-directory change matters in any repository with more than one project, because the applicable conventions, test command and lint configuration all change with it. A hook that re-states the local rules on entering a subdirectory is a better mechanism than an instruction file trying to describe every project at once, and it pairs with the scoping argument in monorepo context scoping.
Worktree creation: set up the isolated environment
If you use separate working trees for concurrent sessions, a hook on worktree creation is where the setup goes: install dependencies, copy the local configuration that is not in version control, point the environment at a separate database or port.
Without it, every new tree starts broken in the same way and someone fixes it by hand each time. With it, the isolation mechanism is actually usable, which is often the difference between parallel sessions being practical and being theoretically available.
Cadence decides your performance budget
The most useful thing to internalise about this map. Events fire at different rates, and the rate sets what you can afford.
Once per session: spend a second if you need it. Once per turn: tens of milliseconds. Once per tool call: single-digit milliseconds, because two hundred calls in a session multiplies everything. A check that would be fine at session start and unbearable per tool call is the most common performance mistake here, and it is entirely a matter of having attached it to the wrong event.
Before optimising a slow hook, ask whether it needs to run that often. Most expensive checks are answering a question whose answer changes once a session, not once a call.
State between events is where hooks become programs
Each invocation sees one event, so anything cumulative — "warn after the fifth failure", "the constraints captured before compaction" — requires the hook to keep its own state on disk.
That is fine and it is the point at which a hook stops being a filter and becomes a small program with its own failure modes: a stale state file, a concurrent session writing the same path, state from yesterday's run. Key the file by session, clean it up on session end, and be aware that you have crossed a line in complexity that is worth crossing deliberately.
Where to start
Three events, in order of value for most teams. Session start, for the environment assertion — it is cheap, it runs once, and it prevents an entire class of wasted work. Prompt submission, for current repository state. And before-compaction, if you run long sessions, because that is where constraints go to die.
Those three take an afternoon and they address problems that instructions cannot solve, which is the test for whether something belongs in a hook at all. Everything at the tool-call level is refinement on top — useful, well-covered in hooks as deterministic enforcement, and not where the unexploited leverage is.
Ordering and multiple handlers
Once you have more than a couple of hooks on the same event, execution order starts to matter, and it is worth being deliberate rather than discovering the dependency. A hook that annotates a command and one that denies it are not commutative; a filter that trims output and one that parses it need a defined sequence.
The practical approach is to prefer one handler per event that dispatches internally, rather than several registered independently. It is marginally less elegant and it makes the order explicit and reviewable, which is what you want in code that runs on every turn.
Not everything belongs in a hook
The temptation once the map is familiar is to reach for an event for everything. Two counter-cases worth remembering.
Anything expressible as a static list of paths or command patterns belongs in declarative permission configuration, which is easier to audit and cannot have a bug. And anything about whether a change is correct belongs in tests and review, where it can be reasoned about by something that understands the domain. Hooks are for computed decisions at defined moments, and that is a narrower category than "things I want to be true".
Testing across the lifecycle
The wider the set of events you use, the more valuable a synthetic test harness becomes, because most of these events are awkward to trigger on purpose. You do not want to run a two-hour session to check that your before-compaction hook works.
Feed each handler a recorded example of its event and assert on the output. Capture those examples once by logging the raw input of each hook during a normal session; after that the tests run in a second and the events stop being mysterious, which is most of what makes people avoid the less familiar ones.
The map is the deliverable here more than any individual hook. Print the event list, keep it near where you write these, and the next time you find yourself writing a conditional inside a tool-call handler to detect a situation, check whether there is an event for that situation. There usually is, and the version attached to the right event is a third of the code.
The events tell you what the system does
A final reason to read the list even if you write no hooks at all: it is the most concise available description of what your agent tooling actually does. Events for compaction tell you compaction happens and when. Events for subagents and teammates tell you delegation is a first-class concept. Events for worktrees tell you isolation is expected to be common.
That is a more reliable picture than any overview document, because events exist where the implementation needed them. Ten minutes reading the lifecycle gives you a better model of the tool than an afternoon of using it.
And a practical starting move: turn on a logging hook for every event you have never used, for one session, writing the event name and a truncated payload to a file. An hour of ordinary work produces a complete picture of which events actually fire in your setup and how often, which is far more useful for deciding where to attach a rule than reading a list of names.
Most hook configurations use two events out of roughly thirty, and attach rules to the wrong moment as a result. Session start is where environment assertions belong; prompt submission is where you inject repository state that moved; before-compaction is where you save the constraints a long session is about to forget; the failure event keeps retry logic out of the success path; and subagent boundaries are the only convenient place to see delegated work. Match the check to the cadence — per session, per turn, per call — because that is what decides whether it is affordable.