Claude Code hooks: run scripts before and after agent actions

Hooks run your scripts on Claude Code events: format code after edits, block risky commands, send notifications. A walkthrough of the configuration based on the official docs.

6 min · · development · автоматизация

Claude Code hooks are for the moments when asking the agent to "remember to run the formatter" isn't enough. A hook fires on its own at a given event, such as before a tool call or after a file edit. Below are the configuration schema, the exit codes, and three working examples. Everything here was checked against the official Claude Code documentation (the Hooks reference and the Automate actions with hooks guide).

What Claude Code hooks are

A hook is a handler that Claude Code calls at a specific point in the session lifecycle. The handler receives JSON describing the event and can do nothing, run a side effect (format a file, send a notification), or return a decision such as denying a tool call.

The main events:

EventWhen it fires
PreToolUsebefore a tool call; can block it
PostToolUseafter a tool call succeeds
PostToolUseFailureafter a tool call fails
PermissionRequestwhen a tool call needs a permission decision
UserPromptSubmitwhen you submit a prompt, before it is processed
Stopwhen Claude finishes responding
SubagentStart / SubagentStopsubagent spawned / finished
Notificationwhen Claude Code sends a notification
SessionStart / SessionEndsession begins / ends
PreCompact / PostCompactbefore / after context compaction

There are no separate "before file edit" or "before terminal command" events. A file edit is a call to the Edit or Write tool, and a command is a call to Bash (or PowerShell on Windows). You pick the tool with a matcher on PreToolUse and PostToolUse. The full event list (more than thirty) is in the hooks reference.

Where hooks live and how the config is shaped

Hooks are defined in JSON settings files:

  • ~/.claude/settings.json — all your projects (on Windows, ~ means %USERPROFILE%);
  • .claude/settings.json in the project — shared with the team, can be committed;
  • .claude/settings.local.json — local project settings, kept out of git.

The config has three levels: the event, a matcher group, and a list of handlers:

bash
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

A matcher can be a tool name (Bash), a |-separated list (Edit|Write), or a regular expression (mcp__.* for every MCP tool). An empty matcher, "*", or no matcher at all fires on every occurrence of the event.

Handler types (type):

  • command — a shell command; the event JSON arrives on stdin;
  • http — a POST request with the event JSON as the body;
  • mcp_tool — calls a tool on a configured MCP server;
  • prompt — a single-turn check by a Claude model;
  • agent — a subagent that can read files before deciding (marked experimental).

Optional common fields: timeout in seconds (600 by default for command and http), statusMessage for the spinner text, and if, a filter in permission-rule syntax such as "Bash(rm *)".

What a hook receives and how it answers

For PreToolUse on the Bash tool, stdin carries JSON like this:

bash
{
  "session_id": "abc123",
  "cwd": "/home/user/my-project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" }
}

For Edit and Write, tool_input contains file_path. There are no special environment variables carrying the file path or the command text. You read the data from the JSON, usually with jq. Scripts can use CLAUDE_PROJECT_DIR, the project root.

Exit codes:

  • 0 — success. For most events stdout goes to the debug log, but if it starts with { and ends with }, it is parsed as a JSON decision;
  • 2 — blocking error. In PreToolUse the tool call is cancelled and Claude receives your stderr. In PostToolUse the action has already happened, so stderr is just shown to Claude;
  • anything else (including 1) — non-blocking error: the action proceeds and the transcript shows a hook error notice.

This trips a lot of people up: exit 1 does not block anything. Use exit 2 or a JSON decision to deny.

Example: format after every edit

The config above is the example from the official guide. After every Edit or Write, the command pulls the file path out of the JSON and passes it to Prettier. For Python, swap in something like jq -r '.tool_input.file_path' | xargs ruff format. You need jq and the formatter installed.

Example: block rm -rf

Save this as .claude/hooks/block-rm.sh and make it executable (chmod +x):

bash
#!/bin/bash
command=$(jq -r '.tool_input.command')

if echo "$command" | grep -q 'rm -rf'; then
  echo "Blocked: rm -rf is not allowed in this project" >&2
  exit 2
fi
exit 0

Wire it up:

bash
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh" }
        ]
      }
    ]
  }
}

Instead of exit 2, the script can print JSON with hookSpecificOutput.permissionDecision: "deny" and a reason in permissionDecisionReason. The docs stress that hook filtering is best-effort. Use permission rules (permissions.deny) for hard restrictions and treat the hook as an extra layer.

Example: an HTTP hook

bash
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "http",
            "url": "http://localhost:8080/hooks/post-tool-use",
            "timeout": 30,
            "headers": { "Authorization": "Bearer $MY_TOKEN" },
            "allowedEnvVars": ["MY_TOKEN"]
          }
        ]
      }
    ]
  }
}

Claude Code POSTs the event JSON. Header variables are expanded only if they appear in allowedEnvVars, so the token never has to sit in the settings file.

Hooks and token spend when you work through Zukko

command and http hooks run locally and never call the model. Only prompt and agent hooks spend tokens, because they call the model through the same gateway as the main session. The other indirect cost is text a hook hands back to Claude (stderr on exit 2, additionalContext). It lands in the context, so keep it short.

Claude Code itself connects to Zukko through environment variables. Set them in your shell or in the env block of the same settings.json:

bash
{
  "env": {
    "ANTHROPIC_BASE_URL": "https://api.zukko.pro",
    "ANTHROPIC_AUTH_TOKEN": "fetch_your_key",
    "ANTHROPIC_MODEL": "claude-sonnet-5"
  }
}

The base URL has no /v1 because Claude Code adds it itself. In Zukko, claude-sonnet-5 costs $0.3 per 1M input tokens and $0.3 per 1M output tokens. One caveat about caching: vendors charge about 10% of the input price for prompt-cache reads, while Zukko bills cached tokens at the normal input price. For an agent with a large, repeated context, the real gap versus official pricing is smaller than a straight price comparison suggests. For the current model list, call GET https://api.zukko.pro/v1/models or open the Models page in your dashboard. The step-by-step setup is in the Claude Code guide.

Debugging

  • /hooks inside Claude Code opens a read-only list of configured hooks, labelled with the settings file each one came from.
  • Ctrl+O opens the transcript view, where blocks and hook errors show up.
  • claude --debug-file /tmp/claude.log writes a detailed log: which hooks matched, exit codes, stdout and stderr.
  • "disableAllHooks": true turns every hook off temporarily.

FAQ

Why doesn't my hook with exit 1 block the command?

Exit code 1 is a non-blocking error, so the action proceeds. Use exit 2, or JSON with permissionDecision: "deny" in PreToolUse.

How do I run a hook only for certain files?

Narrow the matcher to Edit|Write and check the path in your script: jq -r '.tool_input.file_path', then test the extension. The if field is another option, for example "Edit(*.ts)".

Do hooks run inside subagents?

Yes. Hooks from settings files also fire on subagent tool calls, and the input JSON then includes agent_id and agent_type.

How long does Claude Code wait for a hook?

By default up to 600 seconds for command and http, and 30 seconds for prompt. Change it with timeout. If a PreToolUse command hook times out, the call is not blocked and goes through the normal permission flow.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

Claude Code hooks: PreToolUse, PostToolUse and examples · Zukko