[BUG] /goal Stop hook emits markdown to stdout, fails JSON schema validation ("JSON validation failed")

Status Fixed / completed
Reported on v2.1.140
Maintainer reply None cached
Activity 7 comments · opened May 25, 2026 · closed Aug 17, 2026

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

  1. Issue any /goal <condition> slash command.
  2. Complete the work that satisfies the condition.
  3. Observe: \"Ran N stop hooks\" + \"Stop hook error: JSON validation failed\".
  4. 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:

  1. The internal /goal evaluator should emit a Stop hook output payload conforming to the schema, e.g.:

``json
{\"decision\": \"approve\", \"reason\": \"Condition satisfied: ...\"}
``

  1. 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 /goal evaluator 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 /goal hooks — at minimum it should not appear as a user-facing error when the underlying logic succeeds.

Impact

  • Severity: cosmetic / noisy.
  • Functional impact: none — /goal auto-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 /goal session.
  • Happy to provide additional sanitized payloads or transcripts if useful.

View original on GitHub ↗

6 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/58558
  2. https://github.com/anthropics/claude-code/issues/41393
  3. https://github.com/anthropics/claude-code/issues/22411

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

yankay · 3 months ago

+1 — hitting the same Stop hook error: JSON validation failed on Claude Code 2.1.152 (Linux), but the trigger is a background (sessionKind: bg) session, not /goal. No user-defined Stop hooks configured (verified settings.json, project settings.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.

exhyy · 2 months ago

Same error on Claude Code 2.1.178 with a custom model provider.

stephschofield · 1 month ago

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 /goal evaluator prompt (extracted from the claude.exe binary)

Running strings over the bundled CLI binary surfaces the literal sentinel prompt used by the /goal Stop hook:

Based on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.

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 logs JSON validation failed.

2. Quantitative correlation across transcripts

Scanning the 80 most recent session transcripts under ~/.claude/projects/:

  • 11 / 11 hook_non_blocking_error records with stderr: "JSON validation failed" had markdown-prose stdout (# Stopping Condition Analysis, # Stop Hook Condition Evaluation, **YES, the condition is satisfied**, …).
  • 0 had JSON stdout.

Every one of my own user/plugin Stop hooks was recorded as hook_success in 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 /goal evaluator, 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 /goal sentinel 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 /goal is 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.

Bardioc1977 · 1 month ago

Reproducing the identical Stop hook error: JSON validation failed on macOS (not WSL/Linux) — confirming this is not platform-specific.

Environment

  • Claude Code: 2.1.212
  • Platform: macOS 26.5.2 (Darwin 25.5.0), native (no WSL)
  • Reproduced across two separate /goal invocations 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

  • Error fires on effectively every Stop while a /goal is active — matches @stephschofield's finding that it's not limited to the final "condition satisfied" turn.
  • Non-blocking: the goal did eventually auto-clear correctly once the condition was met, so functionally the feature still works — only the repeated user-visible error is the issue.
  • No user-defined Stop hooks are configured in this environment (checked settings.json / settings.local.json), so the failing hook is exclusively the internal /goal evaluator, consistent with every other report here.

This confirms the root cause (internal /goal evaluator 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.

kimnamu · 1 month ago

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:

  1. The current evaluator prompt does constrain the output to JSON, and the request does carry an outputFormat json_schema. The extracted prompt quoted earlier in this thread is outdated.
  2. Despite that, the schema is never sent on Bedrock — see below. That's why this reproduces so reliably for some of us and not at all for others.

1. outputFormat json_schema is gated on a provider list that omits bedrock

The prompt-hook evaluator (qop in the 2.1.220 binary) builds a well-formed request:

options:{
  ...
  outputFormat:{type:"json_schema",schema:{type:"object",
    properties:{ok:{type:"boolean"},reason:{type:"string"},impossible:{type:"boolean"}},
    required:["ok","reason"],additionalProperties:!1}}
}

But whether that ever reaches the wire is decided by:

function i1y(e,t,r,n){
  if(!e || "format" in t || !SWr(n) || !eNe(n,"structured_outputs")) return;
  if(t.format=e, !r.includes(Aye)) r.push(Aye)
}

function SWr(e){ let t=lo(e), r=n_(e); if(!p9(r)) return !1; /* ...model checks... */ return !0 }

function p9(e=xn()){ return e==="firstParty" || aq(e) || e==="foundry" || e==="mantle" }
function aq(e=xn()){ return e==="anthropicAws" || e==="anthropicGoogleCloud" }

p9 enumerates firstParty, anthropicAws, anthropicGoogleCloud, foundry, mantle. bedrock and vertex are 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 is boe(), which strips a leading/trailing code fence and nothing else:

function boe(e){return e.trim().replace(/^```[a-zA-Z]*\s*/,"").replace(/\s*```$/,"").trim()}

Any prose before the JSON therefore lands in the !x branch and produces exactly the reported stderr: "JSON validation failed" with the raw model text in stdout.

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 (the PH() default), temperature: 1, then ran the captured output through a reimplementation of boe() + 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):

조건 분석:\n\n**조건 만족 여부: YES**\n\n**근거:**\n\n1. ...
조건을 평가한다.\n\n{"ok": true, "reason": "..."}

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 language setting in ~/.claude/settings.json was 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

  1. Add bedrock (and vertex) to p9, or gate i1y on 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.
  2. Make 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.
  3. Pin the output language in the evaluator system prompt (e.g. "Respond in English regardless of the transcript language"). Cheap, and directly addresses the trigger.
  4. Consider routing /goal through the agent hook path, which returns via the StructuredOutput tool and so has no text-parsing failure mode at all.

Workaround for anyone hitting this today

Appending an explicit output contract to the /goal condition text fixed it for me (0/8 failures above). The condition limit is 4000 chars, so there's room:

/goal <your real condition>

[OUTPUT CONTRACT - overrides every other formatting instruction, including any language preference]
Reply with exactly one raw JSON object and nothing else.
The very first character you emit MUST be `{` and the very last MUST be `}`.
Forbidden: markdown code fences, headings, bold, any prose before or after the JSON.
Write the "reason" value in English, one short sentence.
Valid reply example: {"ok": false, "reason": "Score is 89/100, below the 90 target."}

Raising ANTHROPIC_SMALL_FAST_MODEL to sonnet also works, but that variable is not evaluator-specific, so it moves cost and latency for other internal calls too.

Environment

  • Claude Code 2.1.220, native macOS (Darwin 25.5.0), arm64
  • Provider: Amazon Bedrock, us-east-1
  • Evaluator model: claude-haiku-4-5 via ANTHROPIC_DEFAULT_HAIKU_MODEL
  • Non-blocking, but fires on every turn while a goal is active, regardless of YES/NO verdict. /goal clear stops it.

Showing cached comments. Read the full discussion on GitHub ↗