PostToolUse hooks in settings.json never register; documented updatedToolOutput example silently fails for structured-output tools
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:
- Hooks defined under the
hookskey in.claude/settings.json(orsettings.local.json, project- or user-scoped) never register, despite exactly matching the documented schema./hooksreports "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. - The official docs' own
PostToolUseexample (the "redact secrets" example on the hooks reference page) setsupdatedToolOutputto a bare string. For any tool whose real output has a structured schema —Bashincluded, whose actual schema is{stdout, stderr, interrupted, isImage, noOutputExpected}— a bare-stringupdatedToolOutputsilently 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 alevel:"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
disableAllHooksflag set; no other hook-related settings.
Issue 1: settings.json-defined hooks never register
Repro steps
- In a project directory, create
.claude/settings.json:
``json``
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/absolute/path/to/hook-script.sh" }
]
}
]
}
}
- Restart the
claudeCLI process in that directory (a fresh process is required — hooks do not hot-reload mid-session, confirmed separately). - Open
/hooks→ selectPostToolUse.
Expected
The hook entry appears under PostToolUse.
Actual
No hooks configured for this event — for every combination tried:
PreToolUseandPostToolUse- Project-scoped
.claude/settings.jsonand.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
- Register a
PostToolUsehook forBash(via a local plugin, since issue 1 blocks the documentedsettings.jsonpath). - 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)
- 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"
PostToolUseexample to use a correctly-shapedupdatedToolOutput, and documenting that the required shape is tool-specific. - Surfacing the schema-validation failure somewhere a hook author can actually see it (even
/hooksor--debugoutput 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.