Claude CodeLesson
Claude Code hooks
An instruction asks the model to do something, and the model may forget. A hook is a command that Claude Code itself runs at a fixed point in a session, so it happens every time, whatever the model decides.
Know hooks already? Skip to In Terrarium.
Outside the box · General
The concept
A hook is declared in a settings file under hooks, in three levels: the event it answers, a matcher group that narrows when it fires, and the handlers to run. Events include SessionStart, UserPromptSubmit, PreToolUse and PostToolUse around each tool call, Stop when Claude finishes responding, and SessionEnd. For the tool events the matcher is the tool's name, such as Bash or Edit|Write; for SessionStart it is how the session started, such as startup or resume.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "npx prettier --write ." }]
}
]
}
}
A command hook gets the event as JSON on stdin and answers with its exit code and what it prints. Exit 0 with nothing printed changes nothing. A PreToolUse hook can print a decision that denies the call, and the reason it gives goes back to Claude. A PostToolUse hook can print additionalContext, which Claude reads beside the tool's result. Every hook that matches runs, in parallel, and the strictest decision wins.
~/.claude/settings.json holds your own hooks for every project. A project's .claude/settings.json is committed, so everyone who works in it gets its hooks; .claude/settings.local.json is the uncommitted variant. The hooks guide walks through first examples.
Full referenceClaude Code docs: Hooks reference
In Terrarium
Every hook Terrarium uses is wired in one committed file, .claude/settings.json, which every session loads, a cloud one included. Each command starts with cd "${CLAUDE_PROJECT_DIR:-.}", which moves it to the project's root. The hooks do three jobs.
Setting up a session. On SessionStart, at startup and on resume, a hook runs pnpm install, then scripts/hooks/unshallow-on-start.ts, which fetches full git history when the clone arrived shallow. That script catches its own failures: a fetch that fails never blocks the start.
Landing the session log. A session writes the judgement half of its log, then hooks land it. The first Stop hook checks for that pending log and lands it with scripts/session/log-session.ts; the second, scripts/hooks/harness-tag-stop.ts, blocks the stop once if the reply holds a tag only the harness delivers:
201 "Stop": [
202 {
203 "matcher": "",
204 "hooks": [
205 {
206 "type": "command",
207 "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -f .session-logs/pending.scratch.json ] && node_modules/.bin/tsx scripts/session/log-session.ts --land || true"
208 },
209 {
210 "type": "command",
211 "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && node_modules/.bin/tsx scripts/hooks/harness-tag-stop.ts || true"
212 }
213 ]
214 }
215 ],
SessionEnd, at teardown, and SessionStart, on resume, call the same --land as fallbacks, and the lander never exits with an error. Why the log lands at the end of the turn rather than at teardown is in Session logs.
Checking tool calls. PreToolUse hooks are guards: each can deny one kind of mistaken call and say what to do instead. PostToolUse, PostToolUseFailure and UserPromptSubmit hooks are hints: each can add guidance beside a call's result or a prompt, and never blocks. docs/agents/guards.md lists both kinds. This guard denies a Skill call for a Skill whose text the session already received inline, and its onFailure turns a crash into a refusal:
44 {
45 "matcher": "Skill",
46 "hooks": [
47 {
48 "type": "command",
49 "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && node_modules/.bin/tsx scripts/hooks/skill-inline-guard.ts",
50 "onFailure": "block"
51 }
52 ]
53 },
Each tool hook's matcher names only the tools it checks, never *. The hooks on Bash, Edit and Write start with a short shell filter, such as scripts/hooks/workflow-edit-guard.sh, which exits at once unless the call's text could match.
Why it's built this way
- Hooks over prose. Some rules failed as instructions: agents kept skipping a full-history check, and a fix written into a doc never reached sessions with no reason to open it. So a
SessionStarthook fetches the history, and a guard's deny message states the rule and its fix at the call. The cost: code that runs in every session and needs its own tests. ADR-0027 (docs/adr/0027-prune-trials.md). - Broken guards refuse. A guard that crashes blocks its call rather than letting it through; Blocking hooks tells the crash that led there, its cost and the exceptions. docs/agents/guards.md.
- Starting a script takes a moment. A hook on every tool would pay that start on each read and edit. So each hook names only the tools it checks, and the
Bash,EditandWritehooks start their guard only when a shell filter finds text that could match. The cost: a call the filter doesn't recognise, such as a re-encoded payload, never reaches the guard. docs/agents/guards.md. - Wiring under human review. No test or build sees a hook act in a session, so a careless wiring change could quietly disable a guard while every check passed. So .claude/settings.json and the guards are on the list (Protected paths). The cost: fixing a hook waits for a person. ADR-0004 (docs/adr/0004-objective-safety-gate.md).
What to watch for
- A crashed hook doesn't stop the call unless its entry sets
onFailure: "block"; Blocking hooks covers the default, and docs/agents/guards.md lists the hooks wired to fail open. - Hooks see a subagent's calls too. A subagent's tool calls fire the same hooks as the main session's. Its payload carries
agent_idandagent_type, which a main session's lacks, and that pair is the only signal;agent_typealone also appears when a session runs under--agent(a session started as a named agent). - The wiring is . An agent may edit .claude/settings.json, but a person merges the change. And a session nobody is watching shouldn't try: the edit raises a permission prompt that no one is there to grant.
Try it in your project
Add a PostToolUse hook with the matcher Edit|Write to your project's .claude/settings.json that runs your formatter, then ask Claude to edit a file and check that it came out formatted.
Where next
- LessonBlocking hooks
- LessonPath-specific rules
- LessonSession logs
- LessonScheduled tasks
- LessonMCP servers
Walkthroughs through this Lesson
- WalkthroughAsking an agent for a change
Last updated · Latest source on GitHub ↗