Requesty
Back|SEP '26AGENTS / BEST PRACTICES
24 MIN READ|

Claude Code Hooks: 10 Examples for Logging, Safety and Costs

Last updated

Claude Code hooks run your handlers at specific points in an agent session. Configure them in settings.json, read event JSON from stdin, and return a decision or perform a local action. Use PreToolUse to prevent an operation, PostToolUse to react after it, and Stop to check a response before Claude pauses. The official hooks reference defines each contract.

These 10 examples cover logging, command and file checks, formatting, notifications, context and cost-related data. The distinction that matters for observability: hooks see local tool activity; model-request metering tells you what the session costs.

Set up hooks in settings.json

Documentation checked September 25, 2026. The observed changelog head was 2.1.285. These examples use Python 3.9 or later on macOS, Linux or WSL.

The hook locations reference defines four settings scopes:

ScopeLocationUse it for
User~/.claude/settings.jsonPersonal hooks across projects
Shared project.claude/settings.jsonReviewed hooks committed with the repo
Local project.claude/settings.local.jsonPersonal hooks in one repo
ManagedOrganization-deployed settingsAdministrator policy

Ordinary settings precedence is managed, command line, local project, shared project, then user; hook arrays merge across applicable settings sources instead of replacing one another.

Shell
mkdir -p .claude/hooks
chmod 700 .claude/hooks
claude --version

Save the chosen scripts there, merge their event arrays into one hooks object, and inspect the read-only /hooks browser inside Claude Code.

Each settings tab is a complete object for an empty settings file. Preserve existing settings and append groups under existing event names. Do not concatenate JSON objects or create duplicate hooks keys. Direct edits are normally hot-reloaded; restart if a saved handler remains missing.

The examples use exec-form handlers: python3 is the executable and args contains the script path. Paths with spaces stay one argument, and the Python files need no executable permission. ${CLAUDE_PROJECT_DIR} identifies the starting project; input cwd follows Claude's current directory and worktree. Use that current cwd for repository commands.

Native Windows needs adaptations: fcntl is Unix-only, Bash filters do not cover PowerShell, and npm's .cmd shim requires different execution handling. Anthropic documents these Windows execution differences.

Match the event's target

On tool events, matcher filters tool_name, not filenames. Edit|Write matches those two names; omission, "" and "*" match all. Matchers are case-sensitive. Regex matching is unanchored, so use ^Edit$ for a whole-string regex match. See the matcher rules.

Use mcp__server__.* for an MCP server's tools. Inspect file paths inside the script for filename policies. Stop and UserPromptSubmit have no matcher filtering.

Combined settings: tool logger and destructive Bash filter

Save both scripts from recipes 1 and 2, then merge this subset into your settings. Both PreToolUse handlers run for a Bash attempt, even when the filter denies it.

JSON
{
  "hooks": {
    "PreToolUse": [
      {"hooks": [{
        "type": "command", "command": "python3",
        "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"],
        "timeout": 5
      }]},
      {"matcher": "Bash", "hooks": [{
        "type": "command", "command": "python3",
        "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.py"],
        "timeout": 5
      }]}
    ],
    "PostToolUse": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"],
      "timeout": 5
    }]}],
    "PostToolUseFailure": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"],
      "timeout": 5
    }]}]
  }
}

An attempt log proves the handler observed a call, not that the tool executed. Check both registration and behavior before adding more handlers.

Events, stdin and exit codes

A command hook receives one JSON object on stdin. Common fields include session_id, hook_event_name, cwd and transcript_path, with event-specific exceptions. Tool events add tool_name and tool_input. File tools use file_path; PostToolUseFailure uses top-level error.

The five current handler types are command, http, mcp_tool, prompt and agent. This collection uses commands. Prompt and agent handlers perform model evaluation; agent hooks are experimental.

Claude Code hook timeline: SessionStart, UserPromptSubmit, then PreToolUse (can block with exit 2), the tool runs, PostToolUse or PostToolUseFailure, repeating for each tool call, then Stop (can ask Claude to continue) and SessionEnd. PreCompact fires before context compaction.
Hooks mark lifecycle boundaries; model requests consume API usage. The compaction path is optional. EndConversation skips the usual tool hooks.

The events these recipes use

The reference lists 33 events as of September 25, 2026. This table covers the working subset; each event has its own input and output schema.

EventMatch targetEffect of command exit 2
SessionStartSourceDoes not block startup
UserPromptSubmitNoneRejects prompt
PreToolUseTool nameBlocks tool call
PostToolUseTool nameFeedback; cannot undo tool
PostToolUseFailureTool nameFeedback; cannot undo failure
NotificationNotification typeIgnored
StopNoneKeeps main agent working
SubagentStopAgent typeKeeps subagent working
PreCompactManual or autoBlocks compaction
PostCompactManual or autoDoes not block
PreModelSwitchTarget canonical modelBlocks switch
SessionEndEnd reasonDoes not block

Source: exit-code behavior by event. Current PreCompact hooks can block compaction.

The other 21 documented events
EventMatch targetEffect of command exit 2
SetupTriggerDoes not block
UserPromptExpansionCommand nameBlocks expansion
PermissionRequestTool nameRequires nested JSON decision
PermissionDeniedTool nameIgnored; already denied
PostToolBatchNoneStops loop before next model call
MessageDisplayNoneOriginal text displays
SubagentStartAgent typeDoes not block
TaskCreatedNoneRolls back creation
TaskCompletedNonePrevents completion
StopFailureErrorIgnored except terminal side effect
TeammateIdleNoneKeeps teammate working
InstructionsLoadedLoad reasonIgnored
ConfigChangeSourceBlocks change except managed policy
CwdChangedNoneDoes not block
DirectoryAddedSourceDoes not block
FileChangedLiteral filename/watch listDoes not block
WorktreeCreateNoneAny nonzero exit fails creation
WorktreeRemoveNoneNonzero fails if directory remains
PostModelSwitchTarget canonical modelDoes not block
ElicitationMCP serverDenies elicitation
ElicitationResultMCP serverDeclines response

See the event-specific exit table before applying a decision pattern to another event.

Exit codes and JSON decisions

Output conventionResult
Exit 0, no outputNo decision; normal permissions apply
Exit 2, reason on stderrBlocks where the event supports it
Exit 0, one JSON object on stdoutStructured decision or context
Other exits without valid decision JSONOrdinarily a non-blocking error

Valid schema-checked JSON can control standard-decision events on other exit codes, so exit 1 is not a reliable denial. PermissionRequest requires its nested decision object; exit 2 alone does not deny it.

Return one JSON object without mixing in log output. For PreToolUse, put the decision inside hookSpecificOutput; context also belongs there as additionalContext with the matching hookEventName. A Stop block means keep working.

JSON
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This operation is not permitted."
  }
}
PreToolUse exit 0 with no output goes to normal permissions; exit 2 with stderr or exit 0 with nested deny JSON blocks the tool. A Stop block continues work.
Silence leaves normal permissions in place. Stop and PermissionRequest have different decision semantics.

10 practical Claude Code hook examples

HookEventBlocks?What it does
1. Tool logPreToolUse, PostToolUse, PostToolUseFailureNoRecords attempts and outcomes
2. Destructive Bash filterPreToolUseYesCatches removal and force-push patterns
3. Secret-path protectionPreToolUseYesDenies selected file paths
4. FormatterPostToolUseNoFormats the changed file
5. Project checksStopContinues onceRuns a non-watching check
6. Attention notificationNotificationNoSends a desktop alert
7. Git contextSessionStartNoAdds branch, status and commits
8. Prompt credential checkUserPromptSubmitYesRejects credential-like text
9. Cache-cost estimatesSessionStart, PreModelSwitchNoLogs operation estimates
10. Transcript snapshotPreCompactNoCopies available transcript bytes

The formatter and Stop check need repository dependencies. Install a pinned Prettier locally, such as the installation guide's prettier@3.9.9, and put node and ruff on the hook PATH. The published Prettier package provides bin/prettier.cjs. For recipe 5, define a reviewed check:agent npm script that runs your tests or typecheck and exits without watch mode; npm run executes that project-defined script.

1. Log tool attempts and outcomes as JSONL

Record identifiers and timing at PreToolUse, PostToolUse and PostToolUseFailure, using the documented tool-event fields.

JSON
{
  "hooks": {
    "PreToolUse": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"], "timeout": 5
    }]}],
    "PostToolUse": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"], "timeout": 5
    }]}],
    "PostToolUseFailure": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log-tools.py"], "timeout": 5
    }]}]
  }
}

Gotcha: a denied attempt can lack an outcome event; the append lock prevents interleaved records, but use IDs to correlate parallel calls.

2. Block common destructive Bash patterns

Inspect tool_input.command before Bash executes, following the official validator pattern, and reject recursive force removal or force pushes.

JSON
{
  "hooks": {
    "PreToolUse": [{"matcher": "Bash", "hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.py"], "timeout": 5
    }]}]
  }
}

Gotcha: this includes --force-with-lease and catches accidents, but aliases, scripts and alternative syntax evade text filters; use permissions and isolation for stronger controls.

3. Protect secret paths from file tools

Check direct and resolved paths before Read, Edit, Write and NotebookEdit, with permission-rule companions for access that bypasses those tools.

JSON
{
  "permissions": {
    "deny": [
      "Read(//**/.env*)", "Edit(//**/.env*)",
      "Read(//**/secrets/**)", "Edit(//**/secrets/**)",
      "Read(~/.ssh/**)", "Edit(~/.ssh/**)"
    ]
  },
  "hooks": {
    "PreToolUse": [{"matcher": "Read|Edit|Write|NotebookEdit", "hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-secrets.py"], "timeout": 5
    }]}]
  }
}

Gotcha: this includes .env.example; the broader .env* permission rule also covers prompt @file references that skip Read hooks, while arbitrary subprocess reads still need filesystem restrictions.

4. Format files after Edit and Write

After a successful Edit or Write, format the changed JS/TS file with local Prettier or the changed Python file with Ruff.

JSON
{
  "hooks": {
    "PostToolUse": [{"matcher": "Edit|Write", "hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format-file.py"], "timeout": 40
    }]}]
  }
}

Gotcha: Bash-written files bypass this hook; keep formatter ignores current, and retain --force-exclude because Ruff otherwise processes explicitly supplied excluded files.

5. Run checks on Stop with one corrective pass

On Stop, run the project's non-watching check:agent script for a dirty Git tree and use stop_hook_active to permit one corrective continuation.

JSON
{
  "hooks": {
    "Stop": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-stop.py"], "timeout": 120
    }]}]
  }
}

Gotcha: the next Stop passes without rechecking, and the dirty tree can include earlier changes; keep CI as the merge gate and review test output before sending it into model context.

6. Notify when Claude needs attention

Handle permission_prompt and idle_prompt notifications with a fixed desktop message or terminal bell.

JSON
{
  "hooks": {
    "Notification": [{"matcher": "permission_prompt|idle_prompt", "hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.py"], "timeout": 5
    }]}]
  }
}

Gotcha: notifications are delayed, macOS needs Script Editor notification permission, and Linux needs a desktop daemon; check visible delivery, since a successful process does not prove an alert appeared.

7. Refresh Git context at SessionStart

At every SessionStart, including resume and post-compaction startup, inject the current branch, working-tree status and recent commits.

JSON
{
  "hooks": {
    "SessionStart": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-context.py"], "timeout": 15
    }]}]
  }
}

Gotcha: filenames and commit messages become model context, so keep sensitive values out; --no-optional-locks avoids unnecessary locking during status inspection.

8. Catch common secrets pasted into prompts

Before UserPromptSubmit processes expanded pasted text, reject these selected credential-like patterns without printing their values.

JSON
{
  "hooks": {
    "UserPromptSubmit": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-prompt.py"], "timeout": 5
    }]}]
  }
}

Gotcha: these sample patterns miss encoded or unfamiliar secrets; suppressOriginalPrompt hides the block-message text but does not erase local history.

9. Record per-session cache-cost estimates

Log context tokens and estimated cache-write cost from resume/fork SessionStart and PreModelSwitch, using fields available in Claude Code 2.1.251 or later.

JSON
{
  "hooks": {
    "SessionStart": [{"matcher": "resume|fork", "hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/cache-cost.py"], "timeout": 5
    }]}],
    "PreModelSwitch": [{"hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/cache-cost.py"], "timeout": 5
    }]}]
  }
}

Gotcha: estimates exclude response cost and can use default pricing for unknown gateway models; a canceled switch is not a charge, so do not sum these records as session spend.

10. Snapshot transcripts before compaction

At PreCompact, copy input transcript_path into a timestamped local backup directory.

JSON
{
  "hooks": {
    "PreCompact": [{"matcher": "manual|auto", "hooks": [{
      "type": "command", "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/backup-transcript.py"], "timeout": 15
    }]}]
  }
}

Gotcha: asynchronous transcript writes can leave recent messages out; backups contain conversation data and need their own deletion policy.

Check the hooks before deploying them

Use a disposable Git/npm repository, dummy secret files and synthetic input. A fixture invokes the script without executing an embedded Bash command. Then trigger a harmless real event and inspect /hooks, the debug log and the resulting file or notification.

Shell
python3 -m json.tool .claude/settings.json > /dev/null
python3 -m compileall -q .claude/hooks
claude --debug-file /tmp/claude-hooks.log

The debug log records hook execution. Keep it private. Registration proves configuration was loaded; event evidence and observed effects establish that the handler ran.

HookFixture or checkExpected result
Tool logAttempt, success and failure with the same tool IDSilent exit 0; JSONL IDs, no raw input
Bash filterRemoval/force-push strings; git status; malformed JSONExit 2, 0 and 2 respectively
Secret pathsDummy .env, ordinary path, symlink to dummy secretDeny secret paths; allow ordinary file
FormatterUnformatted JS/TS and Python; path with spacesChanged bytes; excluded files unchanged
Stop checksFailing script; active continuation; background tasksBlock JSON once; subsequent cases silent
NotificationNative desktop alert or registered bellObserve alert, not just process exit
Git contextRepository, worktree, non-Git directoryCorrect context; non-Git case silent
Prompt checkFake key-shaped text, ordinary prose, malformed JSONBlock, pass, block; no echoed key
Cache estimatePresent estimate, missing estimateLog estimate; missing value adds no record
Transcript backupDummy JSONL fileIdentical copied bytes; private permissions

Review privacy as part of that check. The loggers omit raw commands, prompts, file contents and error text. Their umask protects newly created files, not existing permissions. Set retention for both log directories and transcript backups; local owner-editable logs are diagnostic records.

Copy safe stdin fixtures for all ten scripts

Run from the disposable repository after saving the scripts. The helper prints the process status; use it without shell errexit when expecting exit 2.

Shell
probe_hook() {
  printf '%s\n' "$2" | python3 ".claude/hooks/$1"
  result=$?
  printf '\nExit status: %s\n' "$result"
}
 
probe_hook log-tools.py '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Read","tool_use_id":"demo-tool"}'
probe_hook log-tools.py '{"session_id":"demo","hook_event_name":"PostToolUse","tool_name":"Read","tool_use_id":"demo-tool"}'
probe_hook log-tools.py '{"session_id":"demo","hook_event_name":"PostToolUseFailure","tool_name":"Read","tool_use_id":"demo-tool","error":"synthetic failure"}'
tail -n 3 "$HOME/.claude/hook-logs/tool-events.jsonl"
 
probe_hook block-destructive.py '{"tool_input":{"command":"rm -rf /tmp/example"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"rm -r -f /tmp/example"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"rm --recursive --force /tmp/example"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"git push origin main --force"}}'
probe_hook block-destructive.py '{"tool_input":{"command":"git status"}}'
probe_hook block-destructive.py '{'
 
probe_hook protect-secrets.py '{"tool_input":{"file_path":"/tmp/.env.production"}}'
probe_hook protect-secrets.py '{"tool_input":{"file_path":"/tmp/src/app.py"}}'
probe_hook protect-secrets.py '{"tool_input":{}}'
 
printf '%s\n' 'const value={answer:42}' > 'scratch file.ts'
payload="$(python3 -c 'import json, os; print(json.dumps({"cwd": os.getcwd(), "tool_input": {"file_path": os.path.abspath("scratch file.ts")}}))')"
probe_hook format-file.py "$payload"
cat 'scratch file.ts'
 
payload="$(python3 -c 'import json, os; print(json.dumps({"cwd": os.getcwd(), "stop_hook_active": False}))')"
probe_hook check-stop.py "$payload"
probe_hook check-stop.py '{"stop_hook_active":true}'
probe_hook check-stop.py '{"background_tasks":[{"fixture":"active"}]}'
 
probe_hook notify.py '{}'
payload="$(python3 -c 'import json, os; print(json.dumps({"cwd": os.getcwd()}))')"
probe_hook git-context.py "$payload"
 
payload="$(python3 -c 'import json; print(json.dumps({"prompt": "sk-" + "x" * 32}))')"
probe_hook guard-prompt.py "$payload"
probe_hook guard-prompt.py '{"prompt":"Explain this function."}'
probe_hook guard-prompt.py '{'
 
probe_hook cache-cost.py '{"session_id":"demo","hook_event_name":"PreModelSwitch","context_tokens":100000,"estimated_cache_write_usd":0.5,"pricing":"catalog"}'
probe_hook cache-cost.py '{"session_id":"demo","hook_event_name":"SessionStart","source":"resume"}'
tail -n 1 "$HOME/.claude/hook-logs/cache-cost-estimates.jsonl"
 
printf '%s\n' '{"fixture":"dummy conversation"}' > dummy-transcript.jsonl
payload="$(python3 -c 'import json, os; print(json.dumps({"session_id": "demo", "transcript_path": os.path.abspath("dummy-transcript.jsonl")}))')"
probe_hook backup-transcript.py "$payload"
ls -lt "$HOME/.claude/transcript-backups"

For the Stop failure case, set check:agent temporarily to node -e "process.exit(1)" and leave a dummy file untracked. Expect exit 0 with decision: "block"; the active-continuation and background-task fixtures stay silent. Restore the real check script and exercise its passing path.

The cache fixture assumes 100,000 context tokens and a $0.50 estimate. These are supplied sample values, not a calculated model price. Missing estimates must not become zero-spend records.

The notification probe prints fallback JSON; registered Claude Code interprets terminalSequence. Bell handling is interactive-only. Permission notifications arrive after roughly six seconds of no typing; idle notifications after roughly 60 seconds. Use PermissionRequest for immediate permission-event handling.

Also exercise a missing formatter, a non-Git cwd, a worktree, concurrent logger appends and a timeout. Test dummy @file access separately to check the permission companion. Path resolution alone does not prevent hard-link or filesystem-race access. Compare snapshot bytes and permissions, then delete the dummy files and backups.

Ruff fixture: excluded bytes stay unchanged

This fixture covers global exclusions, formatter-only exclusions and Ruff's default .venv exclusion. It includes a positive control so a wrapper that skips every file fails.

Shell
python3 - .claude/hooks/format-file.py <<'PY'
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
 
hook = Path(sys.argv[1]).resolve()
ruff = shutil.which("ruff")
if not ruff or not hook.is_file():
    raise SystemExit("Need Ruff on PATH and the saved hook.")
print(subprocess.check_output([ruff, "--version"], text=True).strip())
with tempfile.TemporaryDirectory(prefix="ruff-hook-fixture-") as temporary:
    root = Path(temporary).resolve()
    subprocess.run(["git", "init", "-q", str(root)], check=True)
    (root / "pyproject.toml").write_text(
        '[tool.ruff]\nextend-exclude = ["generated"]\n'
        '[tool.ruff.format]\nexclude = ["format_only.py"]\n', encoding="utf-8")
    excluded = [root / "generated/example.py", root / "format_only.py",
                root / ".venv/lib/example.py"]
    included = root / "src/included.py"
    original = b"x=  1\r\n"
    for file in excluded + [included]:
        file.parent.mkdir(parents=True, exist_ok=True)
        file.write_bytes(original)
        probe = subprocess.run([ruff, "format", "--check", "--no-force-exclude", str(file)],
                               cwd=root, capture_output=True, timeout=10)
        assert probe.returncode == 1 and file.read_bytes() == original, file
        payload = {"cwd": str(root), "tool_input": {"file_path": str(file)}}
        result = subprocess.run([sys.executable, str(hook)], input=json.dumps(payload),
                                cwd=root, capture_output=True, text=True, timeout=40)
        assert result.returncode == 0 and not result.stdout and not result.stderr, result
        assert (file.read_bytes() == original) == (file in excluded), file
    checked = subprocess.run([ruff, "format", "--check", "--force-exclude", str(included)],
                             cwd=root, capture_output=True, timeout=10)
    assert checked.returncode == 0, checked
    assert all(file.read_bytes() == original for file in excluded)
    print("PASS: excluded bytes unchanged; included file formatted.")
PY

Expected: excluded bytes retain their spacing and CRLF; the included file changes and passes a read-only check. Ruff's closest configuration wins; replacing the default exclusion list can remove .venv protection. Test your effective repository configuration too.

How do I track what a Claude Code session costs?

Hooks see tool events, not billing. Use statusLine or OpenTelemetry for client usage data, or route model requests through Requesty for per-request cost with a session tag.

SurfaceUseful dataLimitation
Local hook logTool attempts, IDs and outcomesNo billing totals
Hook cache estimatesContext and rebuild estimatesOperation-specific
statusLineEstimated session costClient estimate
OpenTelemetryAPI token/cost metrics and eventsTelemetry setup
RequestyRouted request cost, tokens and latencyGateway traffic only

statusLine exposes cost.total_cost_usd through a separate callback. It is an estimate; don't sum cumulative snapshots or treat current-context tokens as accumulated billable input. OpenTelemetry metrics and API request events provide usage without enabling raw prompt logging.

Ordinary Stop input has no session cost, usage totals or response headers. The foreground Agent tool's usage covers its final API request, and transcript entries use an internal, changing format. Use supported metering instead of summing transcript guesses.

Local logs observe tool actions, Claude usage surfaces report client metrics and estimates, and Requesty meters routed requests. A generated UUID feeds an explicit Claude session ID and a custom launch-time Session label.
Pair tool activity with model-request usage. The custom Session field is a launch label, separate from Requesty's reconstructed session and trace IDs.

After configuring the gateway below, save this launcher outside an untrusted repository and run it from your project:

claude-requesty.py
import os, uuid
session_id = str(uuid.uuid4())
env = os.environ.copy()
headers = [line for line in env.get("ANTHROPIC_CUSTOM_HEADERS", "").splitlines()
           if line.split(":", 1)[0].strip().lower() != "x-requesty-session"]
env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(headers + ["X-Requesty-Session: " + session_id])
print("Session ID: " + session_id, flush=True)
os.execvpe("claude", ["claude", "--session-id", session_id], env)

Claude Code accepts newline-separated custom headers and an explicit session UUID; X-Requesty-Session becomes the custom analytics field Session, so compare it with the local hook ID after a harmless request and group cost or tokens by that field. The header is fixed at launch: /clear does not rotate it, so relaunch with the matching label when changing conversations or externally resuming one; settings env overrides must not replace that label.

Requesty
See request costs and cap developer spending

Every Claude Code model request routed through Requesty is logged with cost, tokens and latency. Give each developer a key with a monthly spend cap; after the cap is reached, new requests for that key are blocked. Set up the gateway with these two variables:

Shell
export ANTHROPIC_BASE_URL="https://router.requesty.ai"
export ANTHROPIC_AUTH_TOKEN="$REQUESTY_API_KEY"

Use your securely supplied Requesty key. Follow the Claude Code integration guide or create an account.

For the full credential setup, see Claude Code with an API key. Use developer-key attribution for the traffic owner and the launch label for the run. For headless runs, --max-budget-usd adds a client-side cap; it is not an interactive-session budget flag.

Debug hooks that are not working

SymptomLikely causeFix
Missing from /hooksInvalid JSON or wrong settings sourceParse settings; inspect effective scope
Registered but never firesWrong event or matcherMatch the event's target, including case
Script cannot startWrong path or hook PATHCheck executable and dependencies
JSON decision ignoredWrong nesting or extra stdoutEmit one event-valid JSON object
Context never reaches ClaudeTop-level additionalContextNest it under hookSpecificOutput
Stop keeps continuingUnbounded feedbackGuard with stop_hook_active
Formatter misses filesBash or another tool wrote themUse a separate file-change workflow
Policy allows after failureError or timeout failed openInspect debug log; retain hard controls
No visible desktop alertOS permissions or missing daemonTest native notifier and observe display
Wrong repository contextStarting project used instead of cwdRead current input cwd
Session tag absent or wrongSettings override launch headersInspect env; compare local/gateway IDs

Write execution details with claude --debug-file /tmp/claude-hooks.log; /debug enables logging mid-session, while --debug does not print it directly to the terminal. Command-hook PreToolUse timeouts do not block execution, and asynchronous hooks cannot retroactively deny a call. Keep blocking checks synchronous and short.

Keep hard controls outside the hook

Command hooks run with your full user permissions, so review repository settings before unattended claude -p or SDK execution. Configured tool hooks also run inside subagents, and PreToolUse denials precede permission-mode checks, but editable scripts and fail-open errors limit them as security controls. Put reviewed automation in project settings and organization policy in managed settings; keep filesystem isolation, CI, protected branches and gateway caps at their own boundaries. For programmatic callbacks in agents you build, use our Claude Agent SDK hooks guide.

Sources

Contracts and configuration checked September 25, 2026.

Frequently asked questions
What are Claude Code hooks?
Claude Code hooks are handlers that run automatically at lifecycle events, such as before a tool call or after a response. Current handler types include commands, HTTP endpoints, MCP tool calls, prompts and agents. Command hooks implement local checks without asking a model to evaluate them.
Where do I configure Claude Code hooks?
Use ~/.claude/settings.json for personal hooks across projects, .claude/settings.json for shared project hooks, or .claude/settings.local.json for personal project settings. Administrators can deploy managed hooks. The /hooks command is a read-only browser; edit settings JSON to change hooks.
What is the difference between PreToolUse and PostToolUse?
PreToolUse runs before execution and can deny a tool call. PostToolUse runs after a successful call and can provide feedback or run a formatter, but it cannot undo the operation.
Why is my Claude Code hook not working?
Check registration in /hooks, the event and matcher, executable paths, dependencies and the event-specific schema. Test the script with synthetic stdin and inspect a debug log. A missing executable or timed-out command hook can leave an operation unblocked.
How do I stop a Stop hook from looping?
Inspect stop_hook_active and define a bounded continuation policy. The example here permits one corrective pass, then lets the next Stop through without rechecking. Keep CI as the merge gate.
Can Claude Code hooks track tokens and costs?
Ordinary Stop input does not expose total session tokens, cost or API response headers. Some hooks expose cache rebuild estimates; statusLine and OpenTelemetry provide client usage data. Requesty meters routed requests and groups their cost and tokens by a custom launch-time Session label.
Related reading

Start building with Requesty

One line of code. 600+ models. Full control.

Speak to founders