Stop hook invalid JSON error is too opaque for unsupported fields

Open 💬 3 comments Opened Apr 21, 2026 by SYRS-AI

Summary

When a Stop hook returns JSON with an unsupported field such as hookSpecificOutput, Codex reports only a generic parse failure like:

hook returned invalid stop hook JSON output

That made a real integration failure much harder to diagnose than it needed to be. The hook was returning a shape that is accepted on other events (SessionStart, UserPromptSubmit, PostToolUse) but not on Stop.

Why this matters

In a multi-event hook integration, it is natural to reuse the same output builder across events. If one event has stricter schema rules than the others, a field-level diagnostic is much more actionable than a generic JSON-invalid error.

In my case, the hook JSON itself was valid JSON and the hook exited 0; the problem was only that Stop rejected an extra field that other hook events commonly use.

Current documented behavior

The public hooks docs say Stop expects JSON on stdout and that it supports the common output fields. The docs do not show hookSpecificOutput for Stop:

Current source behavior

At current main (6f6997758a6936b522c046191ae3cae4293e23e9 when I checked), the hooks schema for Stop is strict and does not include hookSpecificOutput:

  • codex-rs/hooks/src/schema.rs#L340-L353
  • StopCommandOutputWire has #[serde(deny_unknown_fields)]
  • fields are only the flattened universal output, decision, and reason
  • codex-rs/hooks/src/engine/output_parser.rs#L215-L232
  • parse_stop() deserializes directly into StopCommandOutputWire
  • codex-rs/hooks/src/events/stop.rs
  • when parsing fails, Codex surfaces hook returned invalid stop hook JSON output

Minimal example

This is valid JSON, but because it includes hookSpecificOutput, it is rejected by Stop:

{
  "decision": "block",
  "reason": "Run one more pass.",
  "hookSpecificOutput": {
    "hookEventName": "Stop",
    "additionalContext": "Queue DB is source of truth."
  }
}

Requested improvement

At minimum, please make the error specific enough to identify the actual contract violation, for example by reporting the unexpected field name/path instead of only saying the whole payload is invalid.

Any of these would help:

  1. Surface the serde unknown-field error directly, e.g. unsupported field hookSpecificOutput for Stop.
  2. Add event-aware validation diagnostics before returning the generic parse error.
  3. Optionally, if intended, support hookSpecificOutput.additionalContext on Stop too for parity with other events.

I am not claiming the current parser is wrong per the documented schema. The issue is that the runtime diagnostic is too opaque for a common integration mistake.

Environment

  • Codex CLI version: 0.122.0
  • Platform: macOS
  • Hooks feature enabled with features.codex_hooks=true

Additional context

I found this while debugging a Codex-only Stop hook failure in a cross-tool bridge. The local integration bug was easy to fix once I traced the schema, but that took longer than it should have because the runtime error did not reveal that the JSON was structurally valid and only had an unsupported field.

View original on GitHub ↗

3 Comments

oxysoft · 3 months ago

Indexed this hook ticket in the umbrella tracker: #21753

Goal: collect the scattered Codex hook requests and bugs into one parity matrix for Full Claude Code Hook Parity (29+), while preserving this issue as the detailed thread for its specific behavior.

francisco-perez-sorrosal · 3 months ago

I ran into the same diagnostics problem on a different hook event: UserPromptSubmit.

After reading the current hooks docs, I understand that this is not a parser bug. For UserPromptSubmit, Codex accepts either plain text on stdout or JSON in the documented shape:

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "..."
  }
}

What I hit was a cross-tool adapter case where the hook returned this instead:

{
  "additionalContext": "..."
}

On codex-cli 0.130.0 on macOS, that produced:

UserPromptSubmit hook (failed)
error: hook returned invalid user prompt submit JSON output

That message was hard to act on because:

  • the payload was valid JSON
  • the hook exited successfully
  • the actual problem was only that the JSON shape was not the event contract Codex expected

Once I understood the contract, the local fix was straightforward. The expensive part was the diagnosis.

I think this strengthens the request in this issue for more specific hook-output diagnostics. For example, something along the lines of:

  • unsupported top-level field "additionalContext" for UserPromptSubmit
  • or expected plain text stdout or hookSpecificOutput.additionalContext

A docs-side improvement could also help, even if behavior stays exactly as-is: an explicit note that for UserPromptSubmit, top-level JSON additionalContext is not a supported form, even though plain text stdout and hookSpecificOutput.additionalContext are.

I am not asking for broader compatibility here unless that already fits the roadmap. Better event-aware error messages, or a small clarification in the hooks docs, would already make this class of integration mistake much easier to debug.

Related: #19385 for another hook-event parity / docs-clarity case.

dustingregg1 · 1 month ago

Adding a data point from Windows that may be a stronger variant of this issue: in my case the opaque "invalid stop hook JSON output" error fires for a Stop hook whose command is never executed at all, so no output shape can satisfy the validator.

Environment

  • codex-cli 0.144.4 (standalone Windows installer), Windows 11 Pro (build 26200)
  • Hooks registered in ~/.codex/hooks.json, each as a quoted absolute-path .cmd wrapper with a commandWindows field (the invocation form that works reliably on Windows)

Symptom
Every turn ends with:

Stop hook (failed)
  error: hook returned invalid stop hook JSON output

Evidence that the registered command never runs

I replaced the Stop hook target with an instrumented wrapper that (1) appends a timestamped line to a debug log on every invocation, (2) captures stdin to a file, (3) writes bare {} (hex 7B 7D) to stdout via process.stdout.write in node (no shell echo involved), and (4) exits 0.

  • Manual pipe test (payload | cmd /c "<wrapper>.cmd"): log line written, stdin captured, stdout is exactly {}, exit 0.
  • Real turn-end, across multiple fresh sessions (full restart, hook trust re-approved, hooks.json verified to point at the wrapper, trusted hash recorded in config.toml): the error above is reported and the debug log receives zero entries. The command is never spawned.

Meanwhile the identical registration form (same .cmd + commandWindows pattern, same file) reports "completed" on all nine other events I have registered: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, PermissionRequest. Only Stop fails.

Before instrumenting, I iterated output shapes as if it were a schema problem: plain text, {"continue":true} (listed as a common output field for Stop in the hooks docs), and bare {} all produced the identical error, which is consistent with the output never being read rather than rejected.

Two asks

  1. The error string conflates "hook returned invalid JSON" with what appears to be a pre-execution failure inside the Stop pipeline. Per #27052, surfacing spawn/exec failures distinctly from output-validation failures would have saved several rounds of schema guessing.
  2. Stop-event execution itself appears broken on this Windows build. If useful I can provide the full hooks.json, the instrumented wrapper, and debug logs.

Minimal repro sketch (Windows)

"Stop": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "\"C:/Users/<user>/.codex/hooks/stop-test.cmd\"",
        "timeout": 10,
        "commandWindows": "\"C:/Users/<user>/.codex/hooks/stop-test.cmd\""
      }
    ]
  }
]

stop-test.cmd:

@ECHO OFF
@ECHO invoked >> "C:\Users\<user>\.codex\hooks\stop-test.log"
@ECHO {}
@EXIT /B 0

Observed: "invalid stop hook JSON output" at every turn end, and stop-test.log is never created.