The hook contract
Permissions and the sandbox are your policy, baked into the agent. Hooks are how a team bolts its own policy on from the outside — without touching a line of your code. A hook is just a program named in settings.json that Claude Code runs at a lifecycle moment: before a tool runs, after it runs, when a prompt is submitted, when the agent tries to stop. The hook can wave the action through, block it, rewrite its arguments, or inject a note into the next turn. This chapter builds that machinery. This step is the contract you'll implement against — read it carefully, because every field that follows is load-bearing.
The key thing to internalize: a hook is a subprocess, not a callback. Claude Code spawns the configured command, writes a JSON object describing the event to its stdin, and waits. The hook does its work, optionally prints a JSON object to stdout, and exits with a code. Three channels carry the whole conversation — stdin in, stdout out, exit code as the verdict. That's it. Because it's a plain subprocess, a hook can be a shell one-liner, a Python script, or a compiled binary; Claude Code doesn't care what language it's in, only that it honors the JSON-in / JSON-and-exit-code-out contract.
settings.json names a command for an event
│
▼
Claude Code spawns it as a subprocess
│
stdin ──▶ { hook_event_name, tool_name, tool_input, session_id, cwd, ... } (JSON in)
stdout ◀── { decision, reason, updatedInput, additionalContext } (JSON out, optional)
exit ◀── 0 = success, parse stdout │ 2 = block (stderr is the reason) │ other = warn, continue
The exit code is the primary verdict, and it overrides stdout. 0 means success: Claude Code parses whatever JSON the hook printed and acts on it (a decision, an updatedInput, an additionalContext). 2 means block: the action is stopped before it happens, and the hook's stderr is fed back as the reason — the model sees why it was blocked and can adapt. Any other code (1, 127, …) is a non-blocking warning: stderr is surfaced in the transcript, but execution continues as if the hook hadn't objected. A broken hook degrades to a warning, never a silent block.
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/dir",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}
Every hook gets the common fields — session_id, transcript_path, cwd, and the hook_event_name that fired it. Tool-lifecycle events add the two that matter most: tool_name (which tool is about to run, or just ran) and tool_input (its arguments). A PreToolUse hook reads tool_input to decide whether to block or rewrite it; a UserPromptSubmit hook instead gets a prompt field. Your dispatcher's job is to assemble this object faithfully — a hook that can't trust its stdin can't make a correct decision.
The event taxonomy
Hooks fire at named points in the agent's lifecycle. The four you'll work with in this course are the load-bearing ones; the real CLI defines many more (session start/end, compaction, subagent start/stop, notifications), but they all share the same stdin/stdout/exit-code contract — learn these four and the rest are variations.
PreToolUse before a tool runs → can block, or rewrite tool_input, before any side effect
PostToolUse after a tool runs → observe the result; inject context for the next turn
UserPromptSubmit on a new user turn → validate/augment the prompt before the model sees it
Stop when the agent ends → can force the loop to continue ("you're not done")
PreToolUse is the only event that can stop a side effect before it happens — block the call or rewrite its tool_input. PostToolUse runs after the tool already executed, so it can't un-ring the bell; its leverage is observation plus additionalContext — e.g. run the linter after every Edit, and if it fails, inject the errors so the agent fixes them next turn. Both you'll wire up in step 3.
Matchers: which tools a hook fires on
A hook configured for PreToolUse shouldn't fire on every tool — you want the lint hook only on Edit and Write, the dangerous-command check only on Bash. That's what a matcher does: a string in the config, tested against the event's tool_name. There are three flavors, and the rule for telling them apart is purely lexical:
"*" or "" or omitted → match every tool
"Bash" → exact: matches the tool named exactly "Bash"
"Edit|Write" → pipe list: matches "Edit" OR "Write"
"mcp__.*" "^Notebook" → regex: anything with non-word chars is a JS RegExp
A matcher made of only word characters and pipes (/^[\w|]+$/) is treated as an exact name or a literal A|B list — Edit|Write matches the strings "Edit" or "Write", not a regex alternation over characters. Anything containing other characters (., *, ^, () is compiled as a JavaScript regex — which is why mcp__.* matches every MCP tool (mcp__github__create, mcp__memory__store, …). A regex that fails to compile is treated as "no match," never a crash. You'll implement exactly this split in matchHook next.
Claude Code's hook output has grown richer over releases — modern PreToolUse hooks can return a nested hookSpecificOutput.permissionDecision (allow/deny/ask/defer) alongside the top-level decision. This course grades the stable, documented core: top-level decision: "block", reason, updatedInput, and additionalContext, plus the 0/2/other exit-code semantics. That's the contract the next two steps build against; the richer fields layer on top of the same machinery.