
Hooks That Hold: Make Behaviour Stick
Writing hooks that hold
A rule written into a prompt is advice. A hook is a mechanism. When you have
told the model the same thing three times and it still drifts, stop writing
rules and write a hook.
This is the short version of what survives contact with a real project.
Which events are worth your time
There are many hook events. Three carry most of the value:
UserPromptSubmit — runs before the model sees the message. Whatever the
hook prints is prepended to the prompt. Use it to put facts in front of the
model that it would otherwise have to remember to look up: current state,
open tasks, settled values.
This is the single highest-leverage hook. It converts "remember to check the
notes" (unreliable) into "the notes are already on screen" (reliable).
PreToolUse — runs before a tool call and can block it. ReturnpermissionDecision: "deny" with a message and the call does not happen; the
model reads your message instead. Use it to stop one specific known-bad action
while leaving everything else alone.
Stop — runs when the turn ends. Use it to catch things that should have
happened before the model went quiet: unsaved state, cleanup skipped, a
promise made but not executed.
PostToolUse is useful for keeping a running status line, but it fires on
every tool call, so keep it to a few milliseconds.
The contract
A hook is any executable. It gets JSON on stdin and communicates through
stdout and its exit code.
For UserPromptSubmit, stdout is injected into the prompt. For PreToolUse,
print JSON with a permissionDecision. For everything else, stdout is mostly
for you.
Exit code 0 or the turn may not proceed. This is the rule that bites.
Test before you install — this is not optional
A broken hook runs on every turn. If it crashes or hangs, you have wedged the
session, and the thing you would normally use to fix it is the thing that is
broken.
Two habits prevent this:
Run it with temporary settings first.
claude --settings '{"hooks":{ ... }}' -p "a test prompt"
Temporary settings are not written to your config. If the hook misbehaves,
nothing is left behind.
Feed the script the payloads it will actually receive. Not just the happy
path — the empty one and the malformed one too:
echo '{"prompt":"normal"}' | your-hook # expect: exit 0
echo '' | your-hook # expect: exit 0
echo 'not json at all' | your-hook # expect: exit 0
All three must exit 0. A hook that only handles well-formed input will fail
the first time something unusual arrives, which is exactly when you are busy.
Know the escape hatch before you need it: claude --safe-mode starts
without hooks.
Shape of a hook that will not wedge you
import json, sys
def main():
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
if not raw.strip():
return # nothing to do, quietly
try:
data = json.loads(raw)
except Exception:
return # malformed — do not crash the turn
# ... your logic; print what you want injected ...
if __name__ == "__main__":
try:
main()
except Exception:
pass # never let an exception escape
sys.exit(0) # always 0
Three things make this safe: it tolerates empty stdin, it tolerates malformed
stdin, and it cannot exit non-zero. Copy this skeleton before you write logic.
Keep the output small
A UserPromptSubmit hook's output rides along on every request. Twenty lines
is fine. Two hundred is a tax you pay forever, on every message, including the
ones where it is irrelevant.
Make it conditional. Detect whether this particular message needs the context
and stay silent otherwise. A hook that prints nothing most of the time is a
good hook.
When a hook is the wrong tool
If the behaviour depends on judgement that changes case by case, a hook will
fight you. Hooks are for invariants: things that should be true every single
time, with no exceptions worth reasoning about.
If you find yourself adding conditions to a hook to handle exceptions, that is
the signal it should have been a rule after all.


