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:

# 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:

{
  "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