Conditions

Tool

Condition matching the current event’s tool name against one or more names.

Usage

Source

Tool(*names)

Matching is exact membership, honoring cross-editor aliases (Bash/Execute, Write/Create, …) and MCP suffixes (mcp__github__Grep matches Grep) — NOT a regex, so Tool("Edit.*") never matches. Pass names variadically, or as a single |-joined string for back-compat (Tool("Bash|Execute") is Tool("Bash", "Execute")). Tool.EditTools is the prebuilt full edit-shaped set — Tool("Edit", "MultiEdit", "NotebookEdit", "Write").

Parameter Attributes

names: str = ()

Example

>>> hook(Event.PreToolUse, only_if=[Tool("Bash", "Execute")], message="...", block=True)
>>> hook(Event.PreToolUse, only_if=[Tool.EditTools], message="...", block=True)

FilePath

Condition matching the current event’s file path against glob patterns.

Usage

Source

FilePath(*patterns, project_only=True)

Accepts one or more glob patterns as positional arguments.

Parameter Attributes

patterns: str = ()
project_only: bool = True

Example

>>> hook(Event.PostToolUse, only_if=[FilePath("*.py", "*.pyi")], message="Python file edited")

types.Command

Condition matching the current event’s bash command against a regex.

Usage

Source

types.Command(pattern)

Searched against the raw command line and each parsed command’s argv join, so a pattern spanning pipes/operators/redirects (curl ... | sh) matches. Only relevant for PreToolUse events targeting the Bash/Execute tool. The regex is compiled at construction, so a malformed pattern raises immediately rather than at dispatch.

Parameter Attributes

pattern: str

Example

>>> hook(Event.PreToolUse, only_if=[Command(r"git\s+stash")], message="blocked", block=True)

Content

Condition matching the current event’s file content against a regex.

Usage

Source

Content(pattern, project_only=True)

Applies to Edit (new content) and Write (file content) tool events.

Parameters

pattern: str

A regular expression searched against the content with re.MULTILINE.

project_only: bool = True
Only fire on files inside the repository root (default True); set False to also match scratch files, attachments, and other paths outside the repo.

Pattern

Condition matching the edit’s new content against an ast-grep structural pattern.

Usage

Source

Pattern(pattern, lang=None, project_only=True)

Where [Content][captain_hook.types.Content] matches text with a regex, Pattern matches code shape across languages — print($$$) matches a print call however its arguments are spelled, ignoring matches inside strings or comments.

Parameters

pattern: str

An ast-grep pattern string with metavariables, e.g. "os.system($CMD)" or "print($$$)".

lang: str | None = None

ast-grep language id (the short keys of [LANG_GLOBS][captain_hook.langs.LANG_GLOBS], e.g. "py", "go"); inferred from the edited file’s extension when omitted.

project_only: bool = True
Only fire on files inside the repository root (default True), like [Content][captain_hook.types.Content].

Example

>>> hook(Event.PreToolUse, only_if=[Pattern("eval($$$)")], message="no eval", block=True)

TouchedFile

Transcript-history condition: true when an Edit/Write targeted a file matching the glob.

Usage

Source

TouchedFile(*patterns, subagents=False)

Accepts one or more glob patterns as positional arguments. Defaults to the main agent’s edits only (subagent edits rarely gate the main session); pass subagents=True to include sidechains.

Parameter Attributes

patterns: str = ()
subagents: bool = False

TestFile

Condition that matches when the current event targets a test file.

Usage

Source

TestFile(project_only=True)

A test file is test_*.py, *_test.py, conftest.py, any .py under a tests/ directory, *_test.go, *.test.*, or *.spec.*.

Parameter Attributes

project_only: bool = True

ReadFile

Transcript-history condition: true when a Read tool use targeted a matching file.

Usage

Source

ReadFile(*patterns, subagents=True)

Accepts one or more fnmatch glob patterns as positional arguments, matched against each Read’s full path and its basename — so ReadFile("*.md") matches by extension and a bare ReadFile("TESTING.md") matches that file in any directory. Reads are recorded as absolute paths, so anchor a directory match with a leading **/ (ReadFile("**/docs/**")), the same idiom the test-file globs use.

Parameter Attributes

patterns: str = ()
subagents: bool = True

RanCommand

Transcript-history condition: true when a Bash tool use running argv exists.

Usage

Source

RanCommand(*argv, subagents=True)

argv is matched as a leading token prefix of a parsed command’s argv via Session.has_command — wrapper-transparent (sudo/env/timeout are stripped) and quote-exact, so RanCommand("git", "push") matches sudo git push -f but not echo "git push". Launchers are literal: RanCommand("pytest") does not match uv run pytest — list each spelling as its own entry.

Parameter Attributes

argv: str = ()
subagents: bool = True

Runs

Structural Bash condition: true when a parsed command’s argv starts with argv.

Usage

Source

Runs(*argv)

Where [Command][captain_hook.types.Command] regex-matches the command text (and can false-fire on echo git stash), Runs matches the argv prefix of any command in the line — so Runs("git", "stash") fires on git stash, git stash pop, and a && git stash but not echo git stash. Only relevant for Bash/Execute events.

Parameter Attributes

argv: str = ()

Example

>>> hook(Event.PreToolUse, only_if=[Runs("git", "stash")], message="no stashing", block=True)

UsedSkill

Transcript-history condition: true when a Skill tool use named one of names.

Usage

Source

UsedSkill(*names, subagents=True, scope="turn")

Pass names variadically; a single |-joined string still works for back-compat. A bare name also matches the plugin-qualified spelling, so UsedSkill("codex") matches a skill reported as codex:codex and UsedSkill("slop-cop-check") matches slop-cop:slop-cop-check. The search covers the current turn by default — the idiom for skipping a guard once the agent has complied; scope="session" widens it to the whole session.

Parameter Attributes

names: str = ()
subagents: bool = True
scope: Literal["session", "turn"] = "turn"

Example

>>> nudge("run codex first", skip_if=[UsedSkill("codex")])

UsedTool

Transcript-history condition: true when a tool use named one of names exists.

Usage

Source

UsedTool(*names, subagents=True, scope="turn")

Pass names variadically; a single |-joined string still works for back-compat. The search covers the current turn by default — the idiom for skipping a guard once the agent has complied; scope="session" widens it to the whole session.

Parameter Attributes

names: str = ()
subagents: bool = True
scope: Literal["session", "turn"] = "turn"

Example

>>> hook(Event.PreToolUse, skip_if=[UsedTool("EnterPlanMode")], message="...", block=True)

UserSaid

Matches when a user prompt matches one of patterns.

Usage

Source

UserSaid(*patterns, scope="turn")

A string pattern is a case-insensitive regex, so plain keywords keep their substring behavior; a ~captain_hook.Clause runs the dependency-clause scan against each prompt. The default scans only what the user said in the current turn — the idiom for reacting to a directive the user just gave; scope="session" widens the scan to every prompt in the session.

Example

>>> UserSaid(Clause(noun=Phrase("work"), verb=Phrase("stop", "halt")))
>>> UserSaid("just commit", Clause(noun=Phrase("test"), verb=Phrase("skip")), scope="session")

InPlanMode

Matches when the agent is in plan mode.

Usage

Source

InPlanMode()

Reads permission_mode from the current event payload; falls back to counting EnterPlanMode vs ExitPlanMode tool uses in the transcript when the payload omits the field.

Waiting

Condition matching while the session is parked on out-of-band work.

Usage

Source

Waiting()

Any of three signals counts as waiting. The Stop payload’s background_tasks and session_crons arrays (Claude Code 2.1.145+, Stop/SubagentStop only) report in-flight registry work: background shells, sub-agents, workflows, monitors, teammates, and scheduled wake-ups — so a long-lived background shell such as a dev server keeps the session waiting, by the platform’s own “this will wake the session back up” semantics. The transcript’s notification queue holds a <task-notification> that was enqueued but not yet delivered to the agent — a finished task the agent hasn’t seen. Or an async Agent/Task/Workflow launch (tracked across turns, so a launch in an earlier turn still counts) has no delivered notification, and current-turn waits such as run_in_background Bash or ScheduleWakeup/Monitor are in flight.

Blocking Stop gates built with gate/llm_gate skip on it automatically, additively with any skip_if given, so the agent isn’t nagged for pausing on work it is correctly waiting on.

Example

>>> gate("Run tests before stopping", skip_if=[Waiting()])

FromSubagent

Condition matching when the current event originates from a subagent or teammate.

Usage

Source

FromSubagent()

True when the event payload carries a non-empty agent_id, which Claude Code sends only for subagent and teammate events. Distinct from Agent, which matches the subagent type: this matches the event’s origin.

Example

>>> approve("teammate bash", only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()])

SkipPermissions

Condition matching when the session was launched with permission bypass available.

Usage

Source

SkipPermissions()

Walks the process tree to the nearest claude ancestor and matches when that one process was launched with --dangerously-skip-permissions or --allow-dangerously-skip-permissions — either spelling means the user made bypass available at launch, which counts as consent even while the active permission_mode is something else (e.g. plan).

Example

>>> approve("teammate bash", only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()])

FromTeammate

Matches when the current turn’s in-flight subagent spawn is a named teammate.

Usage

Source

FromTeammate()

A teammate spawn carries a name in its Agent/Task input, which cc-transcript surfaces as TaskCall.agent_name; a plain subagent leaves it unset. Scoped to the current turn so a teammate still running from an earlier turn never re-fires against a later unnamed spawn.

Example

>>> nudge("Return tight digests", only_if=[FromTeammate()], events=Event.SubagentStart)

FreshSession

Matches a SessionStart from a fresh startup or clear, not a resume or compact.

Usage

Source

FreshSession()

Example

>>> nudge("Preload your tools", only_if=[FreshSession()], events=Event.SessionStart)

Headless

Matches a headless claude -p / SDK run (CLAUDE_CODE_ENTRYPOINT in the sdk-* family).

Usage

Source

Headless()

Example

>>> llm_gate("Check docs freshness", skip_if=[Headless()], events=Event.Stop)

RewritingExistingPlan

Matches a Write to a plan file (.md under plans/ or specs/) already written this

Usage

Source

RewritingExistingPlan()

session, with no new plan cycle (EnterPlanMode) since the last Write to it.

Reads from evt.ctx.prior (the window before the current turn’s last exchange) so the pending Write being evaluated is never itself counted as the prior edit. A write to the file this session already implies it exists, so no filesystem check is needed.

Example

>>> hook(Event.PreToolUse, only_if=[Tool("Write"), RewritingExistingPlan()],
...      message="Edit the plan instead of rewriting it", block=True)

ScratchPath

Matches a file-tool target resolving into a system temp root or a scratch-named directory.

Usage

Source

ScratchPath()

Resolves a relative path against evt.cwd (a relative path with no cwd never matches), then fully resolves it — following a final-component symlink — before the scratch-path heuristic. The full resolve is a security boundary: this gates the skip-permissions auto-approver, so a symlink that sits in a scratch directory but points at a real file must not be treated as scratch.

Example

>>> approve("scratch writes", only_if=[Tool("Write"), ScratchPath(), SkipPermissions()])

EditedSource

Matches when the session edited a non-test, in-repo source file (docs and config excluded).

Usage

Source

EditedSource(*, exclude_suffixes=NON_SOURCE_SUFFIXES)

exclude_suffixes tailors which extensions count as non-source (prose/config); it defaults to NON_SOURCE_SUFFIXES.

Example

>>> llm_gate("Review the diff before stopping", only_if=[EditedSource()], events=Event.Stop)

Commits

Matches a command line whose primary command explicitly names a path ending in suffix.

Usage

Source

Commits(suffix)

Pairs with a git commit filter to gate a language’s test run only when the commit stages a file of that language — Commits(".py"), Commits(".go").

Example

>>> gate("Run pytest first", only_if=[Runs("git", "commit"), Commits(".py")])

Redirects

Matches when any command in the line carries a shell redirect (input or output).

Usage

Source

Redirects()

Detects file redirects on the parsed commands — >, >>, 2>, <, and fd duplications such as 2>&1. A bare pipe (echo x | cat) moves data between commands but is not a redirect, so it never matches.

Example

>>> hook(Event.PreToolUse, only_if=[Tool("Bash"), Redirects()], message="no redirects", block=True)

Or

Match if any of the inner conditions matches.

Usage

Source

Or(*conditions)

And

Match only if every inner condition matches (useful nested inside Or/Not).

Usage

Source

And(*conditions)

Not

Match only if the inner condition does not match.

Usage

Source

Not(condition)

Parameter Attributes

condition: TCondition

Signal

A regex-based signal pattern used in the scoring pipeline.

Usage

Source

Signal(*, pattern, weight=1, flags=0)

Signals are matched against transcript text via re.search. In a Signals bundle each matched signal contributes weight once toward the threshold; pattern weights must be positive, and false positives are suppressed with Signals.vetoes, not negative weights.

Parameter Attributes

pattern: str
weight: int = 1
flags: int = 0

Example

>>> Signal(pattern=r"retry", weight=2, flags=re.IGNORECASE)

Signals

Bundle of signal patterns with a scoring threshold.

Usage

Source

Signals(
    patterns,
    threshold,
    window=15,
    vetoes=(),
    *,
    scope="text",
    origin="assistant"
)

When a bare list[Signal] is passed to a primitive, resolve_signals wraps it with threshold=1 — meaning any single signal match triggers. Pass a higher threshold to require multiple signals to fire together.

scope selects how threshold is met across the scored candidate texts. The default "text" requires a single candidate text to meet threshold on its own; window then only bounds how far back a qualifying text may sit. "window" instead sums by presence-union across the window: each signal counts once toward threshold however many entries it matches, and the union score is the sum of the distinct matching signals’ weights. Reach for "window" only when one tell is deliberately split across turns (a checklist in one message, its sign-off in the next). Pattern weights must be positive under either scope.

origin selects which candidate texts are eligible before scoring — an upstream filter orthogonal to scope (which decides how threshold is met among the eligible texts). The default "assistant" scores only the agent’s own prose (assistant messages, thinking blocks, prose-carrying tool calls), so a stance nudge never fires on the user’s words quoted back into the transcript. Pass "any" to also score user messages — required for a hook that scores the user’s prompt or correction (a UserPromptSubmit nudge, say), where the just-submitted prompt is prepended to the scan only under "any".

vetoes are presence-only suppressors: if any veto matches any window entry — including already-consumed ones — the bundle does not fire and consumes nothing. A veto’s weight is meaningless and must be left at its default.

window bounds how much transcript each scoring pass reads: the last window events by default, or the whole current turn with the "turn" sentinel. Use "turn" when the tell can sit hundreds of events before the turn ends — a fixed window at Stop only reaches the last few tool exchanges.

Parameter Attributes

patterns: Sequence[Signal | NlpSignal]
threshold: int
window: int | Literal["turn"] = 15
vetoes: Sequence[Signal | NlpSignal] = ()
scope: Literal["text", "window"] = "text"
origin: Literal["assistant", "any"] = "assistant"

CustomCondition

Protocol for user-defined hook conditions.

Usage

Source

CustomCondition()

Implement check to create arbitrary matching logic beyond the built-in condition types.

Override valid_events to restrict to the events your condition can read.

Example

>>> class LargeFile(CustomCondition):
...     def check(self, evt: BaseHookEvent) -> bool:
...         return bool(evt.file and evt.file.path.stat().st_size > 1_000_000)
...
>>> app.hook(Event.PreToolUse, only_if=[LargeFile()], message="Large file", block=True)

CustomCommandLineCondition

CustomCondition that fires only when a parsed Bash command line is present.

Usage

Source

CustomCommandLineCondition()

Implement check_command_line to match against the structured command line; check returns False for events without one (non-Bash tools).

CustomInputTypeCondition

CustomCondition that fires only for a specific typed tool call.

Usage

Source

CustomInputTypeCondition()

Parameterize with the tool call type and implement check_input; check narrows the event’s input to that type and skips events of any other tool.

Example

>>> class BigRead(CustomInputTypeCondition[ReadCall]):
...     def check_input(self, evt: BaseHookEvent, call: ReadCall) -> bool:
...         return call.limit is not None and call.limit > 1000

workflow_script_source()

The pending Workflow call’s script source, or None.

Usage

Source

workflow_script_source(evt)

Returns the inline script when present, else reads the file at scriptPath. A non-Workflow event, a missing/unreadable path, or a file over ~1 MiB yields None; never raises.

workflow_opt_values()

Best-effort raw-source scan for values pinned to key in agent() opts. Never raises.

Usage

Source

workflow_opt_values(source, key)

Values are single-line: the whitespace around the colon is horizontal only, so a valueless key: at end of line does not swallow the next line’s token.