Claude Code hooks that matter for unattended runs
Four hooks cover almost everything a headless claude -p run needs
to react to on its own: a tool about to run, a permission prompt about to show, an internal
notification worth surfacing, and a turn ending. Each one has its own stdin shape and, less
obviously, its own separate output contract — mixing them up is the most common way a hook
silently does nothing.
On this page
The shared shape: stdin, config, timeout
Every hook receives one JSON object on stdin: session_id,
transcript_path, cwd, hook_event_name, plus fields specific
to that event. Hooks are wired up under hooks.<Event> in settings.json
(or a plugin's hooks/hooks.json, same shape) as a matcher plus a list of commands to
run:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./check-bash.sh" }] }
],
"Notification": [
{ "matcher": "*", "hooks": [{ "type": "command", "command": "./notify.sh" }] }
]
}
}
The default timeout is 600 seconds per hook invocation. A hook that times out doesn't block anything — the action it was supposed to weigh in on just proceeds through the normal permission flow as if the hook hadn't fired at all, which is worth testing deliberately (kill the hook process, confirm the run doesn't hang) rather than discovering the first time your hook script hangs in production.
PreToolUse
Fires before a tool call runs. Stdin adds tool_name, tool_input, and
tool_use_id to the shared fields. Two ways to respond:
- Exit 2 blocks the call unconditionally — no JSON needed, just a non-zero exit with this specific code.
- Exit 0 plus JSON on stdout sets
hookSpecificOutput.permissionDecisionto one ofallow,deny,ask, ordefer.
# allow a read-only Bash command outright
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
# ask (fall through to the normal prompt/permission-prompt-tool flow) for anything else
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}'
Multiple hooks can match the same tool call; when they disagree, precedence is
deny > defer > ask > allow — the most conservative decision
present wins, so one cautious hook can override three permissive ones, but not the reverse.
defer (CLI ≥2.1.89, -p-only, and still missing from the official
hooks reference — see anthropics/claude-code#41791) is the one built specifically for
"go ask a human, however long that takes." Returning it exits the process with
stop_reason: "tool_deferred" and a deferred_tool_use: {id, name, input}
payload describing exactly the pending call, for exactly one pending tool call at a time. Resume
later — the same session, once your own durable store shows the decision made — and the same hook
re-fires for that exact call:
claude -p --resume "$SESSION_ID" --permission-prompt-tool mcp__yourservice__permission_prompt
This is the mechanic the Agent SDK's own defer/resume
guidance is built on, and it's the right building block for a request_approval
adapter that shouldn't hold a process open for hours.
PermissionRequest
A newer, more targeted hook (CLI ≥2.0.45) that fires specifically when Claude Code is about to
show a permission prompt — after PreToolUse has already run and not resolved the
call on its own. Its output contract is different in a way that's easy to get wrong if you copy a
PreToolUse handler verbatim:
- Exit 2 is not honored here. There's no unconditional-block shortcut.
- The decision must be a JSON object, not just a top-level string:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "no unattended prod deploys after 6pm"
}
}
}
behavior is "allow" or "deny"; an allow can carry
updatedInput to rewrite the call's arguments before it runs, and either can carry a
human-readable message. If no hook produces a decision at all in a session that has
no way to show an interactive prompt — headless, no TTY — the default is deny,
not allow. Build your unattended path assuming silence means "no," and wire a hook (or
--permission-prompt-tool, see the raw CLI
contract) explicitly rather than relying on nothing firing to mean "proceed."
Notification
Fires on internal events — notification_type values like
permission_prompt and agent_needs_input — and, unlike the two hooks
above, cannot block anything. It's a signal, not a gate. The one timing detail
worth knowing: the permission_prompt notification fires about 6 seconds
after the prompt actually starts waiting, not the instant it appears. For anything that
needs to react immediately — paging someone, starting a countdown — use
PermissionRequest instead, which fires synchronously as the prompt is about to be
shown. Notification is the right hook point for a plain notify() call
that doesn't need to be instant: a Slack message, a log line, an entry in a dashboard.
Stop
Fires when a turn ends. A top-level (not hookSpecificOutput-wrapped) response of
{"decision": "block", "reason": "..."} forces Claude to keep going instead of
stopping — useful for "don't consider this done until the tests pass" style checks. It's
auto-overridden after 8 consecutive blocks, so a broken check can't wedge a
session into looping forever; after the eighth block in a row the turn is allowed to end
regardless of what the hook says.
#!/usr/bin/env bash
# stop-hook.sh — keep going until the build actually passes, capped by the 8-block ceiling
if ! npm test --silent >/dev/null 2>&1; then
echo '{"decision":"block","reason":"tests still failing"}'
fi
command, mcp_tool, and http hooks
A hook entry isn't limited to a local command. Two other types exist
for the same JSON-in/JSON-out contract without shipping a script:
{ "type": "mcp_tool", "server": "yourservice", "tool": "check_policy", "input": {} }
{ "type": "http", "url": "https://api.example.dev/hooks/pretooluse", "headers": { "Authorization": "Bearer ${API_KEY}" } }
http is the simplest no-bundling integration path for a hosted service: it POSTs
the hook's stdin JSON to your URL and reads the same JSON shape back, so a permission policy that
already lives behind an API doesn't need a local adapter script at all — see
authenticating that endpoint if it needs a bearer token.
Gotchas
- Don't reuse a PreToolUse handler for PermissionRequest. The exit-2 shortcut and the bare-string decision both silently do nothing under the wrong hook — verify each one with a deliberate deny before trusting it.
- Precedence isn't "last hook wins." When several hooks fire for the same
event, the most restrictive decision (
deny > defer > ask > allow) wins regardless of registration order. - A hung hook is invisible. It doesn't error, doesn't block, doesn't log by default — it just quietly times out at 600s and the run proceeds as if it weren't there. Log from inside the hook script itself if you need to know it ran.
- Test the deny path, not just the allow path. A hook that's only ever been exercised on the happy path can have a broken JSON shape that only surfaces the first time it actually needs to say no.