Cheatsheet
Every hook you reach for, on one page. Copy a line, swap the strings, drop it in .claude/hooks/. Pick the action first, then narrow with conditions. Full signatures live in the primitives reference and the conditions reference.
Save it to .claude/hooks/x.py and run uvx capt-hook --hooks .claude/hooks test to exercise its inline tests.
π Block before it happens
block_command β stop a dangerous Bash command before it runs
block_command(["git", "stash"], reason="Use the team VCS workflow")gate β refuse to let the agent stop until a condition holds
gate("Run the tests before finishing", skip_if=[RanCommand("pytest"), RanCommand("uv", "run", "pytest")])hook(β¦, block=True) β a hand-rolled always-on guard when no primitive fits
hook(Event.PreToolUse, only_if=[Tool("Bash"), Command(r"curl.*\|.*sh")],
message="Don't pipe curl into a shell", block=True)βοΈ Rewrite in place
rewrite_command β swap a command for a safer form before it runs
rewrite_command(r"^pip install ", replace="uv pip install ")rewrite_command_occurrences β rewrite each occurrence of one program inside a compound Bash line β worked example
rewrite_code β structurally rewrite edited code before it is written, ast-grep pattern to fix template
rewrite_code("os.system($CMD)", "subprocess.run([$CMD], check=True)")set_tool_input β fill a missing tool-input field, never clobbering an explicit one
set_tool_input("model", "sonnet", tool="Agent|Task", note="defaulted the subagent model")π§ Walk the command
evt.command β the parsed, walkable Bash line: match structure, judge blast radius, rewrite calls β the how-to
for call in evt.command.calls("rm"):
return call.sub("rm", "trash", args=call.targets)π Answer permission dialogs
approve β auto-answer a permission dialog for a scoped, consented case
approve("teammate bash", only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()])π¬ Nudge in the moment
nudge β advisory message on an edit or stop, no block
nudge("Prefer the project logger over print()", only_if=[Tool("Edit")])nudge(signals=β¦) β fire only when the transcript crosses a score
nudge("You keep retrying β stop and read the error", signals=RETRIES)warn_command β flag a smell after a command has run
warn_command(r"rm -rf", message="Double-check that path next time")π Lint the code
lint β run a string or AST check on every Python edit
lint(find_bare_excepts, message="Bare except swallows errors: {violations}")diff_lint β flag only what this edit newly introduced
diff_lint(widened_to_any, message="New Any annotation: {violations}")styleguide β run composable matcher rules on the lines you changed
styleguide(NoPrint, NoBareExcept)π€ Ask a model
llm_gate β block on a modelβs judgment of intent or quality
llm_gate("Is the agent making excuses instead of finishing?",
message=lambda r: r.reasoning, max_fires=1)llm_nudge β warn on a modelβs judgment, without blocking
llm_nudge("Does this edit assert behavior, or only that code exists?",
message=lambda r: r.reasoning, only_if=[Tool("Edit")])prompt_check β call the model from inside your own handler
prompt_check(evt, Prompt.from_template("Review {code}", code=evt.content),
prefix="Test quality")evt.llm β a typed answer from the model, from inside any handler
verdict = evt.llm("Rate the risk of this action.", RiskVerdict)π― Decide when (conditions)
Stack conditions in only_if (all must match) or skip_if (any match skips).
Tool β the tool name matches
Tool("Edit|Write")FilePath β the eventβs file path matches a glob
FilePath("src/**/*.py")Command / Content β the Bash command, or the written content, matches a regex
Command(r"git\s+push\s+--force")
Content(r"\bprint\(")Runs β a parsed commandβs argv starts with these tokens
Runs("git", "stash")TestFile / SourceEdits β the edit hits a test, or a source file in a language
SourceEdits(lang="py", include_tests=False)RanCommand / ReadFile / TouchedFile β something already happened this session
RanCommand("uv", "run", "pytest")UsedSkill / UsedTool / InPlanMode / Waiting β session mode and history
only_if=[InPlanMode()]πͺ Multi-step gate
workflow β block a stop until an ordered checklist passes
workflow(label="QA-GATE", marker="QA COMPLETE", steps=[...], artifacts=[...])Step β one checkpoint: a check predicate plus stop and next messages
Step(name="run tests", check=text_matches(r"pytest.*passed"),
message="Tests have not run. Run: pytest -x")Artifact β a file that must exist and parse against a Pydantic model
Artifact(path="reports/lint.json", model=LintReport)text_matches(pattern) β a prose-regex check for Step.check (scans narration, not commands)
πΎ Session state
evt.ctx.s[Model] β a typed slot keyed by a Pydantic model
state = evt.ctx.s[EditCounter].get() or EditCounter()
evt.ctx.s[EditCounter].set(state)@workflow_state(βnameβ) β bundle one workflowβs reads and writes
@workflow_state("review")
class ReviewState(WorkflowState):
ran_tests: bool = False.load(evt) / .save(evt) / .reset(evt) β read-default, write, delete; .mutate(evt) β locked read-modify-write for racing writers @session_state β register a plain model for introspection
π Signal scoring
Signal β a regex worth weight points when it matches
Signal(pattern=r"\bretry\b", weight=2)Signals β a bundle that fires when a single message in the last window events scores threshold; scope="window" pools signals across the window, origin="any" scores user text too (the default scores only the agentβs prose), window="turn" scores the whole current turn
Signals([Signal(pattern=r"retry", weight=2), Signal(pattern=r"again")], threshold=3, window=15)π§± AST matchers
For style rules. Compose with &, |, ~, refine with .where(...), run with .over(tree) or .violations(tree). Import as from captain_hook.style import matchers as M.
Node types β M.cls / M.func / M.definition Β· M.call Β· M.imports Β· M.assignment Β· M.control_flow Β· M.type_checking Β· M.kind(*types)
Calls & references β M.calls(name) (bare-name call) Β· M.kwarg(name) Β· M.ref(name)
Names β M.named(pattern) Β· M.private Β· M.dunder Β· M.constant
Annotations β M.annotated(inner=None) Β· M.forward_ref
Position β M.under(other) (any ancestor) Β· M.child_of(other) (immediate parent) Β· M.following(boundary)
Combine & run β a & b Β· a | b Β· ~a Β· a.where(pred) Β· m.over(tree) Β· m.violations(tree) Β· m.diff(pre, post) Β· m.exists(tree)
The matchers reference lists every matcher with signatures β and the canonical worked rule (NoNestedImports).
πͺ Register by hand
hook / on β when no primitive fits, target an event directly
@on(Event.PostToolUse, only_if=[Tool("Edit")])
def my_check(evt): ...See also
- How-tos: Writing hooks Β· Inspect and rewrite commands Β· LLM hooks & signals Β· State & workflows
- Reference: Primitives & verdicts Β· Conditions Β· Events Β· Matchers