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.
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
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.PreToolUseis 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:
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.
Block vs. Warn
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 |
Combining events
Events are flags --- combine them with | to register one hook for multiple events:
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.
Choose the simplest pattern
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.