Module 9 — Hooks: automate and lock down Claude's behavior
Module 7 showed how to refuse an action with a permission rule, and module 8 how to walk back a step that went too far with /rewind. One gap remains: the behavior you want guaranteed, not one that depends on the model's mood. Formatting every edited file, refusing every write to .env, rerunning tests before handing control back — these are not suggestions, they are laws. A hook is the mechanism that makes them deterministic.
A hook is not a suggestion
A line in CLAUDE.md like "always run ruff after a modification" suggests a behavior to the model; it will apply it often. A hook, by contrast, is a shell command, an HTTP endpoint, an MCP tool, or a prompt that Claude Code executes itself at a specific point in the lifecycle. When the event fires, the hook runs: the model has no choice. That shift from probabilistic to deterministic is what makes hooks the tool of choice for teams that enforce internal rules without fighting for them on every prompt.
The exact catalog of events
The official documentation lists the events Claude Code exposes to hooks.
| Event | When it fires |
|---|---|
SessionStart / SessionEnd | Session starts, resumes, or ends |
Setup | --init-only or -p --init in CI |
UserPromptSubmit / UserPromptExpansion | Prompt submitted, before processing or expansion |
PreToolUse / PostToolUse / PostToolUseFailure | Around a tool call |
PostToolBatch | After a batch of parallel calls |
PermissionRequest / PermissionDenied | Permission requested or denied |
Notification / MessageDisplay | Notification, text display |
SubagentStart / SubagentStop | Birth and end of a subagent |
TaskCreated / TaskCompleted | Task lifecycle |
Stop / StopFailure | End of turn, possibly on error |
TeammateIdle | Agent-team teammate idle |
InstructionsLoaded / ConfigChange | A CLAUDE.md, a rule, or a config changes |
CwdChanged / DirectoryAdded / FileChanged | Current directory, /add-dir, or a watched file |
WorktreeCreate / WorktreeRemove | Worktree creation or removal |
PreCompact / PostCompact | Context compaction |
PreModelSwitch / PostModelSwitch | Model change |
Elicitation / ElicitationResult | An MCP server requests input |
A tool event comes with a matcher that filters: "Bash", "Edit|Write", "mcp__github__.*". A matcher made only of letters, digits, _, -, spaces, ,, and | is treated as an exact match; any other character switches to an unanchored JavaScript regular expression (Edit.* captures Edit and NotebookEdit; write ^Edit$ to pin it).
A second, finer filter exists: the if field on each handler, which accepts the same syntax as permission rules. if: "Bash(rm *)" only fires if the subcommand matches; if: "Edit(*.ts)" targets TypeScript only.
The input and output format
A command hook receives a JSON object on stdin and returns its result through the exit code, optionally enriched with JSON on stdout. Common fields are session_id, prompt_id, transcript_path, cwd, permission_mode, hook_event_name. A PreToolUse also gets tool_name, tool_input, and tool_use_id. Paths arrive with native separators (backslashes on Windows).
Exit codes:
0with no stdout: success, no decision. Normal flow continues.0with a JSON object on stdout: Claude reads the decision fields. Recommended path.2: blocking error on events that can block (PreToolUse,UserPromptSubmit,Stop,SubagentStop,TaskCreated,TaskCompleted,ConfigChange,PreCompact,PreModelSwitch,PostToolBatch,Elicitation,WorktreeCreate). The contents of stderr serve as the message.- Any other code: non-blocking error; the action continues, a notice appears in the transcript.
JSON on stdout accepts two layers. The universal fields are continue (setting false stops Claude entirely), stopReason, systemMessage, and terminalSequence. The hookSpecificOutput field carries decisions specific to each event.
| Events | Decision fields |
|---|---|
UserPromptSubmit, PostToolUse, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact | decision: "block" at the root, reason |
PreToolUse | hookSpecificOutput.permissionDecision (allow / deny / ask / defer), permissionDecisionReason, updatedInput, additionalContext |
PermissionRequest | hookSpecificOutput.decision.behavior (allow / deny), updatedInput, message |
PermissionDenied | hookSpecificOutput.retry: true |
SessionStart, SubagentStart | Context via hookSpecificOutput.additionalContext |
Elicitation / ElicitationResult | hookSpecificOutput.action (accept / decline / cancel), content |
Trace events (Notification, SessionEnd, PostCompact, CwdChanged, FileChanged…) | Side effects, no decision |
Three events let you rewrite content on the fly: PreToolUse.updatedInput (arguments before execution), PermissionRequest.decision.updatedInput (prompt side), PostToolUse.updatedToolOutput (result visible to Claude, the tool having already acted).
The four hook types
The type field on each handler chooses the executor.
command— shell. Receives JSON on stdin, responds through exit code and stdout. Fields:command,args(exec form, no shell),async,shell(bashorpowershell).http— API endpoint. Fields:url,headers($VARinterpolation restricted toallowedEnvVars),timeout. Claude POSTs the JSON.mcp_tool— a tool on a connected MCP server. Fields:server,tool,input(${tool_input.file_path}substitution).prompt— the JSON becomes$ARGUMENTSinside a prompt evaluated by a Claude model, which returns decision JSON.
A fifth type, agent, is marked experimental.
Where to declare a hook
Hooks live in several places and stack across layers: a project hook does not replace a user hook.
| Location | Scope |
|---|---|
~/.claude/settings.json | All your projects |
.claude/settings.json | One project, versioned |
.claude/settings.local.json | One project, you only |
| Managed policy settings | The entire organization |
Plugin: hooks/hooks.json | When the plugin is enabled |
Frontmatter of a SKILL.md or a subagent | The session, once invoked |
The /hooks command opens a read-only browser that lists every configured hook, its event, its matcher, and its source (User Settings, Project Settings, Local Settings, Plugin Hooks, Session Hooks).
Two placeholders let you write portable paths: ${CLAUDE_PROJECT_DIR} (project root, also exposed as an environment variable) and ${CLAUDE_PLUGIN_ROOT} (inside a plugin, the install location).
Kiosque: three hooks that do the work
The Kiosque team codes once and for all what it used to remind by hand: format every edited Python file, refuse any write to migrations/ and .env, do not hand control back while tests are red.
Hook 1 — Format automatically after a modification
Placed in .claude/hooks/format-python.sh and made executable, this script reads the PostToolUse JSON on stdin, extracts the path, only acts on .py files, and chains ruff format then ruff check --fix. It exits 0: side effect, no decision.
#!/usr/bin/env bash
# .claude/hooks/format-python.sh
INPUT=$(cat)
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
if [[ -z "$FILE_PATH" || "$FILE_PATH" != *.py ]]; then
exit 0
fi
ruff format "$FILE_PATH" >/dev/null 2>&1
ruff check --fix "$FILE_PATH" >/dev/null 2>&1
exit 0
Hook 2 — Forbid writes to migrations/ and .env
A PreToolUse with matcher Edit|Write refuses any sensitive path. The rule is doubled in module 7's permissions.deny, but the hook confirms at action time and gives Claude a reason so it can offer an alternative.
#!/usr/bin/env bash
# .claude/hooks/protect-paths.sh
INPUT=$(cat)
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
case "$FILE_PATH" in
*/migrations/*|*/.env|*.env.local)
jq -n --arg p "$FILE_PATH" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Write forbidden in " + $p + ". Use an Alembic migration or a secret manager.")
}
}'
exit 0
;;
esac
exit 0
The hook returns a structured deny decision: Claude receives the reason and reacts — proposing an Alembic migration instead of pushing back.
Hook 3 — Only hand back control if make test passes
The Stop hook blocks the end of the turn when tests fail. Classic trap: without a guard, it fires on every re-continuation and loops. Add an explicit guard through stop_hook_active.
#!/usr/bin/env bash
# .claude/hooks/require-green-tests.sh
INPUT=$(cat)
ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false')
if [[ "$ACTIVE" == "true" ]]; then
exit 0
fi
if make test >/tmp/kiosque-test.log 2>&1; then
exit 0
fi
TAIL=$(tail -n 20 /tmp/kiosque-test.log | jq -Rs .)
jq -n --argjson tail "$TAIL" '{
decision: "block",
reason: ("The `make test` suite fails. Excerpt:\n" + $tail)
}'
The decision: "block" field at the root is the exit contract for Stop: Claude regains control with the reason, fixes, reruns. On the second continuation, stop_hook_active is true and the hook yields.
The resulting .claude/settings.json
The three hooks are assembled in a single project settings file, versioned with Kiosque. ${CLAUDE_PROJECT_DIR} guarantees that the scripts are found from the project root.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-python.sh" }
]}
],
"PreToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.sh" }
]}
],
"Stop": [
{ "hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-green-tests.sh", "timeout": 300 }
]}
]
}
}
Nadia commits the file; Karim and Lea pick up the hooks on their next git pull. No line in CLAUDE.md is needed to remind Claude to format: it happens without it.
Debugging and pitfalls to avoid
Three mistakes recur. The script never runs: a non-executable script fails with exit code 127 (transcript: Failed with non-blocking status code); commit the +x bit. On Windows, the exec form fails on .cmd shims: use shell: powershell. The JSON output has no effect: a shell profile that prints on startup pollutes stdout and breaks parsing; stdout must contain only the JSON object. claude --debug acts as a journal. A hook loops forever: an ill-guarded Stop produces cycles; stop_hook_active serves as the guard, and Claude Code cuts off after eight consecutive blocks.
A PreToolUse hook on Bash(git commit *) can refuse a commit without anyone remembering to type a skill. Reserve skills for workflows where a human decides; use hooks for guardrails where no decision is needed.
Security: hooks run with the session's privileges, without a controlling terminal on macOS and Linux. Never run unaudited code from a plugin or external source as a hook.
Summary
- A hook is deterministic: when the event fires, the command runs, regardless of the model's decision.
- Events cover the whole lifecycle: session, turn, tool, notification, compaction, model switch.
- Input arrives as JSON on stdin; output goes through the exit code (0, 2, other) and a JSON object on stdout with
decision,reason, orhookSpecificOutput. - Four stable types: command, HTTP, MCP tool, prompt evaluated by a model; a fifth,
agent, is experimental. - Hooks are declared in settings (user, project, local), in a plugin, or in the frontmatter of a skill or subagent, and stack across layers.
- For Kiosque, three hooks are enough: auto-format, protected paths, green tests before end of turn.
Next module: Subagents, parallel agents, and agent teams — how to delegate long-running work to an isolated agent, run five investigations in parallel, and orchestrate a full team from the main conversation.