A hook is a way to insert a deterministic reaction into Claude Code's lifecycle: it gets a JSON event, runs a handler and can return a decision or context. Unlike a text instruction the model may or may not heed, a hook is programmatic control: it fires always and the same way. Hence the responsibility: an error in a hook can block work or run an unwanted command, so hooks are reviewed and tested as production code, not as a note.
A handler comes in five types, and the choice depends on the task. command runs a local process, gets the event on stdin and returns a result via the exit code and stdout. http sends a POST with the event JSON to a URL. mcp_tool calls an already connected MCP tool. prompt gives a single-turn model decision. agent is an agentic verifier with tools; it is experimental and costlier than the rest. Most deterministic checks are done with exactly the command handler - it is the most predictable.
A hook's arrangement is easiest to understand on PreToolUse. matcher first filters the event group (for example, Bash), the optional if condition applies one permission-rule condition to the tool input, and matching handlers run in parallel. The script reads stdin in full and returns JSON with a decision. It helps to see such a handler once: below is a script that, on an attempt at recursive deletion, returns permissionDecision deny with a clear reason.
The return value has its own semantics to know. An empty successful output means "the hook made no decision, continue the ordinary flow" - silence is not a ban. And an important caveat: a naive substring check must not be the sole defense against all forms of a shell command, because it is easy to bypass. A hook is an additional deterministic layer, while permission deny and the sandbox remain primary; forgetting this means taking a fragile string check for a real boundary.
There are many events, and practically they group by purpose. There are session and configuration events, prompt and display, tool lifecycle, agents and tasks, memory and context, file system and worktree, MCP and turn end. The full current list is placed in the reference; it helps to see the map of groups once, to understand which event to attach a check to - blocking a dangerous command at PreToolUse, auto-lint at PostToolUse, audit at SessionStart.
| Event group | Examples | Typical task |
|---|---|---|
| session/config | SessionStart, ConfigChange, SessionEnd | Environment setup, audit |
| prompt/display | UserPromptSubmit, Notification | Validation, UX |
| tool lifecycle | PreToolUse, PostToolUse, PermissionDenied | Block, format, test, audit |
| agent/task | SubagentStart/Stop, TaskCompleted | Orchestration controls |
| memory/context | InstructionsLoaded, PreCompact | Provenance, continuity |
| turn end | Stop, StopFailure | Verification, notifications |
Return codes and the output format also obey rules. Code 0 reports success; for command handlers the behavior of a nonzero code depends on the event, and exit 2 is often used as a blocking error with a message in stderr. But you should prefer structured JSON output where the event supports a decision: it explicitly sets the reason and depends less on terminal text, which easily diverges from intent. An explicit decision is more reliable than one guessed from the exit code.
You debug hooks methodically, not by guesswork. claude --debug shows the matched hooks, their exit codes and output. First you test the script separately, feeding it a sample JSON on stdin, then run one real event. You set a short timeout, print no secrets, use absolute or project-root paths and do not depend on an interactive shell profile - otherwise a hook that worked locally will silently break in CI or for a colleague with a different environment.
The typical hook failures are predictable and costly. A naive substring check as the sole defense, bypassed by the first non-standard command form. A long hook with no timeout, hanging the work. A secret printed to stdout or stderr. A dependence on the shell profile, breaking the hook off your machine. And text output where the event supports a structured decision. Review and test hooks as code, keep permission and the sandbox primary, and return the decision as explicit JSON.
#!/usr/bin/env node
// PreToolUse: event on stdin -> a decision in JSON
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => (raw += c));
process.stdin.on('end', () => {
const event = JSON.parse(raw || '{}');
const command = event.tool_input?.command || '';
if (/\brm\s+-[^\n]*r[^\n]*f\b/.test(command)) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Recursive force deletion is blocked'
}
}));
}
// empty output = no decision, continue the ordinary flow
});