Claude Code · hooks

Wiring Claude
Hooks into an app

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.

Where they fire

Fixed points in the agent loop

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.

SessionStart PreToolUse Stop UserPromptSubmit PostToolUse SessionEnd session opens you hit enter session closes repeats for every tool call in the turn can block or rewrite observe only
One turn, and the hook points along it. The marked events run before the thing they're named after, so their exit code decides whether it happens at all.
The contract

JSON in, exit code out

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.

Claude Code waits here event JSON → stdin exit code · stdout · stderr your script one process per event your $PATH, your env killed at the timeout exit 0 — carry on, nothing said exit 2 — blocked; stderr is the reason stdout — JSON: allow / deny / add context and, on the side… POST · write · notify
The blocking channel and the side-effect channel are independent. A hook can ship the event to your service and exit 0 in the same breath — the agent never notices.
observe

Exit 0 and get out of the way

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.

intervene

Exit 2 and say why

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.

Build order

Script, wiring, consumer

01

Write a script that reads stdin

Bash and jq is plenty. Anything that reads stdin works.

02

Register it in settings.json

Event name → optional matcher → command and timeout.

03

Point it at something that's listening

A service, a socket, a file. That's the app.

01 · the script

An observer, in twelve lines

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.

~/.claude/hooks/capture.sh observe · exit 0 always
#!/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.

~/.claude/hooks/no-blocking-commands.sh intervene · exit 2 blocks
#!/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:

stdout · structured decision exit 0
{
  "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.

~/.claude/hooks/deliver-notes.sh intervene · injects context
#!/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")))
  }
}'
02 · the wiring

Nine lines of settings.json

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.

~/.claude/settings.json event → matcher → command
{
  "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" }] }
    ]
  }
}
matcher
A regex, matched against the tool name on tool events (and against the relevant name on a few others). Omit it or leave it empty to catch everything. Events with no such dimension — Stop, SessionStart — just skip the field.
groups
Every group whose matcher hits runs, and all their commands run in parallel. Two hooks on the same event is normal, not a conflict.
timeout
Seconds, per command. Past it the process is killed and the turn continues. Set it low and deliberately — this number is how long you're willing to make the agent wait.
settings tiers
User, project, and local-project settings all contribute; hooks merge across tiers rather than override. A machine-wide capture hook and a repo-specific guard coexist fine.
reloading
Edits to hook config are picked up for safety reasons only at a safe point — restart the session, or use /hooks, if a change doesn't seem to take.
03 · the app

What you get once it's plumbed

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.

sessions every pane, every worktree, every subagent stdin capture hook POST if it's down spool dir replayed your service dedupes by id append event store SSE browser UI live feed, diffs queued in the service UserPromptSubmit hook additionalContext
Capture runs left to right and never blocks; delivery runs right to left and rides the next prompt. The spool is what makes it safe to restart the service mid-session.

What that stream is actually good for, once you have it:

Hard-won

Five things worth knowing first

fail open
A capture hook must exit 0 on every path — missing 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.
they're machine-wide
A hook in user settings fires in every session on the box, concurrently. Your receiver sees interleaved streams from many sessions and must key on session_id. Sessions also outlive your service, so treat delivery as at-least- once and de-duplicate on an id you stamp yourself.
process cost is real
One fork+exec per event per hook. On a busy event that adds up fast, so keep the script to a POST and let the service do the thinking. Where the CLI supports an async flag on a hook entry, use it for anything whose answer you don't need.
stdout is overloaded
Its meaning depends on the event: on most, plain stdout is just shown; on UserPromptSubmit and SessionStart it becomes model context. Emit structured JSON when you mean a decision, and keep debug logging on stderr or a file.
ordering isn't promised
Parallel hooks, parallel tool calls, and a spool that replays later all mean events can arrive out of order. Sort on the receiver by something you control, and make replay idempotent.