Advanced (Updated: 6/1/2026)

Claude Code Hooks Guide: Safe Pre-Checks, Logs, Format, and Tests

A beginner-friendly Claude Code Hooks guide with safe PreToolUse blocks, prompt logs, auto-formatting, tests, and practical guardrails.

Claude Code Hooks Guide: Safe Pre-Checks, Logs, Format, and Tests

Claude Code Hooks are automatic checks that run before or after Claude Code does work. Instead of reminding the agent every time to avoid dangerous commands, format changed files, or run tests, you can put those rules into the lifecycle of the session.

The most useful beginner mental model is simple: PreToolUse is the brake, PostToolUse is the maintenance step, UserPromptSubmit is the intake log, and Stop is the final door check. Hooks do not replace review, permissions, or CI. They make the repeated parts of review harder to forget.

This guide follows the current Claude Code Hooks reference and the Claude Code settings documentation. If you are still designing project memory, read CLAUDE.md best practices. If you want the broader permission model, pair this with the Claude Code permissions guide.

The four hook events to learn first

Claude Code has many hook events, but most teams should start with four. They cover the practical workflow from prompt intake to final response.

EventBest useExample
UserPromptSubmitBefore the user prompt reaches ClaudeSave the request, detect obvious secrets, add lightweight context
PreToolUseBefore a tool executesBlock destructive Bash commands or production operations
PostToolUseAfter a tool succeedsRun formatter, lint, or related tests after edits
StopWhen Claude is about to finishCheck unresolved conflicts, save a summary, remind Claude about missing verification

Use PreToolUse when the action must not happen. Use PostToolUse when the action already happened and you want to clean up or verify. Use UserPromptSubmit when you need observability into the quality of requests. Use Stop when the session should not end with an obvious loose end.

Hook configuration usually lives in .claude/settings.json when the rule is shared by the project. For personal experiments, .claude/settings.local.json is safer because it should stay out of source control. In organizations, managed settings can override project and user settings, so document where the hook is defined and who owns it.

A copy-paste starter configuration

Start with a small set of hooks. This configuration logs prompts, blocks dangerous Bash commands, runs quality checks after file edits, and records a final stop summary.

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/log-prompt.mjs"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/block-dangerous-command.mjs"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/run-quality-checks.mjs",
            "timeout": 120
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/stop-summary.mjs"
          }
        ]
      }
    ]
  }
}

The important detail is the nested hooks array. Modern Claude Code settings define a hook event, an optional matcher group, and then one or more handlers such as type: "command". Older examples that put command directly beside matcher are easy to find, but they are not the structure you should copy today.

Use case 1: block dangerous commands before they run

The first real-world use case is command safety. When an agent is moving quickly, the riskiest moment is not after a destructive command finishes. It is right before the command runs. A PreToolUse hook can inspect the Bash input and deny a clearly unsafe operation.

Save this as .claude/hooks/block-dangerous-command.mjs.

import fs from "node:fs";

const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
const command = String(input.tool_input?.command || "");

const denyPatterns = [
  /rm\s+-rf\s+(\/|\*|\.|\$HOME)/i,
  /cat\s+\.env(\.|$|\s)/i,
  /printenv/i,
  /aws\s+.*\s+delete-/i,
  /gcloud\s+.*\s+delete/i,
  /kubectl\s+delete\s+(namespace|deployment|secret)/i,
  /DROP\s+DATABASE/i
];

const matched = denyPatterns.find((pattern) => pattern.test(command));

if (matched) {
  console.log(JSON.stringify({
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: `Blocked by project hook: ${matched}`
    }
  }));
  process.exit(0);
}

This is not a complete security product. A clever command can evade a small regular expression list. The value is that obvious mistakes become harder: dumping .env, deleting a broad directory, deleting Kubernetes resources, or running a destructive cloud command. For stronger control, combine this with Claude Code permission rules, protected branches, and normal human approval.

Use case 2: log prompts before work starts

The second use case is prompt observability. Many failed Claude Code sessions are not caused by a weak model. They start with a vague request: “fix this”, “clean it up”, or “make it better”. A UserPromptSubmit hook lets you review the prompt later and identify which request patterns produce messy work.

Save this as .claude/hooks/log-prompt.mjs.

import fs from "node:fs";
import path from "node:path";

const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
const dir = path.join(process.cwd(), ".claude", "hook-logs");
fs.mkdirSync(dir, { recursive: true });

const record = {
  time: new Date().toISOString(),
  event: input.hook_event_name,
  cwd: input.cwd,
  prompt: input.prompt || input.user_prompt || ""
};

fs.appendFileSync(
  path.join(dir, "prompts.jsonl"),
  JSON.stringify(record) + "\n",
  "utf8"
);

Keep this log local. Do not send prompts to an external service until you have a privacy policy, retention rule, and approval from the team. Prompts often contain customer names, internal URLs, tokens pasted by mistake, or sensitive business context. Add .claude/hook-logs/ to .gitignore before you make prompt logging part of the workflow.

Use case 3: run format and tests after edits

The third use case is the everyday quality loop. After Claude edits a file, run a formatter and a small verification command. This removes the repetitive instruction “please format and test” from your prompts.

Save this as .claude/hooks/run-quality-checks.mjs.

import fs from "node:fs";
import { execFileSync } from "node:child_process";

const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
const filePath = String(input.tool_input?.file_path || "");

const isSourceFile = /\.(js|jsx|ts|tsx|css|md|mdx|json)$/.test(filePath);
if (!isSourceFile) process.exit(0);

const run = (cmd, args) => {
  try {
    return execFileSync(cmd, args, {
      cwd: process.cwd(),
      encoding: "utf8",
      stdio: ["ignore", "pipe", "pipe"]
    });
  } catch (error) {
    return String(error.stdout || "") + String(error.stderr || "");
  }
};

const messages = [];
messages.push(run("npx", ["prettier", "--write", filePath]));

if (/\.(js|jsx|ts|tsx)$/.test(filePath)) {
  messages.push(run("npm", ["test", "--", "--runInBand"]));
}

console.log(JSON.stringify({
  hookSpecificOutput: {
    hookEventName: "PostToolUse",
    additionalContext: messages.join("\n").slice(-4000)
  }
}));

For a large monorepo, do not run the entire test suite on every write. That is the fastest way to make hooks feel annoying. Start with formatting, then add a related test selector, and only later consider heavier checks. If a command takes more than a few seconds and does not need to block the agent, use an async command hook.

Use case 4: inspect the session at Stop

The fourth use case is the final check. Stop fires when Claude is about to finish responding. It is a good place to record a summary or block only obvious unfinished states, such as unresolved Git conflicts.

Save this as .claude/hooks/stop-summary.mjs.

import fs from "node:fs";
import { execFileSync } from "node:child_process";

const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");

if (input.stop_hook_active) {
  process.exit(0);
}

let status = "";
try {
  status = execFileSync("git", ["status", "--short"], {
    cwd: process.cwd(),
    encoding: "utf8"
  });
} catch {
  process.exit(0);
}

if (status.includes("UU ")) {
  console.error("Git conflict remains. Resolve conflicts before finishing.");
  process.exit(2);
}

fs.mkdirSync(".claude/hook-logs", { recursive: true });
fs.appendFileSync(
  ".claude/hook-logs/stop.jsonl",
  JSON.stringify({
    time: new Date().toISOString(),
    lastAssistantMessage: input.last_assistant_message || "",
    gitStatus: status.slice(0, 2000)
  }) + "\n"
);

Be careful with Stop hooks. If you block too aggressively, the agent can feel trapped in a loop. Check stop_hook_active, keep the condition narrow, and prefer logging over blocking until your team trusts the rule.

Pitfalls that make hooks painful

The first pitfall is treating hooks as a replacement for permissions. Hooks are useful guardrails, but command hooks run with your user permissions. A broken hook can read, modify, or delete anything your account can reach. Validate input, quote paths, skip secrets, and keep destructive operations out of hook scripts.

The second pitfall is running too much work synchronously. Formatting a changed file is fine. Running build, full test, browser tests, and deploy after every edit is usually too much. Keep the fast checks in PostToolUse, move slow checks to async hooks or CI, and report only the final useful output back to Claude.

The third pitfall is saving logs without a policy. Prompt logs and command logs are powerful debugging tools, but they can contain secrets. Decide where logs live, how long they stay, who can read them, and whether they are excluded from Git.

The fourth pitfall is forgetting that matchers are filters, not business logic. A matcher such as Bash chooses which tool event reaches the handler. The handler still needs to inspect the actual command and make the decision.

Masa-style verification note

In practice, the biggest improvement came from returning formatter and test output to Claude through additionalContext. Claude could correct the next step without me pasting logs back into the chat. The second most valuable hook was the dangerous-command block, not because it catches everything, but because it catches the obvious scary cases at the exact moment they matter.

For teams new to Claude Code, I would roll this out in three steps: one week of prompt logs, then edit-time formatting, then a small list of denied Bash patterns. That order gives you evidence before you add friction.

If you want this packaged for your team, the Claude Code training page covers permissions, CLAUDE.md, hooks, and review workflows together. The same pattern can also become a lightweight Gumroad checklist for solo developers who want a repeatable setup.

Summary

Claude Code Hooks are practical guardrails. Use UserPromptSubmit to understand requests, PreToolUse to block risky actions, PostToolUse to verify edits, and Stop to avoid ending with obvious unfinished work.

Start small. A hook that always runs and rarely surprises people is more valuable than a complex automation that everyone disables after two days.

#Claude Code #Hooks #automation #security #testing
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.