Hooks: PostToolUse payload carries no failure signal, and PostToolUseFailure never fires

Open 💬 0 comments Opened Jul 20, 2026 by sumitake

Summary

codex-cli's hooks system fires PostToolUse for completed tool calls whether they succeeded or failed, but the hook payload contains no failure discriminator: no is_error, no exit_code, no error field — and the PostToolUseFailure event name present in the binary's event vocabulary never fires. A hook that must act only on failures (e.g. a failure-triggered context-injection nudge, diagnostics capture, or error telemetry) therefore cannot be built.

The exit code exists internally at dispatch time — codex's own rollout items record it — but it is stripped before the hook is invoked.

Environment: macOS arm64. Reproduced on 0.144.5 (2026-07-17) and 0.144.6 (2026-07-19), by two independent automated agents. The payload key set is identical across both releases.

Scope note, stated honestly: two consecutive patch releases cannot establish that this is intended design rather than a regression still in flight, and the two probes varied both version and agent, so those variables are confounded. The concrete claim is only the two-version reproduction; the cause and longevity are for you to determine.

Reproduction

  1. Register a catch-all logger hook in ~/.codex/hooks.json for PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop — a command that appends its stdin and env to a JSONL file and exits 0.
  2. Run a headless session driving three tool calls: a loud failure (cat /nonexistent-file, exit 1), a quiet failure (sh -c 'exit 3'), and a success (echo ok).
  3. Inspect the captured events.

Hygiene note for reproducers: running the probe from inside an already-sandboxed codex session fails with sandbox-exec: sandbox_apply: Operation not permitted (nested Seatbelt). Disable the sandbox for the child session only.

Observed

  • PostToolUse fires for all three calls. PostToolUseFailure fired zero times despite being registered — across 11 tool calls in the 0.144.5 session (2 of them failures), and across the 0.144.6 re-probe.
  • Payload key set, identical for success and failure:

``
cwd, hook_event_name, model, permission_mode, session_id,
tool_input, tool_name, tool_response, tool_use_id, transcript_path, turn_id
``

  • A field-by-field value diff across loud-failure / quiet-failure / success shows only tool_input, tool_response, and tool_use_id differ. Raw stdin parses to exactly the payload (no additional frames). The hook-subprocess environment is identical across all three outcomes — no outcome-specific env key.
  • tool_response is a plain string (the output text as shown to the model), not a structured object:

| call | tool_response |
|---|---|
| cat /nonexistent-file (exit 1) | "cat: /nonexistent-file: No such file or directory\n" |
| sh -c 'exit 3' (exit 3) | "" ← indistinguishable from a quiet success |
| echo ok (exit 0) | "ok\n" |

The quiet-failure case is the decisive one: an empty tool_response is byte-identical to a successful command that printed nothing.

  • The exit code is known internally. The session rollout's custom_tool_call_output item for the failing call embeds {"chunk_id":…,"exit_code":1,…,"output":"cat: …"}, and the binary's CommandExecutionItem struct carries stdout/stderr/aggregated_output/exit_code. None of it reaches the hook.
  • A transcript-side workaround is not viable: the hook payload's tool_use_id (exec-<uuid>) does not match the rollout item's call_id (call_<base62>), so there is no join key, and the rollout schema is internal and unpinned.

Requested (any one of these unblocks failure-gated hooks)

  1. A dedicated PostToolUseFailure event that actually fires on failed tool calls — the event name already exists in the binary's vocabulary; or
  2. Failure fields on the PostToolUse payload: is_error: bool and/or exit_code: int. The data already exists internally at dispatch time; or
  3. A stable join key in the hook payload equal to the rollout call_id, plus a documented, version-pinned rollout schema. (Weakest option — it would still require parsing a large internal-format file inside the hook's timeout budget.)

Why it matters

codex's hooks system otherwise mirrors Claude Code's contract closely — same JSON shape, hook_event_name, session_id, hookSpecificOutput / additionalContext, and even the Bash tool name. Claude Code splits success and failure into PostToolUse / PostToolUseFailure, which is what enables failure-triggered tooling: error-pattern nudges, automatic diagnostics capture, failure telemetry.

Because codex exposes no equivalent signal, that entire class of hook cannot be ported. Content heuristics on tool_response are not an acceptable substitute: they fire on successful calls whose output merely contains error-like text (test logs, grep output), and they cannot detect the quiet-failure case at all.

View original on GitHub ↗