teachyou.ai academy
← All posts
Claude Codedeveloper toolsautomationCLIAI coding assistant

Automating Your Workflow with Claude Code Hooks

Pramod Dutta · Jul 7, 2026 · 12 min read

Claude code hooks are shell commands that Claude Code runs automatically at defined points in its execution lifecycle, before a tool runs, after it finishes, when a session starts, or when Claude is about to hand control back to you. They give you a deterministic way to enforce rules, log activity, block dangerous actions, and trigger external systems, without relying on the model to remember or follow instructions in a prompt. If you have ever wanted Claude Code to always run your linter before committing, always block edits to a .env file, or always ping Slack when a task finishes, hooks are the mechanism built for exactly that.

This article walks through what hooks are, the event types available, how to configure them, and several complete, runnable examples you can adapt today.

Why claude code hooks exist

Prompt instructions are suggestions. You can tell Claude Code in a system prompt or CLAUDE.md file to "always run tests before finishing" or "never touch the secrets/ directory," and most of the time it will comply. But "most of the time" is not the same as "always," and for anything that touches production data, credentials, or a shared codebase, you want guarantees, not good intentions.

Hooks close that gap. They are configured outside the model's context, in a JSON settings file, and the harness (the Claude Code CLI itself) executes them deterministically, regardless of what the model decides to do. A hook can:

  • Inspect the exact tool call Claude is about to make and reject it before it happens.
  • Run after every file edit to auto-format or auto-lint the changed file.
  • Fire when a session starts to load project-specific context.
  • Fire when Claude is about to stop responding, to force a verification step.
  • Log every command Claude runs to a file for audit purposes.

Because hooks run as plain shell commands with structured JSON input on stdin, you can write them in bash, Python, Node, or anything else that can read stdin and exit with a status code. There is no plugin API to learn beyond "read JSON, do something, optionally return JSON or a nonzero exit code."

The hook events

Claude Code exposes hooks at several points in its lifecycle. The exact event names have stabilized around this set:

  • PreToolUse: fires before Claude executes any tool call (Bash, Edit, Write, Read, and so on). Your hook receives the tool name and its full input. You can approve, deny, or ask for confirmation.
  • PostToolUse: fires after a tool call completes. Useful for formatting, linting, or logging the result of what just happened.
  • UserPromptSubmit: fires when you submit a new prompt, before Claude sees it. Good for injecting extra context or blocking certain requests outright.
  • Stop: fires when Claude is about to finish its turn and stop responding. You can use this to force additional verification, for example "did you run the test suite?"
  • SubagentStop: the same idea, scoped to a subagent finishing its work.
  • SessionStart: fires when a new Claude Code session begins, ideal for loading environment info or reminding Claude of project conventions.
  • SessionEnd: fires when a session terminates, useful for cleanup or final logging.
  • PreCompact: fires before Claude Code compacts (summarizes) the conversation history, letting you snapshot anything you want preserved.
  • Notification: fires when Claude Code sends a system notification, for example when it is waiting on your input.

Not every event needs a hook. Most real-world setups use two or three: PreToolUse for guardrails, PostToolUse for formatting and logging, and Stop for verification.

Where hooks live

Hooks are configured in a settings.json file, at one of three scopes:

  • ~/.claude/settings.json: applies to every project on your machine.
  • .claude/settings.json in a repo: applies to that project, checked into version control, shared with your team.
  • .claude/settings.local.json: applies to that project but stays out of git, useful for machine-specific paths or secrets.

The shape of a hook entry is consistent across events. Each entry matches a tool pattern (for tool-scoped events like PreToolUse and PostToolUse) and lists one or more commands to run:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/check_bash.py"
          }
        ]
      }
    ]
  }
}

The matcher field is a regex-like string matched against the tool name. Use "Bash" to catch only shell commands, "Edit|Write" to catch file mutations, or "*" (or omit the matcher) to catch everything. Events that are not tool-scoped, like SessionStart or Stop, don't need a matcher.

What a hook receives and returns

Claude Code sends your hook a JSON payload on stdin. For a PreToolUse hook on the Bash tool, that payload looks roughly like:

{
  "session_id": "abc123",
  "transcript_path": "/Users/you/.claude/projects/xyz/transcript.jsonl",
  "cwd": "/Users/you/code/my-project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf build/",
    "description": "Clean the build directory"
  }
}

Your hook script reads this from stdin, decides what to do, and communicates back in one of two ways:

  1. Exit code. Exit 0 to allow the action to proceed silently. Exit 2 to block the action; anything your hook writes to stderr is shown to Claude as the reason for the block, so it can adjust and try something else.
  2. Structured JSON on stdout. For finer control, print a JSON object with fields like decision ("approve" or "block") and reason. This lets you approve with a warning, or block with a message that gets fed back into Claude's context automatically.

Here is a minimal PreToolUse guard written in Python that blocks any Bash command touching a .env file:

import json
import sys

data = json.load(sys.stdin)

if data.get("hook_event_name") != "PreToolUse":
    sys.exit(0)

command = data.get("tool_input", {}).get("command", "")

if ".env" in command:
    print("Refusing to run a command that touches .env files.", file=sys.stderr)
    sys.exit(2)

sys.exit(0)

Point a PreToolUse hook with matcher "Bash" at this script, and Claude Code will refuse to execute the command, showing Claude the stderr message so it understands why and can propose an alternative.

Practical hook 1: auto-format after every edit

A common first hook is running your formatter automatically whenever Claude edits a file, so you never end up reviewing a diff full of whitespace noise. This uses PostToolUse matched against Edit|Write:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/format_changed_file.js"
          }
        ]
      }
    ]
  }
}

format_changed_file.js reads the tool input to find which file path changed, then runs the right formatter based on the extension:

const fs = require("fs");

let input = "";
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
  const data = JSON.parse(input);
  const filePath = data.tool_input && data.tool_input.file_path;

  if (!filePath || !fs.existsSync(filePath)) {
    process.exit(0);
  }

  const { execSync } = require("child_process");

  try {
    if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
      execSync(`npx prettier --write "${filePath}"`);
    } else if (filePath.endsWith(".py")) {
      execSync(`black "${filePath}"`);
    }
  } catch (err) {
    console.error(`Formatter failed: ${err.message}`);
    process.exit(1);
  }

  process.exit(0);
});

Because this runs after every single edit, not just at the end of a session, Claude sees the formatted file immediately if it reads the file again, which keeps its mental model of the code accurate.

Practical hook 2: block risky bash commands

Beyond .env files, most teams want a general deny-list for destructive shell commands. This is one of the highest-value hooks you can add, because it catches mistakes regardless of whether the risky command came from the model reasoning poorly or from a prompt injection buried in a file Claude read.

import json
import re
import sys

DENY_PATTERNS = [
    r"rm\s+-rf\s+/",
    r"git\s+push\s+.*--force",
    r"git\s+reset\s+--hard",
    r":\(\)\{.*\};:",  # fork bomb
    r"chmod\s+-R\s+777",
]

data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")

for pattern in DENY_PATTERNS:
    if re.search(pattern, command):
        print(f"Blocked by policy: command matched pattern '{pattern}'.", file=sys.stderr)
        sys.exit(2)

sys.exit(0)

Wire it to PreToolUse with matcher "Bash". This is the same category of guardrail you'd want in any CI pipeline that runs an autonomous agent, deterministic, unavoidable, and independent of the model's judgment in the moment.

Practical hook 3: verification before Claude stops

The Stop event is one of the more underused hooks, but it's powerful for enforcing a "did you actually check your work" step. A Stop hook can inspect the transcript, see whether tests were run in the current turn, and if not, block the stop and tell Claude to run them first.

import json
import subprocess
import sys

data = json.load(sys.stdin)
transcript_path = data.get("transcript_path")

ran_tests = False
if transcript_path:
    with open(transcript_path) as f:
        for line in f:
            if '"command"' in line and "npm test" in line:
                ran_tests = True
                break

if not ran_tests:
    print("You haven't run the test suite yet. Run `npm test` before finishing.", file=sys.stderr)
    sys.exit(2)

sys.exit(0)

This turns "please run tests before you're done" from a hopeful instruction in CLAUDE.md into an enforced checkpoint. Claude cannot end its turn without either running the tests or explaining why it can't (at which point you, the human, see the block and can intervene).

Practical hook 4: session start context injection

SessionStart hooks are a clean way to give Claude fresh, accurate context every time a session begins, instead of relying on a static file that might drift out of date. For example, injecting the current git branch and any open TODO count:

#!/bin/bash
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "not a git repo")
todo_count=$(grep -r "TODO" --include="*.ts" --include="*.py" . 2>/dev/null | wc -l | tr -d ' ')

cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "Current branch: $branch. Open TODOs in the codebase: $todo_count."
  }
}
EOF

The additionalContext field gets folded into Claude's context at session start, so it starts every session already knowing which branch you're on and roughly how much unfinished work is lying around, without you having to say it.

Practical hook 5: notify Slack when a long task finishes

Combine a Stop hook with a webhook call to know the moment Claude wraps up a long-running task, useful if you kick something off and walk away:

import json
import sys
import urllib.request

WEBHOOK_URL = "https://hooks.slack.com/services/REPLACE/WITH/YOURS"

data = json.load(sys.stdin)
cwd = data.get("cwd", "unknown project")

payload = json.dumps({"text": f"Claude Code finished a turn in {cwd}"}).encode("utf-8")
req = urllib.request.Request(WEBHOOK_URL, data=payload, headers={"Content-Type": "application/json"})

try:
    urllib.request.urlopen(req, timeout=5)
except Exception:
    pass  # never block Claude's stop over a notification failure

sys.exit(0)

Note the pattern here: notification hooks should almost never exit with a blocking code. If the Slack call fails, you still want Claude's turn to end normally, so wrap the network call in a try/except and always exit 0 at the bottom.

Debugging hooks

When a hook doesn't seem to fire, or fires with unexpected results, check these in order:

  • Settings file location and JSON validity. A syntax error in settings.json silently disables all hooks in that file. Validate it with any JSON linter before assuming the hook logic is wrong.
  • Matcher pattern. If your matcher is "Edit" but Claude used Write to create a new file, the hook won't fire. Use "Edit|Write" when you want both.
  • Executable permissions. Shell scripts referenced directly (not through python3 or node) need chmod +x.
  • Stdin parsing. The most common bug is a hook script that expects command-line arguments instead of reading JSON from stdin. Test your script manually: echo '{"tool_input":{"command":"ls"}}' | python3 my_hook.py.
  • Exit codes. Remember exit 0 means allow, exit 2 means block with the stderr message shown to Claude. Any other nonzero exit code is typically treated as a non-blocking error, which is worth confirming against your installed version's behavior since this detail has shifted across releases.

Putting it together: a layered setup

A reasonable production setup for a team repo combines several of the hooks above:

  1. PreToolUse on Bash with a deny-list for destructive commands (safety).
  2. PreToolUse on Edit|Write that refuses changes to files matching secrets or lockfile patterns you don't want auto-modified (safety).
  3. PostToolUse on Edit|Write that runs your formatter and linter (quality).
  4. Stop that checks whether tests ran this turn (quality).
  5. SessionStart that injects branch name and recent commit summary (context).

Check these into .claude/settings.json in the repo so every teammate, and every CI agent using Claude Code, inherits the same guardrails automatically. Keep the hook scripts themselves in .claude/hooks/ alongside the settings file, versioned like any other code, since they are effectively policy encoded as scripts.

FAQ

What language do I write claude code hooks in? Any language that can read JSON from stdin and exit with a status code. Bash, Python, and Node are the most common choices because they're available on virtually every developer machine without extra setup, but a compiled binary works just as well.

Can hooks modify what Claude sees, not just approve or block? Yes. Hooks can return structured JSON on stdout with an additionalContext field (on events like SessionStart and UserPromptSubmit) that gets injected into Claude's context, or a reason field on a block that explains why, which Claude reads and can act on.

Do hooks slow Claude Code down? Each hook adds the latency of running your script, typically milliseconds for a simple regex check, longer if you're shelling out to a formatter or making a network call. Keep PreToolUse hooks fast since they run on the critical path of every matching tool call; save slower operations like Slack notifications for Stop or SessionEnd.

Are hooks the same thing as MCP servers? No. MCP (Model Context Protocol) servers expose new tools and resources for Claude to call, expanding what it can do. Hooks intercept and control the built-in lifecycle of tool calls Claude already has, enforcing policy around actions rather than adding new capabilities. They're complementary: use MCP to give Claude new abilities, use hooks to govern how any ability gets used.

Can I have multiple hooks on the same event? Yes. List multiple matcher blocks, or multiple commands within a single matcher block, and Claude Code runs them in order. If any hook in the chain exits with a blocking code, the action is blocked regardless of what the others return.

Will a hook run for subagents too, or only the main session? Tool-scoped hooks like PreToolUse and PostToolUse fire for tool calls made by subagents as well as the main agent, since subagents route through the same tool-execution path. SubagentStop specifically fires when a subagent finishes, separate from the main session's Stop event, which lets you apply different verification rules to subagent work than to the top-level task.

What's the simplest hook worth setting up first? A PreToolUse deny-list on Bash for destructive commands. It's a handful of lines, it never produces false positives on legitimate work, and it protects against the worst-case outcome, an accidental rm -rf or a forced push over shared history, with a single, permanent guardrail.