aieveryminute

Three Claude Code hook fields the docs name wrong

The docs said your PostToolUse hook receives tool_output. It receives tool_response. Here is what 22 captured hook events actually contained, and a re-check showing the documentation has since caught up on every point.

UPDATE, 2026-08-21: every discrepancy below has since been fixed in the documentation. Re-checking the hooks reference today, tool_output is gone and tool_response is documented; end_reason is gone; and load_reason, memory_type, duration_ms, background_tasks, session_crons and stop_hook_active all now appear in the field tables. I am not claiming this page caused that, only reporting that the mismatch no longer exists. What follows is a record of what the payloads and the docs actually said on 2026-08-07 against Claude Code 2.1.223, and the fields themselves are still delivered exactly as captured. If you are reading it for the field names, trust the current doc over this post.

The hooks reference documents 31 events and the fields each one delivers on stdin. Rather than trust the field list, I wired 15 events to a script that dumps its stdin verbatim, ran two throwaway headless sessions, and diffed what actually arrived against what was promised.

Three field names did not match. Several useful fields were not documented at all.

The setup

Every hook points at one script that appends its stdin to a log with a millisecond timestamp:

#!/usr/bin/env bash
LOG="$(dirname "$0")/events.jsonl"
jq -c --arg ts "$(gdate +%s%3N)" --arg ev "$1" \
  '{ts:$ts, hook_arg:$ev, payload:.}' >> "$LOG"
exit 0

Wired into a throwaway project’s .claude/settings.json, one entry per event, then driven with two trivial prompts through claude -p. Twenty-two events captured. Everything below reproduced across both runs.

tool_output does not exist

This is the one that will actually break your hook. The documentation lists tool_output as the field carrying a tool’s result to PostToolUse. The payload does not contain it:

tool_response=true  tool_output=false  duration_ms=1  tool=Read
tool_response=true  tool_output=false  duration_ms=1  tool=Read
tool_response=true  tool_output=false  duration_ms=1  tool=Read

Three tool calls across two sessions, tool_output absent every time. The field is tool_response. A hook written from the documented name reads undefined and, because most people write these as one-liners that exit 0 regardless, fails silently forever.

PostToolBatch uses the same name. Each entry in its tool_calls array contains exactly:

tool_input, tool_name, tool_response, tool_use_id

Two more renamed fields

SessionEnd is documented as delivering end_reason. It delivers reason:

SessionEnd  reason=true  end_reason=false   value: "other"

InstructionsLoaded is documented as delivering reason. It delivers load_reason, and adds an undocumented memory_type:

{ "load_reason": "session_start", "memory_type": "User" }

So the two events that both carry a “why did this happen” field use opposite names to the ones documented, in opposite directions. Worth checking rather than assuming.

Undocumented fields worth knowing about

PostToolUse carries duration_ms, which is not in the field list. That is per-tool-call timing for free, and it is the obvious foundation for a hook that flags slow tools without any external instrumentation.

Stop carries three fields that are not documented:

{ "background_tasks": [], "session_crons": [], "stop_hook_active": false }

stop_hook_active is the useful one. A Stop hook that blocks to continue the conversation can trivially recurse forever, and this flag is how you detect you are already inside one. background_tasks and session_crons let a Stop hook see whether work is still outstanding before deciding the turn is really over.

MessageDisplay is not shaped how you would guess

The docs describe message_text and message_id. There is no message_text. The payload carries:

cwd, delta, final, hook_event_name, index, message_id,
prompt_id, session_id, transcript_path, turn_id

The text arrives in delta. In both runs it fired twice per response, once per assistant text block, with final: true and index: 0 each time:

{ "index": 0, "final": true, "delta": "I'll read both files." }
{ "index": 0, "final": true, "delta": "DONE" }

The delta and final naming suggests a streaming design, but at least in these runs it behaved as one call per completed block rather than per token. If you write a MessageDisplay hook, assume it runs several times per response, not once.

One thing I could not establish

My first read of the log said InstructionsLoaded fires before SessionStart, because that was the line order in the file. The timestamps say otherwise: SessionStart at ...467, InstructionsLoaded at ...468. One millisecond apart, which means the append order raced and proves nothing about sequencing.

Do not infer hook ordering from log line order when hooks run concurrently. Stamp inside the hook and sort on that, and treat sub-millisecond gaps as unordered.

Practical takeaway

If you have hooks in production, grep them for tool_output and end_reason right now. Both are plausible enough to have been copied straight out of the docs, and both fail quietly.

More generally, hooks are an area where a five-line capture script beats reading. The payload is right there on stdin. Dump it once for the events you care about and you will never have to guess at a field name again.

All of the above is against Claude Code 2.1.223. Field names are implementation detail and can move between versions, so re-run the capture rather than trusting this post six months from now.

POSTaieveryminute.com#behaviourbuilt 2026-08-31 17:47 UTC