Skip to content

Conditions

Conditions filter when a hook fires. Every hook registration accepts two condition lists -- only_if and skip_if -- that narrow the hook's scope beyond just the event type.

from captain_hook import hook, Event, Tool, Command, RanCommand

hook(
    Event.PreToolUse,
    message="git stash is not allowed; use jj",
    block=True,
    only_if=[Tool("Bash"), Command(r"git\s+stash")],
    skip_if=[RanCommand(r"jj\s+shelve")],
)

Two categories

Conditions fall into two groups based on what they inspect:

Category What it checks Conditions
Current Event Properties of the event being processed right now Tool, FilePath, Command, Content, Agent, TestFile
Transcript History What has happened earlier in the session ReadFile, TouchedFile, RanCommand, UsedSkill, InPlanMode

Rule of thumb

Use current-event conditions to match what is happening. Use transcript-history conditions to match what has already happened.

Which condition do I need?

Need Use
Filter by tool name Tool("Bash") or Tool("Edit|Write")
Filter by file path FilePath("*.py", "*.pyi")
Filter by bash command text Command(r"git\s+push")
Filter by file content being written Content(r"print\(")
Filter by subagent type Agent("cleanup")
Match only test files TestFile()
Match only if a file was previously read ReadFile("TESTING.md")
Match only if a file was previously edited TouchedFile("**/*.py")
Match only if a command was previously run RanCommand(r"uv\s+run\s+mtest")
Match only if a skill was invoked UsedSkill("codex")
Match only during plan mode InPlanMode()
Custom logic implement CustomCondition

only_if vs skip_if

The two lists use different logical operators. only_if is AND -- every condition in the list must match for the hook to fire. skip_if is OR -- if any condition in the list matches, the hook is skipped.

skip_if is evaluated first. If any skip condition matches, the hook is skipped regardless of only_if.

from captain_hook import hook, Event, Tool, Command, RanCommand

# Fires only when: tool is Bash AND command matches git push
# Skipped when: the agent already ran tests
hook(
    Event.PreToolUse,
    message="Run tests before pushing",
    block=True,
    only_if=[Tool("Bash"), Command(r"git\s+push")],
    skip_if=[RanCommand(r"uv\s+run\s+mtest")],
)

Current-event conditions

Tool

Matches the current event's tool name. The pattern is matched against the tool name directly, not as a regex. Pipe-separated names match any of the listed tools, and tool aliases are expanded automatically -- Bash matches Execute, Write matches Create, and Agent matches Task.

from captain_hook import hook, Event, Tool

# only_if: fire only for Bash tool uses
hook(Event.PreToolUse, message="Be careful", only_if=[Tool("Bash")])

# skip_if: skip Read and Glob tools
hook(Event.PostToolUse, message="Review output", skip_if=[Tool("Read|Glob")])

FilePath

Matches the current event's file path against one or more glob patterns. Accepts multiple patterns as positional arguments. By default, absolute paths outside the project root do not match; pass project_only=False for hooks that intentionally inspect external scratch files, attachments, or logs.

from captain_hook import hook, Event, FilePath

# only_if: fire only for Python files
hook(Event.PostToolUse, message="Check imports", only_if=[FilePath("*.py", "*.pyi")])

# skip_if: skip config files
hook(Event.PostToolUse, message="Review edit", skip_if=[FilePath("*.toml", "*.yaml")])

Command

Matches the current event's bash command text against a regex pattern. Only meaningful for PreToolUse events targeting the Bash/Execute tool.

from captain_hook import hook, Event, Tool, Command

# only_if: fire only for git stash commands
hook(
    Event.PreToolUse,
    message="Use jj shelve instead of git stash",
    block=True,
    only_if=[Tool("Bash"), Command(r"git\s+stash")],
)

# skip_if: skip harmless read-only commands
hook(
    Event.PreToolUse,
    message="Dangerous command",
    block=True,
    only_if=[Tool("Bash")],
    skip_if=[Command(r"^(ls|cat|echo|pwd)")],
)

Content

Matches the current event's file content against a regex pattern. Applies to Edit (the new content) and Write (the file content) tool events. The regex uses re.MULTILINE mode. Like FilePath, it only matches project files by default; pass project_only=False for external files.

from captain_hook import hook, Event, Content

# only_if: fire when print() is being written
hook(Event.PostToolUse, message="Use logger instead of print()", only_if=[Content(r"print\(")])

# skip_if: skip if content contains a noqa comment
hook(Event.PostToolUse, message="Check formatting", skip_if=[Content(r"#\s*noqa")])

Agent

Matches the current event's subagent type against a pipe-separated name list. Only meaningful for SubagentStart and SubagentStop events.

from captain_hook import hook, Event, Agent

# only_if: fire only for cleanup subagents
hook(Event.SubagentStop, message="Review cleanup output", only_if=[Agent("cleanup")])

# skip_if: skip feature-implementer subagents
hook(Event.SubagentStop, message="Check work", skip_if=[Agent("feature-implementer")])

TestFile

Matches when the current event targets a test file -- a file named test_*.py or conftest.py. Takes no arguments. Like FilePath, it only matches project files by default; use TestFile(project_only=False) for external paths.

from captain_hook import hook, Event, TestFile

# only_if: fire only on test files
hook(Event.PostToolUse, message="Check test quality", only_if=[TestFile()])

# skip_if: skip test files (common for linting hooks)
hook(Event.PostToolUse, message="Check code style", skip_if=[TestFile()])

Transcript-history conditions

These conditions inspect the conversation transcript to determine whether specific actions have occurred earlier in the session.

ReadFile

True when a Read tool use targeted a file matching the given glob pattern(s). Accepts multiple patterns as positional arguments.

from captain_hook import hook, Event, ReadFile

# only_if: fire only if the agent read the testing guide
hook(Event.Stop, message="Follow the testing guide", only_if=[ReadFile("TESTING.md")])

# skip_if: skip if docs were already consulted
hook(Event.PostToolUse, message="Read the docs first", skip_if=[ReadFile("docs/*.md")])

TouchedFile

True when an Edit or Write tool use targeted a file matching the given glob pattern(s). Accepts multiple patterns as positional arguments.

from captain_hook import hook, Event, TouchedFile

# only_if: fire only if Python files were edited
hook(Event.Stop, message="Run tests before stopping", only_if=[TouchedFile("**/*.py")])

# skip_if: skip if no source files were modified
hook(Event.Stop, message="Commit your changes", skip_if=[TouchedFile("tests/**")])

RanCommand

True when a Bash tool use with a command matching the regex pattern exists in the transcript.

from captain_hook import hook, Event, RanCommand

# only_if: fire only if tests have been run
hook(Event.Stop, message="Tests passed -- safe to stop", only_if=[RanCommand(r"pytest")])

# skip_if: skip the gate if tests were already run
hook(Event.Stop, message="Run tests before stopping", block=True,
     skip_if=[RanCommand(r"uv\s+run\s+mtest")])

UsedSkill

True when a Skill tool use with a matching name exists in the transcript. Pipe-separated names match any of the listed skills.

from captain_hook import hook, Event, UsedSkill

# only_if: fire only if codex was used
hook(Event.Stop, message="Good -- codex was consulted", only_if=[UsedSkill("codex")])

# skip_if: skip if the agent already used the review skill
hook(Event.Stop, message="Run /review before stopping", skip_if=[UsedSkill("review")])

InPlanMode

True when the current event payload's permission_mode is "plan" -- the canonical signal Claude Code emits while plan/spec mode is active, regardless of whether plan mode was entered via EnterPlanMode tool use or toggled by the user with shift+tab. When the payload omits permission_mode, falls back to comparing EnterPlanMode > ExitPlanMode tool-use counts in the transcript.

from captain_hook import hook, Event, InPlanMode

# only_if: fire only during plan mode
hook(Event.PreToolUse, message="Read-only during planning", block=True,
     only_if=[InPlanMode()])

# skip_if: skip enforcement during plan mode
hook(Event.PreToolUse, message="Check before editing", skip_if=[InPlanMode()])

Custom conditions

For logic that the built-in conditions cannot express, implement the CustomCondition protocol. The protocol requires a single check method that receives the event and returns a boolean.

from captain_hook import CustomCondition, BaseHookEvent, hook, Event

class LargeFile(CustomCondition):
    def check(self, evt: BaseHookEvent) -> bool:
        return bool(evt.file and evt.file.path.stat().st_size > 1_000_000)

hook(Event.PreToolUse, message="Large file -- be careful", only_if=[LargeFile()])

Custom conditions can carry configuration as instance attributes:

from captain_hook import CustomCondition, BaseHookEvent, hook, Event

class MinEdits(CustomCondition):
    def __init__(self, threshold: int = 5):
        self.threshold = threshold

    def check(self, evt: BaseHookEvent) -> bool:
        return evt.ctx.t.tool_uses.where(name="Edit").count() >= self.threshold

hook(Event.Stop, message="Many edits -- run a review", only_if=[MinEdits(threshold=10)])

Protocol, not base class

CustomCondition is a runtime_checkable Protocol. Any object with a check(self, evt: BaseHookEvent) -> bool method satisfies it -- no inheritance required.

Combining conditions

A few patterns for effective condition composition:

Gate that requires tests after editing source files:

from captain_hook import hook, Event, TouchedFile, RanCommand

hook(
    Event.Stop,
    message="Run tests before stopping -- source files were edited",
    block=True,
    only_if=[TouchedFile("src/**/*.py")],
    skip_if=[RanCommand(r"uv\s+run\s+mtest")],
)

Warn about dangerous commands, except in plan mode:

from captain_hook import hook, Event, Tool, Command, InPlanMode

hook(
    Event.PreToolUse,
    message="This command modifies git history",
    block=True,
    only_if=[Tool("Bash"), Command(r"git\s+(rebase|reset|push\s+--force)")],
    skip_if=[InPlanMode()],
)

Lint only non-test Python files:

from captain_hook import hook, Event, FilePath, TestFile

hook(
    Event.PostToolUse,
    message="Check code style",
    only_if=[FilePath("*.py")],
    skip_if=[TestFile()],
)

Regex patterns

Tool, Command, Content, and RanCommand use regex matching. Remember to escape special characters and use raw strings (r"...") to avoid backslash issues.