flowchart TD
A[User submits prompt] -->|UserPromptSubmit| B[Agent processes prompt]
B --> C{Needs a tool?}
C -->|Yes| D[PreToolUse]
D --> E{Hook blocks?}
E -->|Yes| F[Tool skipped]
E -->|No| G[Tool executes]
G --> H{Tool succeeded?}
H -->|Yes| I[PostToolUse]
H -->|No| J[PostToolUseFailure]
I --> C
J --> C
F --> C
C -->|No| K{Agent stopping?}
K -->|Yes| L[Stop]
L --> M{Hook blocks stop?}
M -->|Yes| B
M -->|No| N[Done]
B -->|Launches subagent| O[SubagentStart]
O --> P[Subagent runs]
P --> Q[SubagentStop]
Q --> R{Hook blocks stop?}
R -->|Yes| P
R -->|No| C
B -->|Context too large| S[PreCompact]
S --> T[Context compacted]
T --> B
B -.->|System notification| U[Notification]
style D fill:#f9f,stroke:#333
style I fill:#9f9,stroke:#333
style J fill:#f99,stroke:#333
style L fill:#99f,stroke:#333
style Q fill:#99f,stroke:#333
style A fill:#ff9,stroke:#333
style S fill:#9ff,stroke:#333
style U fill:#ddd,stroke:#333
How It Works
This page explains the full lifecycle of a captain-hook hook — from the moment Claude Code fires an event to the JSON response that controls its behavior.
The Hook Lifecycle
Claude Code processes user input in a loop: parse the prompt, call tools, read results, decide whether to continue or stop. Hooks intercept this loop at specific points.
Key takeaway: PreToolUse is the only event where a hook can prevent an action. Stop and SubagentStop can force the agent to continue. All other events are advisory — they inject context but cannot block.
How a Hook Resolves
Let’s trace a concrete example end-to-end: blocking git stash.
Step 1: Define the hook
from captain_hook import block_command
block_command(["git", "stash"], reason="git stash is not allowed", hint="Use jj shelve")Under the hood, block_command calls hook() with these parameters:
hook(
Event.PreToolUse,
only_if=[Tool("Bash"), Command(r"git\s+stash")],
message="BLOCKED: git stash is not allowed. Use jj shelve.",
block=True,
)Step 2: Claude Code fires the event
When Claude tries to run git stash, Claude Code sends a JSON payload to captain-hook’s stdin:
{
"tool_name": "Bash",
"tool_input": {
"command": "git stash pop"
},
"transcript_path": "/tmp/claude/session/transcript.jsonl"
}Step 3: Parse into a typed event
The CLI entry point reads the JSON, resolves the event type, and constructs a typed event object:
event = Event.PreToolUse
evt = PreToolUseEvent(_raw=raw, ctx=ctx)
evt.tool_name # "Bash"
evt.command # "git stash pop"
evt.command_line # CommandLine(primary=Command("git"), args=["stash", "pop"])Step 4: Check conditions
The dispatch pipeline calls get_matching_hooks(evt), which evaluates each registered hook’s conditions:
- Event match — Event.PreToolUse is in the hook’s event set. Pass.
only_ifconditions —Tool("Bash")checksevt.tool_name == "Bash". Pass.Command(r"git\s+stash")checksre.search(r"git\s+stash", "git stash pop"). Pass.skip_ifconditions — none registered. Pass.- Gitignore check — no file path involved. Pass.
All conditions pass, so this hook matches.
Step 5: Execute the hook
Since this is a declarative hook (no handler function), dispatch calls run_declarative:
HookResult(action=Action.block, message="BLOCKED: git stash is not allowed. Use jj shelve.")Step 6: Format the output
The result is formatted as JSON that Claude Code understands:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: git stash is not allowed. Use jj shelve."
}
}Step 7: Claude Code reads the result
Claude Code reads the JSON from stdout. The permissionDecision: "deny" tells it to skip the tool call. Claude sees the reason message and adjusts its plan accordingly.
If block=False, the action would be Action.warn instead. The output would use additionalContext instead of permissionDecision: "deny", and Claude would see the message as advice rather than a hard stop.
Event Quick Reference
| Event | When it fires | Event class | Key properties | Common use |
|---|---|---|---|---|
| PreToolUse | Before a tool executes | PreToolUseEvent | tool_name, command, file, content | Block dangerous commands, enforce permissions |
| PostToolUse | After a tool succeeds | PostToolUseEvent | tool_name, command, file, tool_response |
Lint edits, warn on patterns, log activity |
| PostToolUseFailure | After a tool fails | PostToolUseFailureEvent | tool_name, error, is_interrupt |
Detect repeated failures, suggest fixes |
| UserPromptSubmit | User submits a prompt | UserPromptSubmitEvent | user_prompt | Validate prompts, inject context |
| Stop | Agent is about to stop | StopEvent | stop_hook_active | Enforce completion gates, require tests |
| SubagentStop | Subagent finishes | SubagentStopEvent | agent_type | Validate subagent output, enforce workflows |
| SubagentStart | Subagent launches | SubagentStartEvent | agent_type | Inject instructions, restrict agent types |
| PreCompact | Before context compaction | PreCompactEvent | trigger, custom_instructions |
Add compaction instructions |
| Notification | System notification | NotificationEvent | message, title, notification_type |
Custom notification handling |
Events are flags — combine them with | to register one hook for multiple events:
hook(Event.Stop | Event.SubagentStop, message="Review before finishing", block=True)Three Registration Patterns
Declarative: hook()
The most common pattern. Specify the event, conditions, message, and whether to block:
from captain_hook import hook, Event, Tool
hook(Event.PreToolUse, only_if=[Tool("Grep")], message="Use Glob instead", block=True)Best for: simple allow/block/warn decisions with static messages.
Primitives: block_command(), nudge(), lint()
Pre-built patterns that handle common use cases with minimal boilerplate:
from captain_hook import block_command, nudge, TouchedFile
block_command(["git", "push", "--force"], reason="Force push is banned")
nudge("Run tests before committing", only_if=[TouchedFile("**/*.py")])Best for: command blocking, advisory nudges, code linting — the patterns that cover 80% of hooks.
Handler: @on()
Full control via a Python function. The handler receives the typed event and returns a HookResult:
from captain_hook import on, Event, Tool
@on(Event.PreToolUse, only_if=[Tool("Bash")])
def check_sudo(evt):
if evt.command and "sudo" in evt.command:
return evt.block("sudo is not allowed in this project")Best for: dynamic logic, conditional messages, state inspection, or anything the declarative API cannot express.
If hook() or a primitive can express your intent, prefer it over @on(). Declarative hooks are easier to test, easier to read, and less likely to break.