Writing hooks
A hook is a primitive plus conditions. The primitive says what happens — block, warn, rewrite, gate — and the conditions say when. Most hooks are one call: pick the primitive, scope it with only_if/skip_if, attach tests={...}, done. This page covers the deterministic primitives, the condition system, and AST style rules; new to the framework, start with the quickstart and come back for the production patterns.
Pick a primitive
Start from what you want to enforce.
- For a Bash command,
block_commanddenies it,warn_commandflags it after it ran, andrewrite_commandswaps it for a better one before it runs — all three live on Inspect and rewrite commands. - Advise the agent when conditions or accumulating signals fire, go to
nudge. - Require something done before the agent finishes, go to
gate. - Check a Python edit against a rule, go to
lint. - Flag only what an edit changed, go to
diff_lint. - Enforce a whole style guide as AST rules, go to Style rules.
- Install the binary a pack’s hooks shell out to, go to
install_binary. - Answer a permission dialog before the user sees it, go to
approve; refuse one outright, go todeny.
Each primitive accepts tests={...} mapping an Input to a Block, Warn, or Allow expectation — run them with uvx capt-hook test (Test hooks inline). The primitives reference holds every full signature. For the model-backed llm_gate, llm_nudge, and llm_approve, see LLM hooks.
A custom predicate belongs in a CustomCondition.check() — including parsed-command checks, via CustomCommandLineCondition — and fires through a declarative nudge, gate, or hook. Reach for an @on handler when the hook acts rather than matches: threaded session state, a message computed per event, or a fluent evt.command walk that rewrites the call.
nudge
Advise the agent when a condition holds or when signals accumulate. A plain nudge with only_if fires once on the next matching tool use. Add signals to fire only after the transcript crosses a weighted threshold, which catches a pattern like repeated retries.
from captain_hook import Allow, Input, Signal, Signals, T, Warn, nudge
nudge(
"You keep retrying. Narrow the failing test before running it again.",
signals=Signals(
patterns=[
Signal(pattern=r"let me try again", weight=2),
Signal(pattern=r"retry", weight=1),
],
threshold=3,
window=10,
scope="window",
),
tests={
Input(transcript=[
T.assistant("let me try again"),
T.assistant("retry the suite"),
]): Warn(pattern="failing test"),
Input(transcript=[T.assistant("all green")]): Allow(),
},
)scope="window" scores signals across the whole window; the default scope="text" requires one message to reach the threshold on its own. T builds transcript fixtures in the exact shape Claude Code writes — T.user/T.assistant lines carrying text, T.tool calls, T.result blocks — and raw JSONL dicts ({"type": ..., "message": {"content": [...]}}) mix in freely for shapes T doesn’t cover; Query the transcript shows how to capture a real one. A nudge with signals defaults to PostToolUse; without them it defaults to PreToolUse. See Signals for the scoring model.
gate
Require something done before the agent stops. A gate is a blocking nudge on the Stop | SubagentStop events — the same as nudge(message, block=True, ...) — so it holds the turn open until the work is complete.
from captain_hook import Allow, Block, Input, RanCommand, T, gate
gate(
"Run the tests before stopping.",
skip_if=[RanCommand("uv", "run", "mtest")],
tests={
Input(transcript=[T.assistant("edited the parser")]): Block(pattern="Run the tests"),
Input(transcript=[T.assistant(T.tool("Bash", command="uv run mtest"))]): Allow(),
},
)Stop gates are wait-aware by default. They skip while the agent waits on background work such as a running Workflow, an async sub-agent, or a ScheduleWakeup loop, then re-fire once that work resumes. A skip_if you pass composes additively with that automatic Waiting() guard instead of replacing it — don’t add Waiting() yourself.
lint
Check a Python edit against a rule. captain-hook infers the mode from the check’s first parameter. A str parameter receives the file content; an ast.AST parameter receives each walked node. The check returns violation strings spliced into {violations}.
import ast
from collections.abc import Iterator
from captain_hook import Allow, Input, Warn, lint
def bare_excepts(node: ast.AST) -> Iterator[str]:
if isinstance(node, ast.ExceptHandler) and node.type is None:
yield f"line {node.lineno}: bare except"
lint(
bare_excepts,
message="Bare except clauses silently swallow errors: {violations}",
trigger="except",
tests={
Input(tool="Edit", file="app.py", content="try:\n f()\nexcept:\n pass\n"): Warn(pattern="bare except"),
Input(tool="Edit", file="app.py", content="try:\n f()\nexcept OSError:\n pass\n"): Allow(),
},
)Lints fire on PostToolUse for Edit and Write of .py files, and skip test files. The trigger substring is a cheap pre-filter, so captain-hook parses only when the source contains it.
diff_lint
Flag only what an edit changed, so a rule fires on a newly added construct without nagging about code the edit left alone. The pattern form takes an ast-grep pattern and warns when the edit introduces a new structural match. Matches are compared by their text, not their line numbers, so a construct the edit merely moved never fires.
from captain_hook import Allow, Input, Warn, diff_lint
diff_lint(
pattern="print($$$)",
message="This edit added a print() call: {violations}",
tests={
Input(tool="Edit", file="app.py", old="x = 1\n", content="x = 1\nprint(x)\n"): Warn(pattern="added a print"),
Input(tool="Edit", file="app.py", old="print(1)\n", content="print(1)\nx = 2\n"): Allow(),
},
)For delta logic a pattern can’t express, pass a check function instead: it receives the pre-edit and post-edit AST and returns violations for the delta.
import ast
from captain_hook import diff_lint
def added_asserts_without_message(pre: ast.AST, post: ast.AST) -> list[str]:
def bare(tree: ast.AST) -> set[int]:
return {n.lineno for n in ast.walk(tree) if isinstance(n, ast.Assert) and n.msg is None}
return [f"line {n}" for n in sorted(bare(post) - bare(pre))]
diff_lint(added_asserts_without_message, message="New assert without a message: {violations}")diff_lint fires on Edit of .py files by default; in the pattern form, lang selects both the grammar and the file guard, so lang="ts" fires on *.ts edits. For declarative AST rules scoped to the change, see Style rules.
approve
Answer a permission dialog with allow before the user sees it. The hook defaults to PreToolUse | PermissionRequest: the PermissionRequest half answers the dialog Claude Code raises, and the PreToolUse half pre-authorizes one stage earlier — which is what covers dialogs forwarded from teammates, where the dialog stage runs no hooks at all. It carries no fire cap, so every matching dialog is answered. In the tests, Allow(explicit=True) asserts the hook actually answered, and Ask() asserts it stayed out of the way so the dialog shows.
from captain_hook import Allow, Ask, FromSubagent, Input, SkipPermissions, Tool, approve
approve(
"teammate bash under skip-permissions",
only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()],
tests={
Input(command="ls -la", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
Input(command="ls -la", skip_permissions=True): Ask(),
},
)approve() with no conditions answers every dialog, the equivalent of running with --dangerously-skip-permissions forever. Always scope it with only_if/skip_if.
deny
Answer a permission dialog with deny. The first argument is both the hook’s label and the message the user sees. Same event default and no fire cap as approve.
from captain_hook import Ask, Block, FromSubagent, Input, deny
from captain_hook.types import Command
deny(
"No force pushes from subagents",
only_if=[FromSubagent(), Command(r"push\s+--force")],
tests={
Input(command="git push --force origin main", agent_id="tm1"): Block(pattern="force"),
Input(command="git push origin main", agent_id="tm1"): Ask(),
},
)Note the import: the Command condition lives in captain_hook.types — top-level captain_hook.Command is the parsed-command dataclass, a different class that fails loudly if you pass it to only_if (details). An unconditioned deny() rejects every dialog, which bricks every prompting tool, so scope it the same way. For the LLM-judged variant that approves only what a safety rubric clears, see llm_approve.
install_binary
Provision the binary a pack’s hooks shell out to. The hook fires on SessionStart — every start, resume, clear, and compact — and runs your installer script with /bin/sh, asynchronously, so a slow download never delays the session. The script path resolves relative to the file that calls install_binary, and the script runs with its own directory as the working directory.
from captain_hook import install_binary
install_binary("../scripts/install-binary.sh")The handler always allows. A missing script, a non-zero exit, or a timeout is logged and swallowed — provisioning failure is diagnosable, never fatal. uvx capt-hook logs shows each run: INFO on a clean exit, WARNING with a stderr tail otherwise.
There is deliberately no already-installed check. The script owns idempotency: make it short-circuit when the binary is current, and a session where nothing changed costs one /bin/sh exec. A capt-hook-side presence check would pin consumers to stale binaries across plugin updates.
install_binary accepts tests={...} like any primitive, but an inline test that fires executes your real installer in every repo that runs uvx capt-hook test. Keep firing coverage in a standalone test file with a stub script.
Ship a pack in a Claude plugin shows the plugin-tier setup this primitive was built for.
Filter with conditions
Conditions decide when a hook fires. Every registration takes two lists: a hook fires when every only_if condition matches and no skip_if condition does.
from captain_hook import hook, Event, Tool, RanCommand
from captain_hook.types import Command
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("jj", "shelve")],
)For an “or” inside one condition, use pipe-alternation instead of separate entries. Tool("Edit|Write") matches either tool. Tool("Edit"), Tool("Write") requires both, which no event satisfies.
Pick a condition
| You want to match | Use |
|---|---|
| A file path | FilePath("*.py", "*.pyi") |
| A bash command | Command(r"git\s+push") — or the structural Runs |
| An edit to a source file in a language | SourceEdits(lang="ts") |
| The current tool | Tool("Bash") |
| A file edited earlier this session | TouchedFile("**/*.py") |
| A command run earlier this session | RanCommand("uv", "run", "mtest") |
| A typed tool call’s fields | CustomInputTypeCondition[ReadCall] |
| A parsed Bash command line | CustomCommandLineCondition |
The first four inspect the current event; TouchedFile and RanCommand inspect the session transcript, so they tell you whether something already happened. The last two are typed custom-condition bases, covered below. For every condition with its pattern type and defaults, see the conditions reference.
FilePath, Content, TestFile, and SourceEdits match project files only by default — pass project_only=False to target scratch files, attachments, or logs. SourceEdits skips test files unless you pass include_tests=True. ToolInput reads one top-level field of any tool’s raw input; WorkflowScript reads a Workflow script whether it arrives inline or as a script_path on disk.
Tool, Command, and Content match with regex — escape special characters and use raw strings (r"...") to avoid backslash issues. RanCommand takes argv tokens instead: matching is wrapper-transparent (sudo/env/timeout are stripped) but launcher-literal, so list RanCommand("uv", "run", "pytest") and RanCommand("pytest") as separate entries.
Write a custom condition
When no built-in fits, implement CustomCondition. It is a runtime_checkable Protocol, so any frozen dataclass with a check(self, evt) -> bool method satisfies it. Carry configuration as fields.
from dataclasses import dataclass
from captain_hook import CustomCondition, BaseHookEvent, hook, Event
@dataclass(frozen=True)
class MinEdits(CustomCondition):
threshold: int = 5
def check(self, evt: BaseHookEvent) -> bool:
return evt.ctx.t.tool_calls.named("Edit").count() >= self.threshold
hook(Event.Stop, message="Many edits, run a review", only_if=[MinEdits(threshold=10)])Use a custom condition like any built-in in either list. The predicate goes in check(); the firing goes through a primitive, never an @on body.
For a one-expression predicate, skip the class: LambdaCondition wraps a bare callable as a condition inline.
from captain_hook import LambdaCondition
hook(Event.Stop, message="Finish the task list first", block=True,
only_if=[LambdaCondition(lambda evt: not evt.tasks.all_completed)])A named class documents itself at every call site; reach for the lambda only when the predicate reads clearly on one line.
Match a typed tool call
When your predicate only makes sense for one tool, subclass CustomInputTypeCondition[T] instead of reading raw payload fields. Parameterize it with the tool-call type and implement check_input(self, evt, call). The base narrows evt.input to that type and skips every other tool, so call arrives typed.
from captain_hook import CustomInputTypeCondition, BaseHookEvent, ReadCall, nudge, Event
class BigRead(CustomInputTypeCondition[ReadCall]):
def check_input(self, evt: BaseHookEvent, call: ReadCall) -> bool:
return call.limit is not None and call.limit > 1000
nudge(
"Reading more than 1000 lines; narrow with offset/limit or grep first.",
only_if=[BigRead()],
events=Event.PreToolUse,
)ReadCall exposes .file_path, .offset, and .limit; the other *Call types carry their own typed fields. The same narrowing is available imperatively via evt.as_input(ReadCall).
Match a structured command line
Command matches the command text with a regex. When you need the parsed structure — which executable runs, whether a flag or subcommand appears — subclass CustomCommandLineCondition and implement check_command_line(self, evt, cl). The base skips every event without a Bash command, so cl arrives as a parsed CommandLine ready to query. These conditions fire on tool events, so set events= explicitly when registering through a Stop-defaulting primitive like gate:
from captain_hook import CustomCommandLineCondition, BaseHookEvent, CommandLine, Event, hook
class PushesWithoutLease(CustomCommandLineCondition):
def check_command_line(self, evt: BaseHookEvent, cl: CommandLine) -> bool:
return cl.q.runs("git", "push") and not cl.q.contains_token("--force-with-lease")
hook(
Event.PreToolUse,
message="Force-push without --force-with-lease. Use the lease form.",
block=True,
only_if=[PushesWithoutLease()],
)cl is the same object as evt.command.line, and cl.q carries the query predicates (runs, has_subcommand, contains_token, uses_redirect). For the full parsed surface — walking calls, judging blast radius, rewriting — see Inspect and rewrite commands.
Worked patterns
Require tests after editing source files, and skip the gate once they pass.
from captain_hook import hook, Event, TouchedFile, RanCommand
hook(
Event.Stop,
message="Source files changed; run tests before stopping",
block=True,
only_if=[TouchedFile("src/**/*.py")],
skip_if=[RanCommand("uv", "run", "mtest")],
)Block history-rewriting commands, except during plan mode.
from captain_hook import hook, Event, Tool, InPlanMode
from captain_hook.types import Command
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()],
)Style rules
styleguide() turns AST-based style checks into a hook. It ships no rules of its own: you author each rule as a StyleRule subclass, and the framework owns parsing, change-scoping, message formatting, and test wiring. A rule is usually just data — a docstring and a matcher.
Start with matchers
A matcher is a CSS selector for your syntax tree. Where button.primary > span picks DOM nodes by tag, class, and parent, M.call & M.child_of(M.func) picks AST nodes by kind and position — you compose, negate, and scope selectors the same way, and the framework does the walking.
from captain_hook.style import matchers as M
M.calls("zip") # a call to the bare-name function zip()
M.child_of(M.control_flow) # a node whose immediate parent is an if/for/while/with/try
M.private # a single-leading-underscore name (not dunder)Read a matcher against a line of code by asking “does this node match?”. Here M.calls("zip") & ~M.kwarg("strict") — a call to bare zip with no strict= keyword — matches the first input and rejects the rest:
| Code | M.calls("zip") & ~M.kwarg("strict") |
|---|---|
zip(a, b) |
✅ matches |
zip(a, b, strict=True) |
❌ the ~M.kwarg excludes it |
itertools.zip_longest(a, b) |
❌ not a bare-name zip call |
x = zip |
❌ a reference, not a call |
M.calls matches the bare name only
M.calls("zip") matches zip(...) but not builtins.zip(...) or an aliased from itertools import zip_longest as zip. It keys on the call’s bare name, not on what the name resolves to — captain-hook has no scope or type information. Reach for M.call.where(...) when you need to inspect an attribute-access call.
Matchers compose with boolean algebra (&, |, ~) and refine with .where(...):
# lazy import nested in control flow
M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)
# a private-named class
M.cls & M.private
# a bespoke one-off predicate
M.call.where(lambda n: len(n.args) > 5)The full catalog lives in AST Matchers.
Write your first rule
A rule is a StyleRule subclass: the message is the class docstring, match is a matcher, and you hand the class to styleguide().
from captain_hook import Allow, Input, Warn
from captain_hook.style import StyleRule, matchers as M, styleguide
class ZipStrict(StyleRule):
"""
zip() without strict=True can silently drop items:
- {violations}
Pass strict=True so length mismatches raise.
"""
tests = {
Input(file="app.py", content="zip(a, b)\n"): Warn(),
Input(file="app.py", content="zip(a, b, strict=True)\n"): Allow(),
}
match = M.calls("zip") & ~M.kwarg("strict")
label = "zip()"
styleguide(ZipStrict)The class name is the identity — ZipStrict becomes zip-strict in kebab-case. The runner substitutes {violations} with the findings joined by sep, a bulleted list by default. match selects the offending nodes; each becomes a violation rendered as label (line N), where label is a fixed string, a node -> str callable, or omitted for the default naming.
Put a newline right after the opening """ so inspect.cleandoc can normalize the indentation. A message that starts on the same line as the quotes keeps its leading whitespace and renders ragged.
Compare before and after with a diff rule
To catch a construct that this edit introduced, subclass StyleDiffRule. It flags nodes matching match in the new tree that were absent from the old tree, comparing by unparsed source:
from captain_hook.style import StyleDiffRule, matchers as M
class NoNewWildcardImport(StyleDiffRule):
"""
Wildcard import added by this edit:
- {violations}
"""
match = M.imports.where(lambda n: any(a.name == "*" for a in n.names))The pre-edit tree is reconstructed from the edit, so the rule fires only on a newly added wildcard, not one already there.
Drop to check() for logic a matcher can’t express
When a rule needs cross-node aggregation, body normalization, or anything stateful, override check() instead of setting match. It receives a Change — .tree is the post-edit ast.Module, .pre_tree the pre-edit one for a StyleDiffRule, alongside .source/.pre/.path — and yields Violations. A matcher still works as a selector via .over(...):
class NoStructuralOnlyTests(StyleRule):
"""Tests that only assert with builtins exercise nothing: {violations}"""
def check(self, change):
for fn in M.func.over(change.tree):
if fn.name.startswith("test_") and only_builtin_calls(fn):
yield Violation(fn.lineno, fn.name)Scope a hook per call
Each styleguide(...) call registers exactly one hook, scoped by that call. Every axis, including block versus warn, is per call, so split concerns into separate calls:
# warn, all *.py
styleguide(NoPrint, NoBareExcept)
# block, api only
styleguide(NoSqlInjection, block=True, only_if=[FilePath("api/**/*.py")])The built-in Tool("Edit|Write") and FilePath("*.py") guards always apply, and test files are skipped. Under block=True, the single hook returns one block listing every violation at once.
| Parameter | Description |
|---|---|
*rules |
StyleRule / StyleDiffRule subclasses to apply |
| block | Block the tool call instead of warning |
only_if / skip_if |
Extra conditions, ANDed/ORed onto the built-in guards |
events |
Override the default PostToolUse targeting |
max_shown |
Maximum violations shown per rule (default 5) |
A rule sees the whole post-edit file, so a check always parses complete source, and it reports only violations on the lines your edit changed. Attach inline tests to each rule and run them with uvx capt-hook test — each Input runs through the whole styleguide, so keep inputs minimal.
Write reports every match
Change-scoping keys off the edited lines. An Edit reports only the lines it touched, but a Write replaces the whole file, so every line counts as changed and every matching violation is reported — not just the ones you added.
Translate a whole prose guide
To turn an existing STYLEGUIDE.md — or the style sections of CONTRIBUTING, AGENTS, or CLAUDE.md — into rules, run the translating-styleguides skill instead of hand-writing every class. It atomizes the prose into candidate rules and sorts each into one of four tiers: a declarative matcher, a custom check(), an LLM hook for judgment calls, or unenforceable-with-a-reason. It writes the hook files, synthesizes inline tests from the guide’s own examples, verifies them with uvx capt-hook test, and reports every statement including the rows it cannot enforce. See Agent Skills.
Inspect and rewrite commands owns the command primitives and the fluent walk. LLM hooks covers the model-backed primitives, Workflows multi-step enforcement, and the reference every full signature.