PostToolUse hooks in settings.json never register; documented updatedToolOutput example silently fails for structured-output tools

Status Open
Reported on v2.1.223
Maintainer reply None cached
Activity 0 comments · opened Aug 6, 2026

Bug report: settings.json-defined hooks never register; documented updatedToolOutput example silently fails for tools with a structured output schema

Claude Code version: 2.1.223 (CLI)
Platform: Linux

Summary

Two separate, confirmed issues found while setting up a PostToolUse hook that replaces large Bash tool output with a compressed version:

  1. Hooks defined under the hooks key in .claude/settings.json (or settings.local.json, project- or user-scoped) never register, despite exactly matching the documented schema. /hooks reports "No hooks configured for this event" for every location and event type tried. Reproduces with a hand-written hook and with a well-known third-party CLI's real, production hook-installation code — ruling out a config-authoring mistake. Confirmed via the binary's own embedded source (see below) that this is a genuine registration gap, not user error.
  2. The official docs' own PostToolUse example (the "redact secrets" example on the hooks reference page) sets updatedToolOutput to a bare string. For any tool whose real output has a structured schema — Bash included, whose actual schema is {stdout, stderr, interrupted, isImage, noOutputExpected} — a bare-string updatedToolOutput silently fails Zod schema validation and Claude Code reverts to the original, unmodified output, with no error visible to the hook author (nothing in /hooks, nothing in normal output; only a level:"error" log line that requires reading the binary's source to know to look for). This makes the documented example itself non-functional as written for at least one built-in tool.

A minor third finding: two different official doc sources describe the hook-input JSON schema inconsistently, and neither matches the real payload exactly.

Environment

  • claude --version: 2.1.223 (Claude Code)
  • Two plugins installed and enabled (unrelated — neither defines hooks): a payments-integration plugin and a language-server plugin, both from the official plugin marketplace.
  • No disableAllHooks flag set; no other hook-related settings.

Issue 1: settings.json-defined hooks never register

Repro steps

  1. In a project directory, create .claude/settings.json:

``json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/absolute/path/to/hook-script.sh" }
]
}
]
}
}
``

  1. Restart the claude CLI process in that directory (a fresh process is required — hooks do not hot-reload mid-session, confirmed separately).
  2. Open /hooks → select PostToolUse.

Expected

The hook entry appears under PostToolUse.

Actual

No hooks configured for this event — for every combination tried:

  • PreToolUse and PostToolUse
  • Project-scoped .claude/settings.json and .claude/settings.local.json
  • Command path as a literal absolute path, and via ${CLAUDE_PROJECT_DIR} substitution
  • Single file present, and both files present simultaneously (rules out a merge conflict)

Confirmed not a config-authoring mistake

A well-known open-source CLI tool (a knowledge-graph indexer with an official Claude Code integration, thousands of real users) writes its own PreToolUse hooks to exactly this same location via its own install command. Running that tool's real, unmodified install command in the same test project produced a .claude/settings.json with three PreToolUse entries (one of mine, two of the third-party tool's). After a fresh restart, none of the three registered.

Debug log evidence

Ran with claude --debug (writes to ~/.claude/debug/<session-id>.txt). The log confirms Claude Code is aware of and watching the settings file:

[DEBUG] Ignoring dangerous permission Bash(bunx *) from .../.claude/settings.json (bypasses classifier)
...
[DEBUG] Watching for changes in setting files ~/.claude/settings.json, <project>/.claude/settings.json, <project>/.claude/settings.local.json...

But the only hook-registration line in the entire log, in every run, regardless of settings.json content, is:

[DEBUG] Registered 0 hooks from 2 plugins

No line ever appears for hooks sourced from settings.json — no success, no parse error. The settings-file-hooks registration path does not appear to execute at all, while the plugin-hooks path visibly does (see below — plugin-registered hooks work correctly once issue 2 is also fixed).

Working alternative found

Hooks registered via a local plugin (claude plugin init <name> --with hooks, producing .claude-plugin/plugin.json + hooks/hooks.json) do register — confirmed via /reload-plugins reporting "1 hook" — and, once issue 2 below is also addressed, fire and apply correctly. This is a viable workaround, but settings.json-based hooks are the documented, expected path and should work.

Issue 2: documented updatedToolOutput example silently fails for structured-output tools

Root cause, found by reading the actual binary

Claude Code ships as a single bundled executable (Bun/Node single-file compile) with de-minified-enough embedded source strings extractable via strings. The relevant logic:

let Ke = e.outputSchema?.safeParse(Pe), st = (Xe) => {
  T(`PostToolUse hook returned updatedToolOutput that does not match ${e.name}'s output shape: ${Xe}`, {level:"error"}),
  Pe = se.data,  // reverts to original, unmodified output
  ...
};
if (Ke && !Ke.success) st(Ke.error.message);

updatedToolOutput is validated against the tool's real outputSchema via Zod. If validation fails, Claude Code silently falls back to the original output — the hook has no way to know this happened short of reading this exact code path, since nothing surfaces in /hooks, the transcript, or normal output.

Repro steps

  1. Register a PostToolUse hook for Bash (via a local plugin, since issue 1 blocks the documented settings.json path).
  2. Follow the docs' own example shape exactly:

``json
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": "some replacement text"
}
}
``
(a bare string, matching the "redact secrets" example on the hooks reference page verbatim)

  1. Trigger a Bash call large enough for the hook to act on.

Expected

The tool result becomes the replacement text.

Actual

The original, unmodified Bash output is what's delivered — no error visible anywhere in normal usage.

Confirmed root cause and fix

Captured a real PostToolUse payload directly and found tool_response for Bash is {stdout, stderr, interrupted, isImage, noOutputExpected} — a structured object, not a string. Changing the hook to return:

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "updatedToolOutput": {
      "stdout": "some replacement text",
      "stderr": "",
      "interrupted": false,
      "isImage": false,
      "noOutputExpected": false
    }
  }
}

worked immediately and correctly — verified live, output substitution applied as expected.

The docs' own example is what's wrong. It should either show the correctly-shaped object for a real tool (Bash or otherwise), or explicitly document that updatedToolOutput's required shape depends on the specific tool's output schema and isn't just "the replacement text."

Issue 3 (minor): documented hook-input JSON schema is inconsistent across sources, and doesn't exactly match reality

| Source | Documented field names |
|---|---|
| Public web docs (cost-management page) | tool_input, tool_output |
| In-app /hooks UI help text | inputs, response |
| Actual payload (captured directly) | tool_input ✓ matches one source, but tool_response (object) — matches neither documented name exactly |

Anyone following either doc source to extract tool output will silently get nothing (the field doesn't exist under either documented name), which looks identical to "hook works, nothing to do" rather than a clear failure.

Impact

A user following the official documentation's own recommended pattern for reducing context bloat from verbose tool output — explicitly called out on the cost-management page as the suggested fix — cannot get it working via the documented settings.json path (issue 1) or by copying the documented updatedToolOutput example verbatim (issue 2), and would extract the wrong field trying to read the tool output in the first place (issue 3). All three compound into a hook that appears to run successfully (no errors surfaced anywhere in normal usage) while silently doing nothing.

What would help

  • A fix or explanation for why settings.json-defined hooks don't register (issue 1) — or, if plugins are now the only supported path, updating the docs to say so.
  • Fixing the "redact secrets" PostToolUse example to use a correctly-shaped updatedToolOutput, and documenting that the required shape is tool-specific.
  • Surfacing the schema-validation failure somewhere a hook author can actually see it (even /hooks or --debug output would help) rather than only in an internal log line.
  • Reconciling the two inconsistent hook-input field-name descriptions (issue 3) against the real payload shape.

View original on GitHub ↗