Conditions
Every condition type, in one place. Use conditions in only_if (all must match) or skip_if (any match skips the hook). Filter hooks with conditions shows how to pick and combine them.
Current event
These match the event being processed right now.
| Condition | Matches when | Pattern | Example |
|---|---|---|---|
Tool(*names) |
The tool name is one of names |
Exact names (aliases + MCP suffixes auto-match); a \|-joined string still works; Tool.EditTools is the prebuilt edit-shaped set (Edit, MultiEdit, NotebookEdit, Write) |
Tool.EditTools |
FilePath(*p, project_only=True) |
The event’s file path matches any glob | Glob | FilePath("**/*.py") |
Command(p) |
The raw Bash line or any parsed command matches | Regex | Command(r"curl.*\|.*sh") |
Runs(*argv) |
Any parsed command’s argv starts with argv |
Argv prefix (structural) | Runs("git", "stash") |
Content(p, project_only=True) |
The written/edited file content matches | Regex (multiline) | Content(r"print\(") |
ToolInput(**fields) |
Every named top-level tool-input field matches (any tool; scalars coerced to text) | Regex per field (multiline) | ToolInput(model=r"(?i)\bhaiku\b") |
WorkflowScript(pattern=None, **opts) |
A Workflow tool’s inline script (or script_path file): pattern searches the raw source, each opt key’s regex matches a value pinned for that agent() opt; all AND |
Regex | WorkflowScript(model="haiku") |
Agent(*names) |
The subagent type is one of names |
Exact names; a \|-joined string still works |
Agent("cleanup", "refactor") |
| FromSubagent() | The event originates from a subagent or teammate, i.e. the payload carries an agent_id; matches the event’s origin, where Agent matches its type | — | FromSubagent() |
TestFile(project_only=True) |
The file is a test file (test_*.py, conftest.py, or any .py under a tests/ directory) |
— | TestFile() |
SourceEdits(lang="py", include_tests=False, paths=None, project_only=True) |
The event edits/writes a source file in lang (and not a test, unless include_tests) |
Language glob | SourceEdits(lang="ts", paths="src/**") |
FilePath, Content, TestFile, and SourceEdits match project files only by default. Pass project_only=False to target external scratch files, attachments, or logs.
Session state
These check what happened earlier in the session, from the transcript.
| Condition | Matches when | Pattern | Example |
|---|---|---|---|
ReadFile(*p, subagents=True) |
A file matching the glob was read | Glob | ReadFile("TESTING.md") |
TouchedFile(*p, subagents=False) |
A file matching the glob was edited or written | Glob | TouchedFile("**/*.py") |
RanCommand(*argv, subagents=True) |
A Bash command running the argv prefix ran (wrapper-transparent, launcher-literal) | Argv tokens | RanCommand("uv", "run", "pytest") |
UsedSkill(*names, subagents=True, scope="turn") |
A skill named one of names was invoked in the current turn (a bare name also matches plugin:name); scope="session" searches the whole session |
Exact names; a \|-joined string still works |
UsedSkill("codex") |
UsedTool(*names, subagents=True, scope="turn") |
A tool use named one of names exists in the current turn; scope="session" searches the whole session |
Exact names; a \|-joined string still works |
UsedTool("EnterPlanMode") |
UserSaid(*patterns, scope="turn") |
The current turn’s prompt matches a pattern; scope="session" scans every prompt in the session |
Case-insensitive regex strings and/or Clauses | UserSaid("just commit") |
| InPlanMode() | The agent is in plan mode | — | InPlanMode() |
| Waiting() | A background or async tool is still pending. Durable Workflow and async sub-agent launches count across turns until their completion notification lands | — | Waiting() |
| SkipPermissions() | The session’s claude process was launched with --dangerously-skip-permissions or --allow-dangerously-skip-permissions, found by walking the process tree to the nearest claude ancestor. Either spelling means the user made bypass available at launch, which counts as consent even while the active permission_mode is something else, such as plan |
— | SkipPermissions() |
subagents=True also searches subagent transcripts; TouchedFile defaults to False (the main agent’s edits only).
Combining conditions
only_if=[A, B, C] # A AND B AND C must all match
skip_if=[X, Y] # X OR Y — any match skips the hookA matching skip_if skips the hook even when every only_if condition matches. For OR within one condition, list the names (Tool("Bash", "Execute")). To combine across condition types, wrap them: Or(*conds) (any), And(*conds) (all — useful nested inside Or), and Not(cond) (negate). All three are exported from captain_hook.
Custom conditions
CustomCondition is a runtime_checkable protocol. Any frozen dataclass with a check(self, evt: BaseHookEvent) -> bool method satisfies it and slots into only_if/skip_if. See Write a custom condition for a worked example.
Two typed base classes narrow check to one shape of input. Subclass and implement the inner method; the base handles narrowing and skips events that don’t apply.
| Base | Implement | Fires when |
|---|---|---|
CustomInputTypeCondition[T] |
check_input(self, evt, call: T) -> bool |
evt.input is a T (e.g. ReadCall) and check_input returns True; skips every other tool |
| CustomCommandLineCondition | check_command_line(self, evt, cl) -> bool |
the event has a parsed Bash CommandLine and check_command_line returns True; returns False for non-Bash tools |
CustomInputTypeCondition[T] narrows evt.input to the parameterized *Call type, so call arrives typed for direct field access like call.limit and call.file_path. CustomCommandLineCondition receives the parsed CommandLine; query it via cl.q, whose methods are runs, has_subcommand, contains_token, and uses_redirect. See Match a typed tool call and Match a structured command line.
Signals (scoring)
Signal and Signals aren’t only_if/skip_if filters — they pass to a primitive’s signals= parameter to score patterns against recent transcript prose. By default (scope="text") a single message must meet the threshold on its own matched weights, and the window only bounds how far back it may sit. Under scope="window" the score pools distinct signals across the window instead, counting each once. origin filters candidates upstream of either scope: the default "assistant" scores only the agent’s own prose, while origin="any" also scores user messages and the just-submitted UserPromptSubmit prompt. See Score patterns with signals.
| Type | Purpose | Example |
|---|---|---|
Signal(pattern=..., weight=1, flags=0) |
A regex worth weight points when it matches (fields are keyword-only) |
Signal(pattern=r"\bretry\b", weight=2) |
Signals(patterns, threshold, window=15, scope="text", origin="assistant") |
A bundle that fires at threshold; scope="text" (default) requires a single text in the window to meet the threshold alone, scope="window" counts each matching signal once across the whole window; origin="assistant" (default) scores only the agent’s own prose, origin="any" also scores user text and the UserPromptSubmit prompt; vetoes match window-wide under either scope; window="turn" scores the whole current turn |
Signals([Signal(pattern=r"retry", weight=2), Signal(pattern=r"again")], threshold=3) |