Skip to main content

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.

EventWhen it fires
SessionStart / SessionEndSession starts, resumes, or ends
Setup--init-only or -p --init in CI
UserPromptSubmit / UserPromptExpansionPrompt submitted, before processing or expansion
PreToolUse / PostToolUse / PostToolUseFailureAround a tool call
PostToolBatchAfter a batch of parallel calls
PermissionRequest / PermissionDeniedPermission requested or denied
Notification / MessageDisplayNotification, text display
SubagentStart / SubagentStopBirth and end of a subagent
TaskCreated / TaskCompletedTask lifecycle
Stop / StopFailureEnd of turn, possibly on error
TeammateIdleAgent-team teammate idle
InstructionsLoaded / ConfigChangeA CLAUDE.md, a rule, or a config changes
CwdChanged / DirectoryAdded / FileChangedCurrent directory, /add-dir, or a watched file
WorktreeCreate / WorktreeRemoveWorktree creation or removal
PreCompact / PostCompactContext compaction
PreModelSwitch / PostModelSwitchModel change
Elicitation / ElicitationResultAn 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:

  • 0 with no stdout: success, no decision. Normal flow continues.
  • 0 with 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.

EventsDecision fields
UserPromptSubmit, PostToolUse, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompactdecision: "block" at the root, reason
PreToolUsehookSpecificOutput.permissionDecision (allow / deny / ask / defer), permissionDecisionReason, updatedInput, additionalContext
PermissionRequesthookSpecificOutput.decision.behavior (allow / deny), updatedInput, message
PermissionDeniedhookSpecificOutput.retry: true
SessionStart, SubagentStartContext via hookSpecificOutput.additionalContext
Elicitation / ElicitationResulthookSpecificOutput.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 (bash or powershell).
  • http — API endpoint. Fields: url, headers ($VAR interpolation restricted to allowedEnvVars), 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 $ARGUMENTS inside 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.

LocationScope
~/.claude/settings.jsonAll your projects
.claude/settings.jsonOne project, versioned
.claude/settings.local.jsonOne project, you only
Managed policy settingsThe entire organization
Plugin: hooks/hooks.jsonWhen the plugin is enabled
Frontmatter of a SKILL.md or a subagentThe 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 hook over a skill

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, or hookSpecificOutput.
  • 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.