Claude Code hooks: deterministic control over an agent

A prompt asks an agent to behave. A hook makes it. If you have written "never edit this file" in CLAUDE.md and watched it happen anyway, this is the mechanism you actually wanted.

By the Continuum team. We build a workbench that runs Claude Code, Codex, and their peers, so the model rates quoted here are the ones our own cost analytics ship with.

The short version

Hooks are commands Claude Code runs at defined points in its loop. Each receives event JSON on stdin. A PreToolUse hook that exits with code 2 blocks the tool call and returns its stderr to the model as the reason. Hooks are configured in the hooks block of a settings file, and also in skill and subagent frontmatter. They are the only deterministic control in Claude Code: a hook that denies a tool call blocks it even in bypassPermissions mode.

What you need to know
  • Hooks are deterministic. Instructions are advisory; hooks are not.
  • Event JSON arrives on stdin; you match on tool_name and tool_input.
  • Exit code 2 blocks a PreToolUse call and feeds stderr back to the model.
  • A hook deny beats bypassPermissions. A hook allow does not beat a deny rule.
  • Five hook types now: command, http, mcp_tool, prompt, agent.

The events worth wiring

Claude Code documents more than thirty hook events as of August 2026. Almost all real configurations use six or seven of them; the rest exist for enterprise auditing and editor integration.

The events people actually configure, and what each can do.

EventFiresCan block?Use for
PreToolUseBefore a tool call executesYesGuards, validation, policy, auto-approval
PostToolUseAfter a tool call succeedsFeedback onlyFormatting, linting, tests
PostToolUseFailureAfter a tool call failsFeedback onlyTurning a raw error into an instruction
UserPromptSubmitWhen you submit a promptYes, erases itInjecting context, redaction
StopWhen Claude finishes respondingYes, keeps it workingCompletion checks, notifications
SubagentStopWhen a subagent finishesYesSame, for delegated work
SessionStartSession begins or resumesNoEnvironment checks, context injection
NotificationClaude Code notifies youNoDesktop alerts when it needs input
PreCompactBefore context compactionYesSaving state, blocking an unwanted compact
SessionEndSession terminatesNoCleanup, logging
One turn read top to bottom with four hook events beside it: UserPromptSubmit can erase the prompt, PreToolUse blocks the call before anything runs, PostToolUse is feedback only after the tool wrote, and Stop can refuse to finish ONE TURN, TOP TO BOTTOM four places a hook can intervene you submit your text, verbatim UserPromptSubmit can erase the prompt a tool is chosen nothing has run yet PreToolUse blocks the call the tool executes here the tool ran it already wrote PostToolUse feedback only the reply ends about to go idle Stop can refuse to stop Exit code 2 blocks the call, and its stderr becomes the reason the model sees.

Configuration and the contract

The shape is the same everywhere: an event name maps to an array of matcher groups, each holding an array of handlers.

01

Add the block to a settings file

Project hooks go in .claude/settings.json and are committed. Personal hooks go in ~/.claude/settings.json.

02

Reference the script by an absolute path

Use "$CLAUDE_PROJECT_DIR" so the hook works whatever directory Claude Code is invoked from, and quote it, because project paths contain spaces.

03

Confirm it registered

Run /hooks. It lists every configured hook grouped by event, with the source file, matcher, and command. The menu is read only; edit the JSON to change anything.

04

Test it with a piped event before trusting it

Pipe a sample JSON payload into the script by hand and check the exit code. A hook that silently fails is worse than no hook, because you will believe the rule is enforced.

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh", "timeout": 5 }
        ]
      }
    ]
  }
}

Matcher syntax

How the matcher string is interpreted.

Characters usedEvaluated asExample
*, empty string, or omittedMatch everythingFires on every occurrence
Letters, digits, _, -, spaces, ,, |Exact string or listBash, Edit|Write
Anything elseJavaScript regex, unanchored^Notebook, mcp__memory__.*

The stdin payload

Every event includes common fields, and each adds its own. A PreToolUse hook on Bash receives something like this:

{
  "session_id": "3f2a...",
  "transcript_path": "/Users/you/.claude/projects/.../3f2a.jsonl",
  "cwd": "/Users/you/code/project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_use_id": "toolu_01...",
  "tool_input": { "command": "rm -rf build" }
}

Exit codes are the API

Exit code semantics.

Exit codeMeaning
0Success. stdout is parsed for JSON output; JSON is only processed on exit 0.
2Blocking error. stdout is ignored; stderr becomes the reason. What "block" means depends on the event.
anything elseNon-blocking error. The action proceeds and the first line of stderr appears in the transcript.

The two hooks worth having

Auto-format after every edit

.claude/hooks/format.sh
#!/usr/bin/env bash
# Format whatever the agent just wrote. Removes style from code review
# entirely, and stops the agent spending turns on formatting itself.
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -z "$file" ] && exit 0
[ ! -f "$file" ] && exit 0

case "$file" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) npx --no-install prettier --write "$file" 2>/dev/null ;;
  *.py)                                    ruff format "$file" 2>/dev/null ;;
  *.go)                                    gofmt -w "$file" ;;
  *.rs)                                    rustfmt "$file" 2>/dev/null ;;
esac
exit 0

Guard the things that must not happen

.claude/hooks/guard.sh - exit 2 blocks and explains.
#!/usr/bin/env bash
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')

block() { printf '%s\n' "$1" >&2; exit 2; }

case "$cmd" in
  *"git push"*--force*|*"git push"*-f*)
    block "Force-push is blocked by a repo hook. Push normally, or ask a human." ;;
  *"rm -rf /"*|*"rm -rf ~"*)
    block "Refusing a recursive delete outside the project." ;;
  *".env"*|*"credentials"*|*"id_rsa"*)
    block "That path holds secrets. Ask for what you need instead of reading it." ;;
  *"git commit"*--no-verify*)
    block "Pre-commit hooks exist deliberately. Do not skip them." ;;
esac
exit 0
Test either hook without an agent.
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git push --force"}}' \
  | .claude/hooks/guard.sh; echo "exit=$?"

JSON output, and auto-approving a prompt

Exit codes cover blocking. For anything richer, exit 0 and print JSON to stdout. This is how you approve a permission prompt without dropping the whole session into a looser mode.

A PreToolUse hook that pre-approves reads under docs/.
#!/usr/bin/env bash
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')

case "$path" in
  */docs/*)
    jq -nc '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "allow",
        permissionDecisionReason: "Documentation is always readable."
      }
    }'
    ;;
esac
exit 0

The output fields you will actually use.

FieldEffect
hookSpecificOutput.permissionDecisionallow, deny, ask, or defer on PreToolUse
hookSpecificOutput.updatedInputRewrites the tool arguments before it runs
hookSpecificOutput.additionalContextInjects text the model reads, without blocking
decision: "block" plus reasonThe JSON equivalent of exit 2, on the events that support it
continue: false plus stopReasonStops Claude entirely
systemMessageShows a warning to you, not to the model
suppressOutputKeeps hook stdout out of the transcript

Five hook types, not one

Most hooks are type: "command". Four other types exist, and two of them are for decisions a shell script cannot make.

Hook types as of August 2026.

TypeRunsDefault timeout
commandA shell command with event JSON on stdin10 minutes
httpPOST of the same JSON to a URL; the response body is the output10 minutes
mcp_toolA tool on an already-connected MCP server10 minutes
promptOne LLM call, Haiku by default, returning {ok, reason}30 seconds
agentA subagent with tool access, up to 50 turns. Experimental.60 seconds
A prompt hook that refuses to let Claude stop with work outstanding.
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check if all requested tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains\"}."
          }
        ]
      }
    ]
  }
}

HTTP hooks are the one to know about for teams: a shared audit service can receive every tool-use event from every developer without shipping a script to every machine. Header values interpolate environment variables, but only those listed in allowedEnvVars, and an admin can restrict the URLs with allowedHttpHookUrls.

Where hooks can live, and being careful with them

Every place a hook can be configured.

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, commit it
.claude/settings.local.jsonOne project, just youNo
Managed policy settingsWhole organisationYes, admin controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledYes
Skill or subagent frontmatterWhile that component is activeYes
  • Hooks run with your full permissions. A hook configured in a repository you cloned is arbitrary code execution. Read .claude/settings.json in unfamiliar repositories before running an agent in them.
  • Keep them fast. A PostToolUse hook runs after every matching call. Two seconds becomes very noticeable, and you can lower the ceiling per hook with the timeout field in seconds.
  • Fail open unless you mean it. A formatter that exits non-zero when the tool is missing will nag on every edit forever.
  • Quote everything. Paths contain spaces. Unquoted $file is a bug waiting for one, and so is an unquoted $CLAUDE_PROJECT_DIR.
  • Know the special timeouts. UserPromptSubmit is capped at 30 seconds, MessageDisplay at 10, and every SessionEnd hook shares a 1.5 second budget that a per-hook timeout can raise to at most 60.
  • Stop hooks fire on every response, not only at task completion, and they do not fire on a user interrupt.
Auditing and disabling.
# inside a session: every configured hook, grouped by event, read only
/hooks

# kill switch, in a settings file
# { "disableAllHooks": true }

Questions people ask

Commands Claude Code runs at defined points in its loop, such as before or after a tool call. They receive event JSON on stdin and can block actions, which makes them the only deterministic control in the system.

Exit with code 2 from a PreToolUse hook. The call is blocked and whatever you wrote to stderr is returned to the model as the reason. The JSON equivalent is exit 0 with hookSpecificOutput.permissionDecision set to deny.

In the hooks block of .claude/settings.json for a project, ~/.claude/settings.json for all your work, or managed policy settings for an organisation. Hooks can also be scoped to a single skill or subagent through its frontmatter.

A hook can tighten but not loosen. A PreToolUse hook returning deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. A hook returning allow does not override a deny rule in settings.

They run with your full permissions, so a hook configured in a repository you cloned is arbitrary code execution. Read the settings file before running an agent in an unfamiliar repo, and use disableAllHooks if you want a kill switch.

Auto-formatting after every edit, which removes style from review entirely. The second is a PreToolUse guard blocking force-pushes, credential reads, and --no-verify commits.

A PostToolUse hook runs after every matching tool call, so anything slow is felt constantly. Keep them under a few hundred milliseconds and set a per-hook timeout in seconds rather than relying on the ten-minute default.

They all run in parallel and all run to completion before the results are merged. For a permission decision the most restrictive answer wins, in the order deny, defer, ask, allow. One hook returning deny does not suppress side effects in another.

Sources

Every figure above was read from these pages on August 2026. Vendors reprice without notice; if you find a stale number, tell us.

  1. Claude Code hooks reference
  2. Claude Code: automate actions with hooks
  3. Claude Code settings reference
Try it

Rules that
actually hold.

Continuum runs the same Claude Code you configure, so your hooks, memory files, and permissions apply unchanged.

free app · your subscriptions · local-first