tempo / notes / claude code hooks

guide

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

EventFiresGood for
SessionStartWhen a session begins or resumesWarming a state file, recording a session boundary, injecting standing context.
UserPromptSubmitAfter you send a prompt, before the model sees itObserving prompt shape. Unusually, its stdout can be added to the model's context — the cleanest place to inject facts.
PreToolUseBefore a tool call runsGuardrails. The one event that can genuinely stop something from happening.
PostToolUseAfter a tool call completesFormatters, linters, tests, and observation. The workhorse.
NotificationWhen Claude Code notifies youBridging to your own desktop or phone notifications.
SubagentStopWhen a subagent finishesNoticing subagent churn — a lot of them finishing in a short window says something.
StopWhen the main turn finishesThe seam. Nothing is running; the human is back. The only polite place to say anything.
PreCompactBefore the transcript is compactedArchiving something you are about to lose.
SessionEndWhen the session endsFlushing 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:

ExitMeaning
0Success. Carry on.
2Blocking. 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 elseNon-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.json should copy it aside, with a timestamp, first.
  • Set timeouts. Per-hook timeout is 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:

  1. Write, do not think. On high-frequency events (PostToolUse fires constantly), append one compact line to a log and exit. No parsing, no scoring, no I/O you cannot bound.
  2. Score on the quiet events. Do the real computation on Stop or UserPromptSubmit, where the cadence is human-paced.
  3. Cache to a state file. Anything the statusline needs should already be sitting in a file, precomputed. See building a statusline.
  4. 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.
  5. Speak only at seams. If your tool has something to say, say it on Stop, when nothing is running — never mid-agent.
  6. 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