Claude Code hooks: the ones that actually matter
Hooks are the only way to make Claude Code do something reliably rather than when the model remembers to. They are also the easiest place in the whole tool to break your own session. This is what each event is good for, and the rules that keep a hook from becoming a problem.
The shape of it
A hook is a shell command that Claude Code runs when a named event fires. It receives a
JSON object on stdin describing the event, and it communicates back in two
ways: its exit code, and optionally JSON on stdout. You
declare hooks in ~/.claude/settings.json, or a project's
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "python3 \"$HOME/.mytool/observe.py\"", "timeout": 5 }
]
}
]
}
}
Note the shape: each event holds an array of groups, each group has an optional
matcher (which tool names it applies to) and an array of commands. Multiple
tools can therefore coexist on one event, which is the point — append a group, never
overwrite the array.
The events, and what each is for
| Event | Fires | Good for |
|---|---|---|
| SessionStart | When a session begins or resumes | Warming a state file, recording a session boundary, injecting standing context. |
| UserPromptSubmit | After you send a prompt, before the model sees it | Observing prompt shape. Unusually, its stdout can be added to the model's context — the cleanest place to inject facts. |
| PreToolUse | Before a tool call runs | Guardrails. The one event that can genuinely stop something from happening. |
| PostToolUse | After a tool call completes | Formatters, linters, tests, and observation. The workhorse. |
| Notification | When Claude Code notifies you | Bridging to your own desktop or phone notifications. |
| SubagentStop | When a subagent finishes | Noticing subagent churn — a lot of them finishing in a short window says something. |
| Stop | When the main turn finishes | The seam. Nothing is running; the human is back. The only polite place to say anything. |
| PreCompact | Before the transcript is compacted | Archiving something you are about to lose. |
| SessionEnd | When the session ends | Flushing state, closing out the day's totals. |
Event names and payload fields have moved between versions. Rather than trusting any table, including this one, capture the real thing once:
#!/usr/bin/env bash
# log every payload, then get out of the way
cat >> /tmp/claude-hooks.jsonl
exit 0
Register that on the events you care about, work for ten minutes, then read the file.
You will see session_id, transcript_path, cwd and
hook_event_name on everything, plus event-specific fields like
tool_name, tool_input and the tool's response.
Exit codes are the actual API
This is the part that surprises people, and the part worth getting right before you write anything ambitious:
| Exit | Meaning |
|---|---|
0 | Success. Carry on. |
2 | Blocking. Claude Code treats stderr as feedback for the model and acts on it — on PreToolUse the tool call is blocked, on UserPromptSubmit the prompt is, and on Stop the model is pushed to keep going instead of finishing. |
| anything else | Non-blocking error. Noted, session continues. |
The trap: a Stop hook that exits 2 tells Claude not
to stop. Do that by accident — an unhandled exception in a script that was only meant to
observe — and you have built a machine that will not let the turn end. Any hook whose job
is observation should be wrapped so that every path exits 0.
For richer control there is structured JSON on stdout, with fields for continuing or
halting and a systemMessage for surfacing a line to the human. Which matters
because of the next point.
Plain stdout is not a way to talk to the user
A natural first instinct is to echo something from a Stop hook
and expect it to appear. It does not — plain stdout from Stop is discarded.
If a hook needs to say something to a person, emit JSON with a
systemMessage. (UserPromptSubmit is the exception where stdout is
meaningful, and even there it goes to the model, not to you.)
This is exactly why Tempo's reset offer is delivered as a JSON
systemMessage from a Stop hook rather than a print. There was no
other reliable way to put one line in front of a human without touching the turn.
Hooks run as you, with your permissions
A hook is arbitrary code executed automatically, with your shell, your keys and your filesystem. Treat installing someone's hooks the way you would treat installing a shell plugin from a stranger:
- Read the script. If a project's hooks are not short enough to read, that is information.
- Check for network calls. A hook that fires on every tool use and phones home is a telemetry pipe on your machine.
- Require a settings backup. Anything merging JSON into
settings.jsonshould copy it aside, with a timestamp, first. - Set timeouts. Per-hook
timeoutis there for a reason; a hook without one is a session that can hang. - Be wary of project-level hooks from repositories you did not write. Cloning a repo should not mean running its automation.
A pattern: observing a session without interrupting it
Most interesting hook work is not automation, it is observation — building a picture of how a session is going. The pattern that holds up:
- Write, do not think. On high-frequency events
(
PostToolUsefires constantly), append one compact line to a log and exit. No parsing, no scoring, no I/O you cannot bound. - Score on the quiet events. Do the real computation on
StoporUserPromptSubmit, where the cadence is human-paced. - Cache to a state file. Anything the statusline needs should already be sitting in a file, precomputed. See building a statusline.
- Store shapes, not contents. You almost never need the prompt text. Word counts, error flags and salted hashes of paths answer the same questions without creating a file you would not want read aloud.
- Speak only at seams. If your tool has something to say, say it on
Stop, when nothing is running — never mid-agent. - Ship a doctor. Hook chains fail silently and invisibly. A script that checks each link — registered, firing, writing, scoring — and reports which one is broken will save you and your users hours.
Why "always exit 0" is a product decision, not just a safety rule. A hook that can fail loudly is a hook people uninstall the first time it interrupts them. Exiting 0 on every path — including the failure paths — is what makes an always-on tool tolerable to live with.
What Tempo does with them
Tempo registers on SessionStart, UserPromptSubmit,
PostToolUse, SubagentStop, Stop and
SessionEnd. The observer writes derived shapes; the scorer turns them into a
load index covering both friction and accumulated dose; the statusline reads cached state;
and the offer only ever appears on Stop, at most twice a day, citing the
signal that earned it. Every hook exits 0 unconditionally, with a five-second timeout,
and nothing in the codebase makes a network call. python3 ~/.tempo/doctor.py
is the doctor described above.
A worked example you can read
Tempo's hooks are short, local, and deliberately boring. Read them before you run them — that is the whole recommendation of this page applied to itself.
free in betapython 3no network calls