Unwait

What Claude Code actually passes to your hooks

· 5 min read claude code hooks workflow reference

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 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.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts