[BUG] /goal Stop hook emits markdown to stdout, fails JSON schema validation ("JSON validation failed")
Summary
The internal Stop hook spawned by the /goal slash command writes a human-readable markdown report to stdout instead of a JSON payload conforming to the Stop hook output schema. Claude Code logs stderr: \"JSON validation failed\" and exitCode: 1, surfacing as Stop hook error: JSON validation failed in the UI on every /goal evaluation.
The error is non-blocking: the goal auto-clears correctly and the user's work is unaffected. But the noisy error message appears repeatedly during normal use of /goal.
Environment
- Claude Code:
2.1.140 - Node:
24.15.0(volta-managed) - Platform: Linux (WSL2 Ubuntu)
- User-defined Stop hooks present (atlas + plugins) — verified independently OK
- 4 total Stop hooks reported (
Ran 4 stop hooks)
Reproduction
- Issue any
/goal <condition>slash command. - Complete the work that satisfies the condition.
- Observe: \"Ran N stop hooks\" + \"Stop hook error: JSON validation failed\".
- Despite the error, the goal auto-clears (condition correctly evaluated as satisfied).
Evidence
The session transcript JSONL records the actual hook payload as a hook_non_blocking_error attachment:
{
\"type\": \"attachment\",
\"attachment\": {
\"type\": \"hook_non_blocking_error\",
\"hookName\": \"Stop\",
\"hookEvent\": \"Stop\",
\"toolUseID\": \"<uuid>\",
\"stderr\": \"JSON validation failed\",
\"stdout\": \"<markdown-evaluation-report>\",
\"exitCode\": 1,
\"command\": \"<full-text-of-the-goal-condition>\",
\"durationMs\": 11656
}
}
The command field exactly matches the text passed to /goal. The stdout field contains markdown like:
# Evaluation of Stop Condition: <name>
## Answer: YES, condition is satisfied
## Evidence from Transcript
...
**Condition fully satisfied. Stop hook can auto-clear.**
This is human-readable evaluation content — not a JSON object matching the documented Stop hook output schema ({decision, reason, systemMessage, continue, suppressOutput, hookSpecificOutput}).
Diagnosis
I verified the four user-/plugin-level Stop hooks individually with isolated dry-runs (echo '{...}' | node hook.js):
| Hook | Source | exit | stdout |
|------|--------|------|--------|
| echo STOP_HOOK_OK >> /tmp/... | ~/.claude/settings.json | 0 | empty |
| atlas-session-stop.js | ~/.claude/settings.json | 0 | empty |
| openai-codex/stop-review-gate-hook.mjs | plugin | 0 | empty (no review needed) |
| thedotmack/claude-mem summarize | plugin | 0 | {} |
All four are conformant. The failing hook is the internal /goal evaluator hook, which is not present in settings.json or any plugin manifest — it is generated and dispatched by Claude Code itself when a /goal is active.
Root Cause Hypothesis
The agent/subagent that evaluates the /goal stop condition is prompted to assess whether the condition holds, but the prompt template does not constrain output to JSON. The model responds with a natural-language markdown report. Claude Code then attempts JSON.parse(stdout), fails, records JSON validation failed to stderr, and downgrades the failure to hook_non_blocking_error (exitCode 1) so the session continues.
Expected Behavior
Either:
- The internal
/goalevaluator should emit a Stop hook output payload conforming to the schema, e.g.:
``json``
{\"decision\": \"approve\", \"reason\": \"Condition satisfied: ...\"}
- Or, Claude Code should not validate stdout from internal hooks against the same schema as user hooks (or should treat empty/markdown stdout as silent OK).
Suggested Fix
- Update the
/goalevaluator prompt template to require structured JSON output matching the Stop hook output schema. - Alternatively, wrap the evaluator output in a JSON envelope before exposing it to the hook validator (e.g.
{\"decision\": \"approve\", \"reason\": <markdown-as-string>}). - Or, suppress the \"JSON validation failed\" surfacing for internal
/goalhooks — at minimum it should not appear as a user-facing error when the underlying logic succeeds.
Impact
- Severity: cosmetic / noisy.
- Functional impact: none —
/goalauto-clear works correctly. - User impact: misleading error messages on every goal evaluation, eroding trust in the hook system and obscuring real hook errors.
Notes
- I have not modified anything locally to reproduce this; the diagnosis is based on reading the transcript JSONL captured during a regular
/goalsession. - Happy to provide additional sanitized payloads or transcripts if useful.
Showing cached comments. Read the full discussion on GitHub ↗
6 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
+1 — hitting the same
Stop hook error: JSON validation failedon Claude Code 2.1.152 (Linux), but the trigger is a background (sessionKind: bg) session, not/goal. No user-defined Stop hooks configured (verifiedsettings.json, projectsettings.local.json, and enabled plugins). The error repeats on every turn and is non-blocking. Same symptom as described here — hook stdout not conforming to the Stop hook output schema.Same error on Claude Code 2.1.178 with a custom model provider.
Confirming this is still present on
2.1.195(Linux/WSL2, Node 24.14.0) — the reported range was 2.1.140–2.1.178, so it has survived several releases.I independently reproduced the diagnosis in this issue and can add two pieces of new evidence that pin the root cause down further.
1. The internal
/goalevaluator prompt (extracted from theclaude.exebinary)Running
stringsover the bundled CLI binary surfaces the literal sentinel prompt used by the/goalStop hook:The associated record type in both the binary and the session transcripts is
goal_status. This directly confirms the Root Cause Hypothesis in the original report: the evaluator prompt asks a yes/no question and never constrains the output to JSON, so the model naturally answers with a markdown analysis. Claude Code then tries to validate that markdown against the Stop hook output schema and logsJSON validation failed.2. Quantitative correlation across transcripts
Scanning the 80 most recent session transcripts under
~/.claude/projects/:hook_non_blocking_errorrecords withstderr: "JSON validation failed"had markdown-prose stdout (# Stopping Condition Analysis,# Stop Hook Condition Evaluation,**YES, the condition is satisfied**, …).Every one of my own user/plugin Stop hooks was recorded as
hook_successin the same sessions (including plugin hooks that echo the input JSON back to stdout and ECC async hooks) — so the failing producer is exclusively the internal/goalevaluator, never a user/plugin hook. This matches the original reporter's per-hook dry-run table.Reproduction detail worth noting
The error fires whenever a
/goalsentinel evaluates, not only when the condition is satisfied — both "YES satisfied" and "NO not yet" markdown responses fail schema validation identically. So the noise appears on essentially every stop while a/goalis active, not just at auto-clear time.On the suggested fix
Of the two options in the original report, constraining the evaluator's own prompt to emit the schema (option 1) seems strictly better than relaxing validation (option 2): the evaluator already has a well-defined decision (
met: true/false), so wrapping it as{"decision": "block"|"approve", "reason": <summary>}— or simply having the internal evaluator bypass the user-hook stdout validator entirely — would both silence the error and preserve the structured signal. Relaxing validation globally risks masking genuine malformed output from real user hooks.Happy to attach a sanitized transcript excerpt if useful.
Reproducing the identical
Stop hook error: JSON validation failedon macOS (not WSL/Linux) — confirming this is not platform-specific.Environment
2.1.212/goalinvocations in the same session, each with a different condition text, e.g.:implementiere slice 1 mit implementer/reviewer. Goal ist erfüllt, wenn Reviewer APPROVAL gibt.implementiere slice 1 mit datona-feature-implementer/reviewer. Goal ist erfüllt, wenn Reviewer APPROVAL gibt. ...Observation
/goalis active — matches @stephschofield's finding that it's not limited to the final "condition satisfied" turn.settings.json/settings.local.json), so the failing hook is exclusively the internal/goalevaluator, consistent with every other report here.This confirms the root cause (internal
/goalevaluator prompt not constraining its own output to the Stop hook JSON schema) is platform-independent — happens on native macOS just as on WSL2/Linux. Given three separate OSes now confirmed (WSL2, Linux, macOS) plus a custom-model-provider report, this looks like a pure prompt/schema issue in the evaluator itself, unrelated to OS or environment.Reproduced on
2.1.220(macOS, Darwin 25.5.0, native) with Amazon Bedrock as the provider. Adding a root cause that I don't think has been named yet, plus a reproduction that fails 5/5.Two notes up front, since they correct earlier findings in this thread:
outputFormatjson_schema. The extracted prompt quoted earlier in this thread is outdated.1.
outputFormatjson_schema is gated on a provider list that omitsbedrockThe prompt-hook evaluator (
qopin the 2.1.220 binary) builds a well-formed request:But whether that ever reaches the wire is decided by:
p9enumeratesfirstParty,anthropicAws,anthropicGoogleCloud,foundry,mantle.bedrockandvertexare absent. So on Bedrock the structured-output contract degrades to a prompt-level request, and the only thing standing between the model and a parse failure isboe(), which strips a leading/trailing code fence and nothing else:Any prose before the JSON therefore lands in the
!xbranch and produces exactly the reportedstderr: "JSON validation failed"with the raw model text instdout.2. What actually triggers the prose: a non-English transcript
The evaluator inherits the conversation transcript. When that transcript is not in English, the small fast model answers in that language and prepends a summary before the JSON — even though the system prompt is English and asks for JSON.
I replayed my real failing case directly against Bedrock: 36 turns / ~50K chars, Korean-language transcript, the exact system prompt and user message from the binary,
claude-haiku-4-5(thePH()default),temperature: 1, then ran the captured output through a reimplementation ofboe()+JSON.parse.| Configuration | Parse failures |
| --- | --- |
| As shipped (haiku, no output contract) | 5/5 sequential, 2/8 in a parallel batch |
| Same, but an explicit output contract appended to the condition text | 0/8 |
| As shipped, but evaluator model raised to
claude-sonnet-5| 0/8 |Representative failing outputs (leading prose, then valid JSON):
The second one is the interesting shape: the JSON is perfectly valid, but a four-word preamble in front of it is enough to fail the whole hook.
For completeness, I checked whether the
languagesetting in~/.claude/settings.jsonwas responsible. It isn't — a short English-only prompt stays in English, and a Korean transcript produces Korean output with or without that setting. The transcript language is what carries it.Suggested fixes, in order of leverage
bedrock(andvertex) top9, or gatei1yon a model capability rather than a provider allow-list. This restores the intended schema enforcement for third-party providers and makes the rest of this moot.boe()tolerant. Extracting the first balanced{...}from the response instead of only unwrapping a fence would absorb the preamble case with a two-line change, and would help every provider that lacks structured outputs./goalthrough theagenthook path, which returns via theStructuredOutputtool and so has no text-parsing failure mode at all.Workaround for anyone hitting this today
Appending an explicit output contract to the
/goalcondition text fixed it for me (0/8 failures above). The condition limit is 4000 chars, so there's room:Raising
ANTHROPIC_SMALL_FAST_MODELto sonnet also works, but that variable is not evaluator-specific, so it moves cost and latency for other internal calls too.Environment
2.1.220, native macOS (Darwin 25.5.0), arm64us-east-1claude-haiku-4-5viaANTHROPIC_DEFAULT_HAIKU_MODEL/goal clearstops it.