C Claude Code Internals
EN | ES

Hooks

Hooks are shell commands, LLM prompts, multi-turn agents, or HTTP calls that execute in response to Claude Code events. They can block tool calls, post-process results, transform prompts, auto-format files, and control permissions programmatically.

27 hook events 4 hook types 7 hook sources all run in parallel
i Event-driven automation for Claude Code
Every time Claude runs a tool, starts a session, requests a permission, or completes a turn, hooks fire. They run in parallel, receive a JSON payload on stdin, and return structured JSON on stdout. Exit code 2 blocks the operation and sends stderr to the model.

Hook Sources (Priority Order)

# Source Location Scope
1 Policy (managed) Managed settings Enterprise-wide, highest authority
2 User settings ~/.claude/settings.json Personal, all projects
3 Project settings .claude/settings.json Shared with team
4 Local settings .claude/settings.local.json Private per project
5 Plugin hooks ~/.claude/plugins/*/hooks/hooks.json Plugin-scoped
6 Session hooks In-memory (from skills/agents) Temporary, current session only
7 Function hooks In-memory (SDK/plugins) Programmatic validation

All matching hooks for a given event run in parallel.

All 27 Hook Events

Tool Lifecycle

PreToolUse Before tool runs. Can block or modify input.
PostToolUse After tool succeeds. Can inject context.
PostToolUseFailure After tool fails or is interrupted.

Session

SessionStart startup / resume / clear / compact
SessionEnd clear / resume / logout / exit / other
UserPromptSubmit When user submits a prompt. Can block.
Stop Turn completed (no more tool calls).
StopFailure Turn failed with an error.
Setup One-time setup: init / maintenance.

Permissions

PermissionRequest Before permission dialog shows. Can auto-approve.
PermissionDenied After user denies. Can retry.
Notification When Claude sends a notification.

Subagents & Teams

SubagentStart When a subagent is spawned.
SubagentStop When a subagent completes.
TeammateIdle Teammate has no work.
TaskCreated Task created in a team.
TaskCompleted Task completed.

Context

PreCompact Before compaction. manual / auto.
PostCompact After compaction. Includes summary.
InstructionsLoaded When CLAUDE.md / memory files load.
ConfigChange Settings files changed.

MCP & Filesystem

Elicitation MCP server requests user input.
ElicitationResult After MCP elicitation response.
WorktreeCreate Git worktree created.
WorktreeRemove Git worktree removed.
CwdChanged Working directory changed.
FileChanged A watched file changed.

The 4 Hook Types

command — most common

{
  "type": "command",
  "command": "prettier --write "$FILE"",
  "if": "Write|Edit",
  "shell": "bash",
  "timeout": 30,
  "statusMessage": "Formatting...",
  "once": false,
  "async": false,
  "asyncRewake": false
}
timeout — seconds (default: 600)
once — auto-remove after first run
async — run in background without blocking
asyncRewake — background + wake model on exit 2

prompt — LLM evaluation

{
  "type": "prompt",
  "prompt": "Review for security issues: $ARGUMENTS",
  "if": "Write|Edit",
  "timeout": 30,
  "model": "claude-sonnet-4-6",
  "statusMessage": "Reviewing..."
}
$ARGUMENTS — replaced with the full input JSON
model — defaults to small fast model
timeout — default: 30 seconds

agent — multi-turn verification

{
  "type": "agent",
  "prompt": "Verify tests still pass: $ARGUMENTS",
  "timeout": 60,
  "model": "claude-sonnet-4-6"
}
Has access to tools, up to 50 turns
model — defaults to Haiku
timeout — default: 60 seconds

http — webhook / external API

{
  "type": "http",
  "url": "https://api.example.com/hooks/claude",
  "headers": {
    "Authorization": "Bearer $API_TOKEN"
  },
  "allowedEnvVars": ["API_TOKEN"],
  "timeout": 30
}
POSTs hook input JSON as body
allowedEnvVars — whitelist for $VAR expansion in headers
SSRF guard: blocks localhost/internal IPs

Configuration Format

settings.json structure

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "prettier --check "$FILE"",
            "timeout": 10
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "notify-done.sh"
          }
        ]
      }
    ]
  }
}

Skill frontmatter hooks

---
hooks:
  PostToolUse:
    - matcher: "Write|Edit"
      hooks:
        - type: command
          command: "prettier --write $FILE"
---

Registered as in-memory session hooks. Active only during skill invocation (or the session if once: false).

Agent frontmatter hooks

Same YAML format. Stop hooks are automatically converted to SubagentStop hooks, scoped to the agent's ID, and cleaned up when the agent finishes.

Hook Input (stdin) & Output (stdout)

Base input fields (all events)

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/path/to/project",
  "hook_event_name": "PreToolUse",
  "permission_mode": "default",
  "agent_id": "agent-xyz",
  "agent_type": "general-purpose"
}

Key per-event additions

PreToolUse / PostToolUse
tool_name, tool_input, tool_use_id
// PostToolUse adds: tool_response
// PostToolUseFailure adds: error, is_interrupt
SessionStart
source, agent_type, model
Stop / SubagentStop
stop_hook_active, last_assistant_message
// SubagentStop adds: agent_id, agent_transcript_path
PermissionRequest
tool_name, tool_input, permission_suggestions
FileChanged / CwdChanged
file_path, event           // FileChanged
old_cwd, new_cwd            // CwdChanged

Output: plain text or JSON

If stdout starts with {, it's parsed as JSON. Otherwise shown as plain text in the transcript.

{
  "continue": true,
  "suppressOutput": false,
  "stopReason": "string",
  "decision": "approve|block",
  "reason": "string",
  "systemMessage": "shown to user",
  "hookSpecificOutput": { }
}

hookSpecificOutput by event

PreToolUse
permissionDecision: "allow|deny|ask"
permissionDecisionReason: "string"
updatedInput: { }     // modify the tool input
additionalContext: "" // injected as context
PostToolUse
additionalContext: ""
updatedMCPToolOutput: { }  // replace MCP output
SessionStart
additionalContext: ""
initialUserMessage: ""  // auto-inject message
watchPaths: ["/path/"]  // set up FileChanged watchers
PermissionRequest
decision: {
  behavior: "allow|deny",
  updatedInput: { },
  updatedPermissions: [
    { tool: "Bash(npm*)", behavior: "allow" }
  ]
}

Exit Codes

Code Meaning Behavior
0 Success Stdout shown in transcript or parsed as JSON
2 Blocking error Blocks the operation. Stderr sent to model as feedback.
other Non-blocking error Shown to user only, does not block the action
Event Exit code 2 behavior
PreToolUse Blocks the tool call. Stderr shown to model.
PostToolUse Shows stderr to model immediately as feedback.
UserPromptSubmit Blocks processing, clears prompt, shows stderr to user.
Stop Shows stderr to model and continues the conversation.
SessionStart Blocking errors are ignored — session starts anyway.

Matching & the if Filter

matcher field (3 modes)

Mode Example
Exact "Write"
Pipe-separated "Write|Edit"
Regex "^Write.*" or ".*"

No matcher = fires for ALL instances of that event. Matched against tool_name, source, agent_type, etc. depending on the event.

if condition — secondary filter

Uses permission rule syntax. Evaluated before spawning the hook process. Avoids unnecessary process creation.

// Only git commands
"if": "Bash(git *)"

// Only npm publish
"if": "Bash(npm publish:*)"

// Only TypeScript writes
"if": "Write(*.ts)"

// Only edits in api/ directory
"if": "Edit(src/api/*)"

Permission Integration

PreToolUse permission precedence

Hook says "allow"
  + deny rule exists  → DENY (rule wins)
  + ask rule exists   → FORCE prompt dialog
  + no rules          → ALLOW ✓

Hook says "deny"      → DENY immediately

Hook says "ask"       → Force permission dialog

Multiple hooks: deny > ask > allow

A hook allow does NOT bypass deny/ask rules from settings.json. Rules always win.

PermissionRequest — auto-approve in CI

Fires before the permission dialog would show. Hooks can auto-approve or auto-deny without human interaction.

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow",
      "updatedPermissions": [
        { "tool": "Bash(npm test:*)",
          "behavior": "allow" }
      ]
    }
  }
}

Async Hooks

Config-based async

{
  "type": "command",
  "command": "slow-task.sh",
  "async": true
}

Hook runs in background. Claude continues without waiting.

Runtime async declaration

Hook declares itself async at runtime via its first stdout line:

{"async": true, "asyncTimeout": 30}

asyncRewake

{
  "type": "command",
  "command": "check-ci.sh",
  "async": true,
  "asyncRewake": true
}

When this async hook exits with code 2, it wakes the model via enqueuePendingNotification(). Enables background monitoring patterns.

Environment Variables & Timeouts

Available to command hooks

CLAUDE_PROJECT_DIR Root project directory (stable, not worktree path)
CLAUDE_PLUGIN_ROOT Plugin/skill root directory
CLAUDE_PLUGIN_DATA Plugin data directory
CLAUDE_PLUGIN_OPTION_* Plugin option values (UPPERCASE)
CLAUDE_ENV_FILE Path to .sh file for BashTool env vars. SessionStart, Setup, CwdChanged, FileChanged only.
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS Override SessionEnd timeout (default: 1500ms)

Default timeouts

General (command / http) 10 min
SessionEnd hooks 1.5 sec
Prompt hooks 30 sec
Agent hooks 60 sec
Async hooks (asyncTimeout) 15 sec
Function hooks 5 sec

The timeout field in hook config is in seconds.

Security policies

allowManagedHooksOnly — only policy hooks execute
disableAllHooks — in policySettings: disables all hooks; in other sources: only non-managed

Interactive Prompt Elicitation

Command hooks can request user input via a stdout/stdin protocol — enabling interactive multi-step hooks without a separate UI.

Hook writes to stdout:

{
  "prompt": "request-id-123",
  "message": "Choose deployment target:",
  "options": [
    { "key": "staging",    "label": "Staging" },
    { "key": "production", "label": "Production" }
  ]
}

Claude Code writes back to hook's stdin:

{
  "prompt_response": "request-id-123",
  "selected": "staging"
}

Practical Examples

Auto-format after file edits

// PostToolUse, matcher: "Write|Edit"
{
  "type": "command",
  "command": "prettier --write "$(cat | jq -r '.tool_input.file_path')"",
  "timeout": 10,
  "statusMessage": "Formatting..."
}

Block dangerous git commands

// PreToolUse, matcher: "Bash", if: "Bash(git *)"
{
  "type": "command",
  "command": "CMD=$(cat|jq -r '.tool_input.command'); if echo "$CMD" | grep -qE 'force|reset --hard'; then echo 'Blocked' >&2; exit 2; fi"
}

Auto-approve permissions in CI

// PermissionRequest, matcher: "Bash"
{
  "type": "command",
  "command": "echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'"
}

Background CI monitoring (asyncRewake)

// Stop event
{
  "type": "command",
  "command": "check-ci-status.sh",
  "async": true,
  "asyncRewake": true
}
// When CI fails, script exits 2 → wakes model
! Hooks run with full user permissions
Command hooks execute as the user's shell process with no sandboxing. A malicious hook in a project's .claude/settings.json could execute arbitrary code. Always verify project-level hooks before accepting workspace trust, and use allowManagedHooksOnly in enterprise environments to restrict hooks to policy-controlled sources only.