Build your own Claude Code
Hooks·6 min

The hook contract

0 / 1

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.

The hook contract — three channels
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.

What a PreToolUse hook reads on stdin
{
  "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.

The core hook events
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")
Pre vs Post — the same event name, different powers

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:

Three matcher flavors (tested against tool_name)
"*"  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
The lexical rule that decides pipe-list vs regex

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.

The contract we grade is a documented subset

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.