What Claude Code actually passes to your hooks
Every Claude Code hook receives a JSON payload on stdin, and most example hooks throw it away. Here is the data contract for the events people actually wire up, and the things the payload cannot tell you.
Most Claude Code hook examples are one-liners that play a sound. They work, and they ignore the most useful part of the hook system: every hook receives a JSON payload on stdin describing what just happened. If your hook reads it, a dumb bell turns into something that knows which project finished, which session is blocked, and what Claude just did.
We built Unwait on top of these payloads, so this post is the data contract as we actually use it: the fields that arrive, the events worth wiring, and the two things we learned the payload cannot tell you.
The envelope every event shares
Whatever the event, the JSON on stdin carries a common envelope. The fields you will reach for:
| Field | What it is | What it is for |
|---|---|---|
session_id |
Identifier of the current session | Telling concurrent sessions apart |
cwd |
Working directory of the session | Labeling notifications by project |
hook_event_name |
Which event fired | One script serving several events |
transcript_path |
Path to the conversation JSONL | Reading what happened, with a caveat below |
permission_mode |
default, plan, acceptEdits, bypassPermissions, ... |
Knowing how unattended the session is |
Reading it in a shell hook is one cat:
#!/bin/sh
payload="$(cat)"
project=$(printf '%s' "$payload" | jq -r '.cwd | split("/") | last')
event=$(printf '%s' "$payload" | jq -r '.hook_event_name')
session_id is the field that starts mattering the day you run two agents at once. Our daemon keys every wait on it, which is how one overlay can track three terminals without mixing them up. cwd is the human-readable half: its last path segment is almost always the project name, and a notification that says unwait finished beats Turn finished every time.
One caveat we hit: transcript_path points at a file that can lag the current turn. Treat it as history, not as live state.
The events worth wiring, and what each one carries
The hooks reference lists over twenty events now. For the "react to my agent" use case, five carry the signal:
UserPromptSubmit fires when you submit a prompt, and the payload includes the prompt text itself. This is the start-of-wait signal. Unwait posts it to the daemon and starts the clock; a prompt that is still unanswered N seconds later is what triggers a card.
Stop fires when Claude finishes responding. The payload includes last_assistant_message, the full text of the final response, plus a stop_hook_active flag covered below. This is the end-of-wait signal, and last_assistant_message makes a fine notification body, the same trick we described for Codex's notify hook.
Notification fires when Claude Code wants your attention, with a message and a notification_type. The types worth filtering on are permission_prompt (blocked on an approval) and idle_prompt (waiting for input). A blocked agent looks exactly like a working one from the outside, so this event is the difference between "it is thinking" and "it has been waiting for you for ten minutes". Wire it separately from Stop; they mean different things.
PreToolUse / PostToolUse fire around every tool call, with tool_name and the full tool_input, and on the Post side the tool_response too. These are matcher-scoped, so you can subscribe to just Bash, or just an MCP tool pattern. Most notification setups do not need them, but they are the payload-richest events in the system, and PreToolUse can veto or rewrite a call by writing JSON back.
SubagentStop fires per subagent with the subagent's final output. If you run one agent it is noise. If you orchestrate fleets, it is how you count completions without watching the screen.
What the payload cannot tell you
Two lessons from shipping against this contract.
The payload does not know which window it came from. session_id names the session and cwd names the project, but nothing in the JSON locates the terminal window on your screen. When Unwait's "Go" button needs to raise the right window, the payload has nothing for us. The information lives in the hook's process context instead: the hook runs as a child of the claude process, so $PPID identifies the agent's process, and walking up from it reaches the terminal app. Inside tmux even that fails, because a pane's ancestry dead-ends at the tmux server, so we pass $TMUX_PANE alongside. Everything environment-shaped, the payload leaves for you to collect:
curl -s -m 1 -X POST \
--data "$payload" \
"http://127.0.0.1:4242/wait/start?ppid=${PPID}&tmux_pane=${TMUX_PANE}" \
>/dev/null 2>&1 &
The payload is only as local as the machine it fired on. Process ids, pane ids, and paths all describe the host where claude runs. Ship them over an SSH tunnel and they describe the wrong computer. We wrote up that failure mode separately in Why your Claude Code hooks do nothing over SSH.
Talking back: exit codes, and the flag that prevents infinite loops
Hooks are not read-only. The short version of the contract:
- Exit 0 is success. If you print JSON, Claude Code parses it.
- Exit 2 blocks the thing that was about to happen, where blocking makes sense: it stops a
PreToolUsetool call, rejects aUserPromptSubmitprompt, and onStopit prevents Claude from stopping, feeding your stderr back in as instructions. - Any other exit code is a non-blocking error; the action proceeds.
Exit 2 on Stop is the sharp edge. It is how people build "keep going until the tests pass" loops, and it is also how people build turns that never end. That is what stop_hook_active in the Stop payload is for: it is true when the current turn is already a continuation forced by a stop hook. Check it before blocking again, or your loop has no exit:
active=$(printf '%s' "$payload" | jq -r '.stop_hook_active')
[ "$active" = "true" ] && exit 0 # already looping once; let it end
For a notification hook, none of this applies: always exit 0, background your work, and give network calls a timeout, because hooks run in the turn's critical path. We covered that discipline in the first post of this series.
Further reading
The full event list, including the newer lifecycle events and the JSON output schema for controlling Claude, is in the Claude Code hooks reference. The payloads shown here are the subset we have verified in production, forwarding every one of them to a local daemon since the day Unwait started dogfooding itself.