A hook is a shell script that Claude Code runs at a fixed point in its own loop. It gets the event as JSON on stdin. What it does with that — and what it says back — is the whole extension mechanism.
Every hook is named after a moment: the session opening, a prompt being submitted, a tool about to run, a tool having run, the turn ending. There are a few dozen of them, covering tool lifecycle, prompt and turn lifecycle, session lifecycle, subagents, and environment changes like a config file being edited mid-session.
The useful split isn't by category, it's by power. Some fire after the fact and can only watch. Some fire before the thing happens, and can stop it.
There is no SDK and no daemon. Claude Code spawns your command as a normal process, writes one JSON object to its stdin, and waits (up to a timeout you set). Your script's exit code and stdout are the entire return channel.
The event is yours to keep. Push it somewhere, then exit clean. This is the mode you build an app on, and the only rule is: never make the agent wait on you.
On a pre-flight event, exit 2 cancels the action and feeds stderr back to the model as the reason. It reads the message and adapts — this is how you turn a house rule into something the agent physically cannot violate.
Bash and jq is plenty. Anything that reads stdin works.
Event name → optional matcher → command and timeout.
A service, a socket, a file. That's the app.
This is the shape of a capture hook: stamp the payload with an id so the receiver can de-duplicate replays, POST it, and if the POST fails, drop it in a spool directory for later instead of losing it. Note the exit codes — every path exits 0. A capture hook that fails closed would wedge the agent every time your service restarts.
#!/usr/bin/env bash
# Fire-and-forget: ship the event, spool it if the service is down.
set -uo pipefail
PORT=8787 # ← your port number here
API="http://127.0.0.1:$PORT/api/hooks"
SPOOL="$HOME/.local/state/myapp/spool" # ← wherever you like
uuid=$(uuidgen) || exit 0
# stdin is the event JSON; add an idempotency key for replays
payload=$(jq -c --arg u "$uuid" '. + {client_uuid: $u}') || exit 0
code=$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' \
-X POST "$API" \
-H 'Content-Type: application/json' \
-d "$payload") || code=000
[[ "$code" == 2* ]] && exit 0
# maildir-style spool: write to tmp/, then rename() into new/ so the
# reader never sees a half-written file. Replayed on the next sweep.
mkdir -p "$SPOOL/tmp" "$SPOOL/new"
printf '%s' "$payload" > "$SPOOL/tmp/$uuid.json" \
&& mv "$SPOOL/tmp/$uuid.json" "$SPOOL/new/$uuid.json"
exit 0
The payload every event carries: session_id, transcript_path,
cwd, hook_event_name, plus whatever that event
adds —
tool_name and tool_input for a tool event,
prompt
for a prompt event. You don't have to model any of it up front. Promote
the fields you query on, keep the rest as a JSON blob.
#!/usr/bin/env bash
# PreToolUse[Bash] — refuse commands that would stall the session.
input=$(cat) || exit 0
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""') || exit 0
if printf '%s' "$cmd" | grep -Eq 'sleep[[:space:]]+[0-9]{2,}'; then
# stderr goes back to the model as the rejection reason — write it
# like an instruction, not an error. The model reads this and retries.
echo "Blocked: no sleep over 10s. Dispatch the work, then check once." >&2
exit 2
fi
exit 0
For finer control than a binary yes/no, print a JSON object on stdout
instead. The same
PreToolUse hook can auto-approve rather than merely permit
— no permission prompt, no interruption:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "edits under .claude/ are pre-approved"
}
}
And the direction that makes the whole thing a loop rather than a tap:
on
UserPromptSubmit, whatever your hook returns as
additionalContext is prepended to the user's
prompt before the model sees it. Your app now has a way to
talk into the session.
#!/usr/bin/env bash
# UserPromptSubmit — pull anything the app has queued for this session
# and hand it to the model as context. Deliver-once lives server-side.
PORT=8787 # ← same service as above
API="http://127.0.0.1:$PORT"
session=$(jq -r '.session_id') # from the event on stdin
notes=$(curl -sf -m 2 -X POST "$API/notes/$session/take-pending") || exit 0
[[ -z "$notes" || "$notes" == "[]" ]] && exit 0
jq -n --argjson notes "$notes" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: ("Notes left on your diff — address them:\n"
+ ($notes | map("- " + .path + ":" + (.line|tostring)
+ " — " + .summary) | join("\n")))
}
}'
Hooks are registered in settings.json — user-level at
~/.claude/settings.json, or project-level at
.claude/settings.json in the repo. The structure is the
same three levels every time: event name → a list of
matcher groups → the commands in
each group.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", // regex over the tool name
"hooks": [{ "type": "command",
"command": "~/.claude/hooks/no-blocking-commands.sh",
"timeout": 5 }] },
{ "matcher": "", // empty = every tool
"hooks": [{ "type": "command", "command": "~/.claude/hooks/capture.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write", // alternation works
"hooks": [{ "type": "command", "command": "~/.claude/hooks/format.sh" }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/deliver-notes.sh" }] }
]
}
}
Stop, SessionStart — just skip the field.
/hooks, if a change
doesn't seem to take.
Now the payoff. Every session on the machine — every pane, every
worktree, every subagent — runs the same capture hook, so a single
localhost service sees the whole fleet's activity as one ordered stream,
tagged by session_id. Persist it and you have history;
stream it to a browser and you have a live view.
The delivery hook closes the loop the other way. That's the part that turns a dashboard into a companion: you leave a note on a diff in the UI, and it arrives inside the agent's next turn as context, without anyone retyping anything.
What that stream is actually good for, once you have it:
PostToolUse on
Edit|Write tells you exactly which files this session
changed, which is a diff scope you cannot get from git alone.
PreToolUse hook that exits 2 is a constraint.
jq,
dead service, full disk. A non-zero exit on some events surfaces as
noise; on a pre-flight event, exit 2 blocks real work. Your telemetry
should never be able to stop the agent.
session_id. Sessions also outlive your service, so treat
delivery as at-least- once and de-duplicate on an id you stamp yourself.
async flag on a hook entry, use it for
anything whose answer you don't need.
UserPromptSubmit and SessionStart it becomes
model context. Emit structured JSON when you mean a decision, and keep
debug logging on stderr or a file.