Conditions
Tool
Condition matching the current event’s tool name against one or more names.
Usage
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
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
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
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); setFalseto 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
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
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
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
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
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
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
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 = Truescope: 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
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 = Truescope: 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
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
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
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
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
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
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
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
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
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
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
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
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
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
Or(*conditions)And
Usage
And(*conditions)Not
Match only if the inner condition does not match.
Usage
Not(condition)Parameter Attributes
condition: TCondition
Signal
A regex-based signal pattern used in the scoring pipeline.
Usage
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: strweight: int = 1flags: int = 0
Example
>>> Signal(pattern=r"retry", weight=2, flags=re.IGNORECASE)Signals
Bundle of signal patterns with a scoring threshold.
Usage
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
CustomCondition
Protocol for user-defined hook conditions.
Usage
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
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
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 > 1000workflow_script_source()
The pending Workflow call’s script source, or None.
Usage
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
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.