# 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](../../docs/guide/primitives.md#filter-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](../../reference/state-sessions.md#captain_hook.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()](../../reference/conditions.md#captain_hook.FromSubagent) | The event originates from a subagent or teammate, i.e. the payload carries an [agent_id](../../reference/events-results.md#captain_hook.BaseHookEvent.agent_id); matches the event's *origin*, where [Agent](../../reference/events-results.md#captain_hook.Agent) matches its *type* | -- | [FromSubagent()](../../reference/conditions.md#captain_hook.FromSubagent) |
| `TestFile(project_only=True)` | The file is a test file (`test_*.py`, `conftest.py`, or any `.py` under a `tests/` directory) | -- | [TestFile()](../../reference/conditions.md#captain_hook.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](../../reference/conditions.md#captain_hook.FilePath), [Content](../../reference/conditions.md#captain_hook.Content), [TestFile](../../reference/conditions.md#captain_hook.TestFile), and [SourceEdits](../../reference/state-sessions.md#captain_hook.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 [Clause](../../reference/signals.md#captain_hook.Clause)s | `UserSaid("just commit")` |
| [InPlanMode()](../../reference/conditions.md#captain_hook.InPlanMode) | The agent is in plan mode | -- | [InPlanMode()](../../reference/conditions.md#captain_hook.InPlanMode) |
| [Waiting()](../../reference/conditions.md#captain_hook.Waiting) | A background or async tool is still pending. Durable [Workflow](../../reference/state-sessions.md#captain_hook.Workflow) and async sub-agent launches count across turns until their completion notification lands | -- | [Waiting()](../../reference/conditions.md#captain_hook.Waiting) |
| [SkipPermissions()](../../reference/conditions.md#captain_hook.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()](../../reference/conditions.md#captain_hook.SkipPermissions) |

`subagents=True` also searches subagent transcripts; [TouchedFile](../../reference/conditions.md#captain_hook.TouchedFile) defaults to `False` (the main agent's edits only).


# Combining conditions

``` python
only_if=[A, B, C]   # A AND B AND C must all match
skip_if=[X, Y]      # X OR Y -- any match skips the hook
```

A 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](../../reference/conditions.md#captain_hook.Or)), and `Not(cond)` (negate). All three are exported from `captain_hook`.


# Custom conditions

[CustomCondition](../../reference/conditions.md#captain_hook.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](../../docs/guide/primitives.md#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](../../reference/testing.md#captain_hook.T) (e.g. [ReadCall](../../reference/tool-calls.md#captain_hook.ReadCall)) and `check_input` returns `True`; skips every other tool |
| [CustomCommandLineCondition](../../reference/conditions.md#captain_hook.CustomCommandLineCondition) | `check_command_line(self, evt, cl) -> bool` | the event has a parsed Bash [CommandLine](../../reference/files-commands.md#captain_hook.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](../../reference/files-commands.md#captain_hook.Cmd.call) arrives typed for direct field access like `call.limit` and `call.file_path`. [CustomCommandLineCondition](../../reference/conditions.md#captain_hook.CustomCommandLineCondition) receives the parsed [CommandLine](../../reference/files-commands.md#captain_hook.CommandLine); query it via `cl.q`, whose methods are `runs`, `has_subcommand`, `contains_token`, and `uses_redirect`. See [Match a typed tool call](../../docs/guide/primitives.md#match-a-typed-tool-call) and [Match a structured command line](../../docs/guide/primitives.md#match-a-structured-command-line).


# Signals (scoring)

[Signal](../../reference/conditions.md#captain_hook.Signal) and [Signals](../../reference/conditions.md#captain_hook.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](../../docs/guide/llm-hooks.md#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)` |
