3Hooks and the SDK

Defining hooks

5 min read832 words

You understand what hooks are. Now build one. This lesson is the four-step process you will follow every time — whether you are blocking file access, running tests, or wiring in a type checker. The running example throughout this lesson and the next is the one from the PreToolUse diagram: prevent Claude from ever reading the .env file.

The four-step process

Every hook comes together the same way.

  1. Choose PreToolUse or PostToolUse. Before the tool runs, or after it?
  2. Determine which tool calls to watch — the matcher.
  3. Write the command that receives the tool call data via stdin as JSON.
  4. Use exit codes to provide feedback (and, for PreToolUse, to block).

Walk through each step with the .env protection scenario in mind.

Step 1 — Choose the hook type

You want to prevent Claude from reading .env. "Prevent" means block the tool call before it happens. That is PreToolUse — PostToolUse cannot block.

Step 2 — Identify the tools to match

Which tools read file contents? Two of them: the Read tool and the Grep tool. Both can surface the contents of .env — Read directly, Grep by searching inside it — so the hook needs to match both.

If you are not sure which tools a given Claude Code instance exposes, there is a trick: ask Claude directly. "Give me a bullet point list of all the tool names you have access to." Claude will dump the list. Now you know exactly what to match.

The matcher syntax uses pipes for OR:

Read|Grep

That matches either tool. Not a lowercase l, not a capital I — the pipe symbol above the Return key.

Step 3 — Write the command

The command receives a JSON payload on stdin describing the tool call. The shape is:

{
  "session_id": "…",
  "tool_name": "Read",
  "tool_input": {
    "file_path": "/absolute/path/to/.env"
  }
}

Your command's job is to read that JSON, decide whether to allow or block, and exit with the right code.

A minimal Node.js command looks like this:

// hooks/read_hook.js
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
  const toolArgs = JSON.parse(raw);
  const readPath = toolArgs.tool_input.file_path || toolArgs.tool_input.path;
  // decision logic goes here — see Step 4
});

The command can be anything your shell can run — a shell script, a Python file, a compiled binary. Node is used here because the rest of the sample project uses it.

Step 4 — Exit codes control behaviour

The hook talks to Claude Code through its exit code and stderr.

Exit code 0 — allow the tool call to proceed normally. This is the default for "everything is fine."

Exit code 2 (PreToolUse only) — block the tool call. Anything the command wrote to stderr is sent back to Claude as feedback, so Claude knows why it was blocked and can try a different approach.

For PostToolUse, exit code 2 does not block — the tool has already run — but stderr output is still fed back to Claude as feedback. That is the self-correcting loop from the previous lesson.

For the .env example, you want: if the path includes .env, write a clear explanation to stderr and exit 2. Otherwise exit 0.

if (readPath.includes(".env")) {
  console.error("You cannot read the .env file. It contains secrets.");
  process.exit(2);
}
process.exit(0);

console.error() writes to stderr specifically — that is how the feedback reaches Claude. console.log() would go to stdout and be ignored.

Putting the four steps together

Here is the complete plan for the .env protection hook:

  1. Type: PreToolUse — you need to block.
  2. Matcher: Read|Grep — both tools can expose file contents.
  3. Command: a Node script that parses the stdin JSON, grabs tool_input.file_path (with tool_input.path as a fallback), and checks whether the path includes .env.
  4. Exit code: exit 2 with a stderr message when blocked, exit 0 otherwise.

That is the whole design. The next lesson wires it up in the sample project end to end.

The mental model to keep

Every hook you ever write is these four decisions. Get them straight before you type a line of code and the implementation falls out cleanly.

Key Takeaways

  • 1The four-step hook process is: choose the type, pick a matcher, write a command that reads stdin JSON, and use exit codes to control behaviour.
  • 2Claude Code passes tool call data to your hook on stdin as JSON containing `session_id`, `tool_name`, and `tool_input`.
  • 3Exit code 0 allows the tool call; exit code 2 blocks it in PreToolUse hooks and sends any stderr output back to Claude as feedback.
  • 4Use `console.error()` (or anything else that writes to stderr) to provide feedback — stdout is ignored.
  • 5The Read tool and the Grep tool are the two common tools that can surface file contents, so a matcher of `Read|Grep` is the standard pattern for file-access protection hooks.