MPIsaac Ventures
Back to Blog

Claude Code Hooks: What Operators Actually Need to Know (2026)

Michael Isaac
Michael Isaac
Operator. 30 yrs in enterprise AI.20 min read

I started using Claude Code hooks the week an unattended agent ran git push --force over a teammate's branch on a production-adjacent repo in late March 2026. The setup: Claude Code 1.x on acceptEdits permission mode, with a project-scoped allow rule for git that the harness had persisted to .claude/settings.local.json three days earlier when I clicked approve. CLAUDE.md instructions did nothing. The permission layer never re-prompted. Hooks were the only thing that ran the actual shell command before invocation, inspected the arguments, and said no. That incident reframed how I think about agent guardrails.

TL;DR

Use Claude Code hooks for any guarantee you cannot afford to have the model skip: secret scanning, formatter enforcement, blocked-path policy, audit logging, and post-edit validation. Configure them in .claude/settings.json per project for toolchain rules, and in ~/.claude/settings.json for personal guardrails. Hooks are deterministic handlers the Claude Code harness invokes on lifecycle events. The table below is the working subset I keep in my head, verified against code.claude.com/docs/en/hooks on 2026-04-28; the docs are the source of truth before wiring anything new.

EventFires whenCan block?
PreToolUseBefore a tool call executesYes
PostToolUseAfter a tool call completes cleanlyNo (feedback only)
PostToolUseFailureAfter a tool call errors outNo
PostToolBatchAfter a batched tool invocation resolvesNo
PermissionRequestBefore the harness surfaces a permission promptYes (auto-deny / auto-approve)
UserPromptSubmitUser submits a messageYes
UserPromptExpansionAfter prompt expansion, before model sees itNo (context injection)
SessionStartSession bootsNo
SessionEndSession terminatesNo
StopModel finishes its turnYes (re-prompt)
SubagentStopSubagent finishesYes
ConfigChangeSettings reloaded mid-sessionNo
CwdChangedWorking directory changesNo
FileChangedWatched file changes on diskNo
TaskCreated / TaskCompletedTask tool lifecycleNo
NotificationHarness emits a notificationNo
PreCompactBefore context compactionNo

Each handler is one of five types: command (shell command, JSON on stdin, exit codes plus stdout JSON for control), http (POST to a URL), mcp_tool (invoke a registered MCP tool), prompt (run an inline prompt against the model), or agent (delegate to a named subagent). The shell-and-exit-code shape is command specifically, and that is the baseline I recommend for production guardrails: most debuggable, lowest-latency, easiest to reason about under failure. Hooks are a guardrail layer, not a sandbox. Skip hooks if you only need soft guidance the model can interpret; that belongs in CLAUDE.md.

The mental model

A hook is a handler the Claude Code harness invokes when a specific lifecycle event fires. The model does not decide whether the hook runs. The harness does. That distinction is the entire point.

Configuration lives in settings.json under the hooks key, with one entry per event type. Each entry contains a list of matcher rules, and each rule contains one or more handlers. The harness builds a JSON event payload, dispatches it according to the handler type, and reads the response back to decide whether to allow, block, or inject feedback.

Handler types, per code.claude.com/docs/en/hooks fetched 2026-04-28:

  • command: a shell command. The harness pipes the JSON payload onto stdin. Exit 0 to allow, exit 2 to block (with stderr surfaced to the model), or emit a JSON object on stdout for structured control. Lowest latency, easiest to debug, language-agnostic. The default for production guardrails.
  • http: the harness POSTs the payload to a URL. Useful for centralized policy services. Adds a network dependency to every event.
  • mcp_tool: invokes a registered MCP tool. Couples hook lifecycle to MCP server health.
  • prompt: runs an inline prompt against the model. Reintroduces the model into a control path most guardrails want it out of.
  • agent: delegates to a named subagent. Same caveat as prompt, plus subagent startup cost.

Handler type support is event-specific; check the per-event compatibility table at code.claude.com/docs/en/hooks before configuring prompt, agent, or http handlers. SessionStart and Setup, for instance, accept only command and mcp_tool.

The events I lean on most in production:

  • PreToolUse: fires before a tool call executes. Can block the call.
  • PostToolUse: fires after a tool call completes. Cannot block but can inject feedback into the next turn.
  • PostToolUseFailure: different payload, different exit semantics, the right place to capture failure traces or rate-limit retries.
  • PostToolBatch: fires after a batched tool invocation resolves. Wiring a per-call PostToolUse on a batch tool either over-runs or misses the aggregate output.
  • PermissionRequest: fires before the harness surfaces a permission prompt. The right place for fleet-wide policy that must short-circuit prompts.
  • UserPromptSubmit: fires when the user submits a message. Can inject context, block submission, or log.
  • SessionStart: right place to inject environment status, branch, repo health.
  • Stop: fires when the model finishes its turn. Right place to enforce "did you actually finish" checks.
  • SubagentStop: fires when a subagent finishes.
  • SessionEnd: right place for cleanup and audit-log flushing.

What the hook actually sees: event name, session id, tool name and tool input on tool events, user prompt text on UserPromptSubmit, and a transcript_path field pointing at the on-disk JSONL transcript. Stop and SubagentStop additionally include last_assistant_message directly. So "no access to the conversation" is wrong as a categorical claim: a command hook that wants conversation history can read and parse transcript_path. What is true is that the harness does not stream reasoning traces or pre-tool-call deliberation into the payload; if the model thinks about doing something stupid and then does not invoke a tool, no hook fires.

A hook is not a plugin. It does not get model-routed. Treat it like a Git hook: small, fast, deterministic, side-effect-aware.

The landscape

Claude Code is the implementation worth designing against. Other harnesses (Cursor, Aider, Continue, Codex CLI, the various OpenAI agent SDKs) ship their own callback, rule, or middleware shapes. A cross-vendor comparison is intentionally excluded here because this article only verifies Claude Code docs as of 2026-04-28.

What makes Claude Code's implementation distinct:

  1. Hooks are not model-selected at inference time. The harness fires them on lifecycle events. Hook availability, however, is governed by a layered settings hierarchy: managed policy (admin-deployed), user (~/.claude/settings.json), project (.claude/settings.json), and local (.claude/settings.local.json). Per code.claude.com/docs/en/configuration (verified 2026-04-28), disableAllHooks set in user, project, or local settings turns off non-managed hooks only; managed hooks remain loaded and only a managed-layer disableAllHooks can disable them. The related allowManagedHooksOnly flag restricts execution to handlers declared in managed policy. Managed policy is the only layer that survives a teammate adding "disableAllHooks": true to local settings.

  2. Hooks are per-event, not per-tool. A PreToolUse hook can match on any tool name with a regex.

  3. Five handler types, and not every type works on every event. Event compatibility is documented per handler type at code.claude.com/docs/en/hooks; check the per-event table before wiring a non-command handler, or the harness rejects the configuration.

  4. Hooks are stackable, and matching handlers run in parallel. When several entries match the same event, the harness fans them out concurrently and deduplicates identical handler invocations. Total wall time approximates the slowest matching handler plus process startup overhead, not the sum across the matcher list. A single slow handler poisons the budget for the whole event, and splitting it across rules does not help.

  5. Hooks compose with permissions and CLAUDE.md. Permissions decide what the model is allowed to attempt. CLAUDE.md tells it what it should attempt. Hooks decide what actually executes once a tool call is in flight.

There is no public RFC for the hook interface. The contract is the JSON payload schema on stdin and the per-handler-type response semantics. There is no cross-vendor standard. A hook library written today is Claude-Code-specific.

What actually matters operationally

Latency budget. Every PreToolUse hook adds wall time to every tool call. Anecdotally, on a single M2 MacBook Pro against one ~40k-LOC TypeScript monorepo, a naive secret-scanning hook over the staged diff felt like roughly a second of added wait per Edit. I am citing that as personal feel, not a benchmark. Cache aggressively, prefer PostToolUse over PreToolUse where blocking is not required, benchmark on the actual hardware before sizing the hook fleet.

Failure mode under timeout. Hook timeouts are handler-type-specific, not a single number. Verified against code.claude.com/docs/en/hooks on 2026-04-28: command defaults to 600 seconds, agent to 60, prompt to 30. Each entry accepts a timeout override, and the asynchronous variants (async plus asyncRewake) let a long-running handler return control immediately. When a synchronous handler exceeds its budget, the harness reports a hook error and, in my testing, the tool call proceeds with a warning. If a hook is enforcing a hard policy ("never push to main"), do not rely on it completing inside the timeout. Encode the same rule as a permission deny so the harness never even attempts the call.

Idempotency. PreToolUse can fire on a tool call that the user then rejects at the permission prompt. The hook ran. The tool did not. If the hook has side effects, make them idempotent or move the side-effecting work to PostToolUse.

Stdin payload stability. The JSON schema for hook input is documented, but the contract has shifted between releases before and will again. Pin parsing to the fields actually used, fail closed on missing or unexpected fields, validate types before reading, and log the raw payload to disk during the first week of any new hook so the next drift is debuggable.

Observability of the hook itself, without leaking secrets. Hooks do not fail silently in the way I used to think; per the reference, a non-zero exit surfaces a hook error notice and the first line of stderr inline, with full stderr captured to the debug log. The real operational trap is fail-open behavior: exit codes other than 2 typically let execution continue, timeouts on synchronous handlers do not block the underlying tool call, and http handlers that return non-2xx are non-blocking by default. The trap I walked into the first time: logging every tool input verbatim to ~/.claude/hook-audit.log happily persists Bash commands, file paths, prompt text, and occasionally credentials, world-readable, forever. The current posture I run:

  • Default to metadata-only lines: timestamp, event name, tool name, payload byte-length, exit code.
  • Raw-payload logging is opt-in via an env var (CLAUDE_HOOK_DEBUG=1), bounded to a debugging window.
  • Redact known-sensitive shapes before logging (regexes for AWS keys, GitHub tokens, JWT-shaped strings, Authorization: headers).
  • Create the log file with a restrictive umask (umask 077) so the file is 0600. Verify ownership.
  • Rotate via logrotate on a size threshold, with a short retention window (I use 7 days).

Source of truth for configuration. Handlers can be loaded from managed policy, user settings, project settings, local settings, installed plugins, skills and subagents that ship hook definitions, session-scoped hooks created via /hooks, and a small set of harness built-ins. The merged set is what /hooks shows at session start, and that output is the only source of truth I trust. Run /hooks on a fresh checkout, treat the loaded set as canonical, and add a SessionStart handler that diffs the loaded list against an expected manifest and warns on drift.

Lock-in. Hooks written today are coupled to Claude Code's payload schema, exit-code semantics, and handler-type taxonomy. Migrating a hook suite to another agent harness is rewrite, not port.

Detailed teardowns

Pattern 1: PreToolUse Bash command policy

The first hook I deploy in any shell-enabled session. It reads the proposed bash command, applies a denylist for destructive operations (git push --force on main, rm -rf without specific paths, dropping production databases), and exits with code 2 to block.

Position in the mental model: defense-in-depth on top of permissions. Permissions ask once and remember; this re-evaluates every command against current state.

Architecture: a 60-line Python script that parses the JSON payload, extracts the bash command string, runs it through a series of regex checks, and either exits 0 (allow) or exits 2 with stderr explaining the block. The harness shows stderr to the model.

Tradeoffs: regex is brittle; a determined model can bypass with command substitution or chained commands. I treat this as a guardrail against accidents, not a security control. A pure-regex hook with no subprocess calls is the cheapest shape a command handler can take, and the wait is imperceptible relative to the tool call itself.

When it is the right call: any agent session with shell access on a machine that has anything you cannot afford to lose.

When it is the wrong call: ephemeral sandboxed environments where the worst case is "kill the container."

Pattern 2: PostToolUse formatter and type-check feedback

After Edit or Write on a .ts, .py, or .go file, I run the project's formatter on the changed file and surface any errors back to the model as additional context for the next turn.

Architecture: a shell script that switches on file extension, runs the appropriate tool (prettier --write, ruff format, gofmt -w), captures stderr, and emits a JSON object on stdout shaped as {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "..."}}. Plain stdout from a PostToolUse hook does not reach the model; the only events where exit-0 stdout is injected as context are UserPromptSubmit, UserPromptExpansion, and SessionStart. If the goal is to push the model harder on the failure rather than just informing it, swap the JSON emission for exit 2 with the message on stderr. PostToolUse fires after the tool has already run, so exit 2 is a feedback channel, not a gate. If the requirement is "the bad state never lands," the rule belongs earlier in the lifecycle (PreToolUse inspecting the proposed content) or later (a Stop hook running the same checks against the working tree).

Tradeoffs: this turns Claude Code from "an autocomplete that can run commands" into something that actually delivers code passing CI. Use file-scoped formatters, never project-wide, and measure on your own box before shipping.

When it is the right call: any repo with a real CI pipeline.

When it is the wrong call: exploratory sessions or notebooks where speed beats correctness.

Pattern 3: UserPromptSubmit context injection

Inject repo state into every user prompt automatically: current branch, dirty files, last commit message, current ticket if you can grep for it. The harness appends the injected context.

Architecture: a shell command that runs git status --porcelain, git log -1, and a project-specific lookup, then prints the assembled context block.

Tradeoffs: one of the few hooks that improves model output rather than constraining it. Token cost is real and grows with what the injector emits. Cap the injection to a fixed byte budget (I use 2 KB), truncate git status --porcelain past the first N entries with a "+ M more" suffix, never include full diffs. The other risk is staleness; compute fresh per prompt.

When it is the right call: long-running sessions in active repos.

When it is the wrong call: greenfield repos where there is no state to inject yet.

Pattern 4: Stop hook for verification gating

Fires when the model says it is done. Runs a project-specific verification script (typecheck, lint, fast unit suite). If verification fails, exits with code 2 and structured feedback. The model is forced to address the failure rather than declaring victory.

Architecture: a Makefile target like make verify-quick that wraps the cheap checks. Tune until it finishes in a handful of seconds on the local box. The hook calls it, captures output, and decides.

Tradeoffs: massively reduces the "the model claims it works" failure mode. The cost is wall time added to every turn end.

When it is the right call: implementation sessions. Always.

When it is the wrong call: research and exploration.

The standards layer

There is no cross-vendor standard for agent hooks as of 2026-04-28. The Model Context Protocol (MCP) covers tool exposure, not lifecycle hooks. OpenTelemetry's GenAI semantic conventions cover observability output, not control-plane interception. The closest thing across vendors is the LangChain callbacks system, but that is a Python library convention, not a harness-level guarantee.

Claude Code itself is no longer a single-shape interface; the five handler types (command, http, mcp_tool, prompt, agent) cover different operational stories. I default to command for production guardrails. The other four are useful, but each adds dependencies you have to operate: HTTP needs a service that does not page you at 2am, mcp_tool couples lifecycle to MCP server health, and prompt/agent reintroduce the model into a control path you almost certainly wanted out of it.

Cross-vendor: hooks written for Claude Code are Claude-Code-shaped. Patterns transfer (rebuilding a Bash-policy command hook for any agent with shell access is straightforward), but the configuration schema, payload contract, and handler-type taxonomy are Anthropic-specific. If you are building an agent platform yourself, the Claude Code command hook contract is a reasonable starting point: JSON on stdin, exit codes for control, stdout for feedback injection.

Things nobody talks about

Hooks run with your shell environment, not a sanitized one. A PreToolUse hook that calls git will use whatever git config the user has, including any core.sshCommand overrides, any aliases that shell out, any pre-commit hooks the global git config installs. I have seen a hook hang for 90 seconds because it called git status in a repo with a slow git-lfs filter configured globally. The fix in my setup: any hook that calls external commands sets GIT_OPTIONAL_LOCKS=0, sets explicit env vars, and gets benchmarked on both a clean machine and a real one before it ships.

Multiple matcher rules on the same event run in parallel, with deduplication. Total wall time per event is roughly the slowest matching handler plus process startup overhead, not the sum. Stacking four 300ms handlers costs ~300ms per call, not 1.2s. The corollary that bites: a single slow handler poisons the budget for the whole event. Profile the slowest matcher first, not the longest list.

The hook payload schema can change between Claude Code versions without a migration warning. In a release I caught in early 2026, a PreToolUse Bash payload that previously exposed the command at tool_input.command started arriving with the same string nested under a wrapper field, and my parser raised KeyError. The harness logged the non-zero exit but did not surface it inline, so tool calls stopped being blocked while I was looking at green "session started" status. Worse, the script I had shipped exited 1 on the unhandled exception, and Claude Code treats exit 1 as non-blocking for most hooks, so the Bash calls went through anyway. Fail-closed parsing is the only safe posture: wrap json.load in try/except, type-check the wrapper object, validate that tool_input.command is a string before reading it, and on any parse error or missing required field emit a terse stderr message and exit 2.

Hooks do not see model thinking, only tool calls. A model that decides to do something stupid can describe its plan in detail in its reasoning, and the PreToolUse hook will never see that text. Auditing agent intent requires a separate observability layer. A UserPromptSubmit context injector plus a PostToolUse logger captures most of the operational picture, but the reasoning traces themselves are gone unless something is scraping the API directly.

Local settings interact with project settings in ways worth verifying on your version. The documented precedence is that user, project, and local settings merge, with later layers overriding earlier ones for the same key. What I have not been able to reproduce reliably across Claude Code versions is exactly what happens when .claude/settings.local.json sets hooks to an empty object versus omits the key entirely, and how disableAllHooks interacts with per-event entries. The safe operational stance: run /hooks at session start in a fresh checkout, treat that output as the source of truth, and add a SessionStart hook that diffs the loaded handler set against an expected list and warns on drift.

Implementation patterns

A minimal PreToolUse Bash policy hook

#!/usr/bin/env python3
"""
.claude/hooks/bash_policy.py

Toy denylist for obvious-shape destructive shell commands. Hooked into
PreToolUse for Bash. Verified against the PreToolUse payload schema as
documented at code.claude.com/docs/en/hooks on 2026-04-28.
Re-verify field names (tool_name, tool_input.command) against the docs
before upgrading Claude Code; the schema has been renamed in past releases
without a migration warning.

This is NOT a security control. The regex below catches a handful of
literal patterns and nothing more. It will miss `git push origin main -f`,
`git push -f origin HEAD:main`, refspecs, shell aliases, command
substitution, and anything chained behind `&&` or `;`. The actual
guarantee for "do not force-push to main" must come from GitHub branch
protection plus a Claude Code permission deny rule (which the harness
evaluates with full argv parsing and `if`-condition support); this script
is a tripwire for the easy mistakes, not a policy engine.
"""
import json
import re
import sys

DENY_PATTERNS = [
    (re.compile(r"\bgit\s+push\b.*\b(--force|--force-with-lease|-f)\b"),
     "git push with a force flag (verify branch protection enforces the actual rule)"),
    (re.compile(r"\brm\s+-rf\s+/(?!tmp|var/tmp)"),
     "rm -rf on a top-level path outside /tmp"),
    (re.compile(r"\bDROP\s+(DATABASE|TABLE)\b", re.IGNORECASE),
     "destructive SQL"),
    (re.compile(r"\bcurl\s+[^|]*\|\s*(sh|bash|zsh)\b"),
     "curl-pipe-shell pattern"),
]

def fail_closed(msg: str) -> int:
    print(f"BLOCKED by bash_policy.py (fail-closed): {msg}", file=sys.stderr)
    return 2

def main() -> int:
    try:
        payload = json.load(sys.stdin)
    except (json.JSONDecodeError, ValueError) as e:
        return fail_closed(f"unparseable hook payload: {e}")
    if not isinstance(payload, dict):
        return fail_closed("payload is not a JSON object")
    tool_name = payload.get("tool_name")
    if tool_name is None:
        return fail_closed("missing tool_name in payload")
    if tool_name != "Bash":
        return 0
    tool_input = payload.get("tool_input")
    if not isinstance(tool_input, dict):
        return fail_closed("missing or malformed tool_input")
    command = tool_input.get("command")
    if not isinstance(command, str):
        return fail_closed("missing tool_input.command")
    for pattern, reason in DENY_PATTERNS:
        if pattern.search(command):
            print(f"BLOCKED by bash_policy.py: {reason}", file=sys.stderr)
            print(f"Command: {command}", file=sys.stderr)
            return 2
    return 0

if __name__ == "__main__":
    sys.exit(main())

Two things to notice. Every failure path returns exit code 2, not 1. Claude Code treats exit 1 as non-blocking for most hooks, so a parser exception on a command handler that exits 1 silently lets the Bash call proceed. Exit 2 is the documented blocking signal, and that is what fail-closed actually means.

Wired into .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/bash_policy.py" }
        ]
      }
    ]
  }
}

The $CLAUDE_PROJECT_DIR prefix matters. Hooks run in the harness's current working directory, which is not guaranteed to be the project root after the model has cd'd into a subdirectory. A relative path will resolve against whatever cwd happens to be active, and a command-not-found failure on a command hook is non-blocking, so a guardrail wired by relative path can fail open without anyone noticing.

The actual force-push guarantee comes from GitHub branch protection plus a permission deny rule in settings.json's permissions.deny block.

A PostToolUse formatter feedback hook

#!/usr/bin/env bash
# .claude/hooks/format_feedback.sh
# Runs project formatter on the file just edited, returns errors as
# additionalContext via the PostToolUse JSON output contract.
set -uo pipefail

payload="$(cat)"
file_path="$(echo "$payload" | jq -r '.tool_input.file_path // empty')"
[ -z "$file_path" ] && exit 0
[ ! -f "$file_path" ] && exit 0

case "$file_path" in
  *.ts|*.tsx|*.js|*.jsx)
    output="$(npx --no-install prettier --write "$file_path" 2>&1)" ;;
  *.py)
    output="$(ruff format "$file_path" 2>&1 && ruff check "$file_path" 2>&1)" ;;
  *.go)
    output="$(gofmt -w "$file_path" 2>&1)" ;;
  *) exit 0 ;;
esac

if [ -n "$output" ]; then
  jq -n --arg ctx "format_feedback: $output" '{
    hookSpecificOutput: {
      hookEventName: "PostToolUse",
      additionalContext: $ctx
    }
  }'
fi
exit 0

The mistake worth flagging: plain stdout from a PostToolUse hook does not reach the model. For PostToolUse, the harness only forwards text to the next turn through the JSON object on stdout shown above, or via exit code 2 with the message on stderr. I shipped the wrong version of this hook for two weeks before noticing the model never reacted to lint output.

PostToolUse cannot block. The tool already ran by the time the event fires. If the goal is hard enforcement, the rule belongs earlier in the lifecycle: a PreToolUse matcher that inspects the proposed file content, a Stop hook that runs the same checks against the working tree, or a permission deny.

A Stop hook for verify-before-done

#!/usr/bin/env bash
# .claude/hooks/verify_quick.sh
# Runs cheap verification before letting the model claim completion.
set -uo pipefail

if ! make -q verify-quick 2>/dev/null; then
  output="$(make verify-quick 2>&1)"
  rc=$?
  if [ $rc -ne 0 ]; then
    cat <<EOF >&2
verify_quick failed (exit $rc). The session is not complete.
Output:
$output
EOF
    exit 2
  fi
fi
exit 0

This is the hook that turns "the model said it was done" into "the model is actually done." Pair with a make verify-quick target that runs typecheck and a 10-second test slice.

A working version of all three patterns lives in the companion repo at github.com/MPIsaac-Per/agentinfra-examples under claude-code-hooks/.

Decision framework

When deciding whether to write a hook:

  1. Can CLAUDE.md handle it? If the rule is "the model should generally avoid X," put it in CLAUDE.md. Hooks have a real cost.
  2. Is it deterministic and machine-checkable? If yes, hook. If no (judgment, taste, design), prompt.
  3. Does it need to fire on every event of a type, or just sometimes? Hooks fire every time. If only sometimes, the rule belongs in CLAUDE.md or in a permission rule.
  4. What is the failure mode if the hook hangs or crashes? If "the tool call goes through anyway" is unacceptable, do not put the rule in a hook; put it in a permission deny.

The shortest decision tree I use:

  • Need to block a class of operation absolutely: PreToolUse hook with deny exit.
  • Need to ensure something runs after every code change: PostToolUse hook.
  • Need to inject context per turn: UserPromptSubmit hook.
  • Need to gate "done" on real verification: Stop hook.
  • Need agent observability: PostToolUse logger writing to a file or remote sink.
  • Need anything else: probably not a hook.

Where the space is heading: within 18 months, hook-shaped lifecycle interception will be a feature in every serious agent harness, and there will be at least a draft of a vendor-neutral schema. Right now Claude Code is the most polished implementation. Operators running an agent stack on Claude Code in 2026 want at least a Bash policy hook, a verify-quick Stop hook, and a context-injection UserPromptSubmit hook in place before letting any session run unattended. Everything else is optimization.

What I would avoid: building a hook framework. The interface is simple enough that a 60-line script per hook beats any abstraction. Every framework I have seen for hooks ends up being a worse version of just writing the bash.