Primitives & verdicts
Every built-in primitive, its signature, and the verdict types the LLM primitives use. The Guide how-tos teach which one to reach for.
At a glance
| Primitive | Action | Default events | Inline tests? | Use |
|---|---|---|---|---|
| block_command | block | PreToolUse |
✓ | command |
| warn_command | warn | PostToolUse |
✓ | command |
| rewrite_command | rewrite (or block) | PreToolUse |
✓ | command |
rewrite_command_occurrences |
rewrite (or block) | PreToolUse |
✓ | command |
| gate | block | Stop \| SubagentStop |
✓ | completion |
| nudge | warn (or block) | PreToolUse/PostToolUse/Stop \| SubagentStop |
✓ | advice |
| lint | warn (or block) | PostToolUse |
✓ | edit (string/AST) |
| diff_lint | warn (or block) | PostToolUse |
✓ | edit delta |
| rewrite_code | rewrite | PreToolUse |
✓ | edit (AST) |
set_tool_input |
rewrite | PreToolUse |
✓ | tool input |
| llm_gate | block | Stop \| SubagentStop |
✓ | judgment |
| llm_nudge | warn | PostToolUse |
✓ | judgment |
| prompt_check | warn/block | called in a handler | SKIP | handler |
| workflow | block | SubagentStop |
✓ | checklist |
| approve | allow | PermissionRequest |
✓ | permission dialog |
| deny | block (deny) | PermissionRequest |
✓ | permission dialog |
| llm_approve | allow | PermissionRequest |
✓ | permission judgment |
install_binary |
none — always allows | SessionStart |
✓ | provisioning |
Deterministic primitives carry inline tests={...}. LLM-backed primitives run their inline tests too: uvx capt-hook test stubs the model with a deterministic verdict (block=True for llm_gate, fire=True for llm_nudge, safe=False for llm_approve), so a gate expects Block(), a nudge Warn(), and an approve Ask(). Override the stub per test with Input(llm={...}). nudge defaults to PreToolUse with a when= predicate, PostToolUse with signals=, and Stop | SubagentStop when block=True; skip_planning_agents defaults to not block, so a blocking gate enforces on every agent while a warning nudge skips planning and exploration subagents. Deterministic primitives and LLM Hooks show which one to reach for, with worked examples.
Commands
block_command(pattern: str | list[str], *, reason: str, hint: str | None = None,
tests: InlineTests | None = None) -> None
warn_command(pattern: str | list[str], *, message: str, tests: InlineTests | None = None,
events: Event = Event.PostToolUse) -> None
rewrite_command(pattern: str | None = None, replace: str | None = None, *,
only_if: Sequence[TCondition] = (),
to: Callable[[PreToolUseEvent], str | None] | None = None,
block: str | None = None,
note: str | Callable[[PreToolUseEvent], str | None] | None = None,
tests: InlineTests | None = None) -> None
rewrite_command_occurrences(*, to: Callable[[PreToolUseEvent, Occurrence], str | None] | None = None,
visit: Callable[[PreToolUseEvent, Occurrence, WalkContext], str | Rewritten | HookResult | None] | None = None,
only_if: Sequence[TCondition] = (), skip_if: Sequence[TCondition] = (),
block_if: Callable[[PreToolUseEvent, Occurrence], bool] | None = None,
block: str | Callable[[PreToolUseEvent, CommandLine], str] | None = None,
note: str | Callable[[PreToolUseEvent, Sequence[tuple[Occurrence, str]]], str | None] | None = None,
tests: InlineTests | None = None) -> NoneA token list (["git", "stash"]) becomes a whitespace-tolerant regex; a string is used as-is.
rewrite_command takes one of two mutually exclusive forms. Passing both, or neither, raises TypeError.
| Form | Pass | Behavior |
|---|---|---|
| Regex | pattern, replace |
Rewrites matching commands via re.sub. note must be a string. |
| Conditional | to, plus optional only_if and block |
to maps the event to its new command under the [Tool("Bash"), *only_if] gate. A None return blocks with block when set, otherwise passes the command through. note may be a callable. |
Both forms surface note as additionalContext and assert with Rewrite for a substring match or Block.
rewrite_command_occurrences is the per-occurrence sibling of the conditional form. to(evt, occ) runs once per parsed command of a ;/&&/|-joined line, and every non-None return is spliced back by byte span, so untouched segments, operators, redirects, and comments survive byte-for-byte. to is never called for a span-less occurrence (an absorbed-word redirect shape such as echo a >out b); those pass through untouched. block_if — which requires block — checks every occurrence, span-less ones included, before any rewrite, and one trip blocks the whole line: blocking is line-grained, so an unrewritable segment never runs because a sibling was rewritable. With no rewrites the line blocks when block is set, and passes otherwise. block may itself be a callable (evt, cl) -> str over the full CommandLine, resolved only at the moment the line actually blocks — never at registration, never on a successful rewrite — so the message can quote the live command. A callable note receives every (Occurrence, replacement) pair and composes the one note for the line.
rewrite_command_occurrences takes to or visit, never both. The visit form is a stateful walk: visit(evt, occ, ctx) runs once per occurrence in order — span-less ones included — with a WalkContext carrying the effective cwd (threaded through resolvable cds), whether the words are quote-free (plain_words), and whether a rewrite can splice back (spliceable). Each call returns one of four verdicts: a HookResult (typically evt.block(...)) aborts the walk and discards every pending rewrite; a str or Rewritten replaces the occurrence (Rewritten.note lines dedupe into one note); None leaves it untouched. The visit form takes no block_if/block/note — blocking and notes ride on the verdicts.
Nudges & gates
nudge(message: str, *, when=None, signals=None, only_if=(), skip_if=(), block=False,
events=None, max_fires=None, tests=None, async_=False) -> None
gate(message: str, **kwargs) -> None # == nudge(message, block=True, ...)Linting
lint(check: Callable[[str], list[str]] | Callable[[ast.AST], Iterator[str]], *, message: str,
trigger=None, sep=", ", block=False, events=None, tests=None, max_shown=5) -> None
diff_lint(check: Callable[[ast.AST, ast.AST], list[str]], *, message: str, sep=", ",
block=False, events=None, tests=None, max_shown=5, only_if=..., skip_if=...) -> Nonelint infers string- vs AST-mode from the check’s first parameter type. {violations} in the message is replaced with the joined results. lint fires on .py edits and writes (Edit|Write); diff_lint fires on .py edits only (Edit). Both skip test files.
LLM primitives
llm_gate(prompt: str | Prompt, *, message: str | Callable[[GateVerdict], str], verdict=lambda r: r.block,
response_model=GateVerdict, label=None, signals=None, when=None, contexts=(), only_if=(), skip_if=(),
events=None, max_fires=None, tests=None, max_context=2000, specialty="review",
model="small", agent=True, transcript=True, diff=False) -> None
llm_nudge(prompt: str | Prompt, *, message: str | Callable[[NudgeVerdict], str], verdict=lambda r: r.fire,
response_model=NudgeVerdict, label=None, signals=None, when=None, contexts=(), only_if=(), skip_if=(),
events=None, max_fires=None, tests=None, async_=False, max_context=2000, specialty="review",
model="small", agent=True, transcript=True, diff=False) -> None
prompt_check(evt, template: str | Prompt, fmt=None, *, prefix: str, suffix="", timeout=45,
include_reasoning=True, diff=False, response_model=PromptCheckVerdict) -> HookResult | Nonellm_gate defaults to max_fires=1, llm_nudge to max_fires=3; budgets count per agent context, so the main agent and each subagent get the full allowance. prompt_check is called from a handler and returns a HookResult (or None). max_fires, a narrow only_if, and model="small" bound the cost.
The str form of message is a {field} template over the verdict’s fields (e.g. {reasoning}), following Prompt.from_template’s placeholder rules — every other brace stays literal. label gives a gate a stable identity so its review verdicts and fire state survive prompt edits; otherwise the hook name derives from the prompt hash. contexts attaches declarative evidence blocks — BeforeEdit/AfterEdit/Introduced and other PromptContexts — each rendered as an XML block, and a required context with no content skips the call. diff=True (or diff="staged"/diff="HEAD~1") attaches a working-tree diff as a <diff> block; an empty diff skips the call without spending a fire.
Workflows
workflow(*, label: str, marker: str, steps: list[Step], artifacts: list[Artifact] | None = None,
post_complete=None, on_start=None, only_if=(), skip_if=(), tests=None) -> Noneworkflow registers a SubagentStop guard (max_fires=1); with on_start it also registers a SubagentStart hook. Step and Artifact are keyword-only dataclasses; text_matches(pattern) builds a transcript check for Step.check.
Permissions
approve(label: str, *, events: Event = Event.PreToolUse | Event.PermissionRequest,
only_if: Sequence[TCondition] = (), skip_if: Sequence[TCondition] = (),
tests: InlineTests | None = None) -> None
deny(reason: str, *, events: Event = Event.PreToolUse | Event.PermissionRequest,
only_if: Sequence[TCondition] = (), skip_if: Sequence[TCondition] = (),
tests: InlineTests | None = None) -> None
llm_approve(label: str, *, rubric: str | None = None,
events: Event = Event.PermissionRequest, only_if: Sequence[TCondition] = (),
skip_if: Sequence[TCondition] = (), model: TModel = "small",
tests: InlineTests | None = None) -> Noneapprove and deny answer on PreToolUse | PermissionRequest by default, llm_approve on PermissionRequest; none carries a fire cap, so every matching event is answered. Answering on PreToolUse matters — a deny there blocks a call that would otherwise run without ever prompting, not just one that reached a dialog, and events= narrows the scope. approve answers with allow, so the tool runs and the user never sees the dialog. deny answers with deny, surfacing reason as the message. llm_approve consults an LLM safety judge whose rubric is seeded from claude auto-mode defaults plus your rubric text; the seeded rules are cached globally, keyed by claude --version, and a static built-in rubric stands in when the binary or the verb is unavailable. A safe verdict allows, and an unsafe verdict or any LLM failure returns None so the real dialog shows; it never auto-denies.
Among PermissionRequest hooks, the first decisive result in registration order wins: local hooks load alphabetically before packs, so your own deny beats a pack’s approve, but within one hooks directory a_allow.py shadows z_guards.py. skip_if on the approving hook is the supported way to carve out exceptions.
Provisioning
install_binary(script: str | Path, *, label: str | None = None, timeout: float = 600,
only_if: Sequence[TCondition] = (), skip_if: Sequence[TCondition] = (),
tests: InlineTests | None = None) -> Noneinstall_binary registers an async SessionStart hook — no fire cap, so it runs on every start, resume, clear, and compact — that executes script with /bin/sh, cwd set to the script’s directory, output captured and logged: INFO on exit 0, WARNING with a stderr tail on anything else, a missing script or a timeout overrun included. The handler always returns None; provisioning never blocks and never raises. script resolves relative to the calling file, and label (default: the script’s file name) names the hook in logs. There is no presence check — the script owns idempotency. An inline test that fires runs the real installer in every consumer repo; keep firing coverage in standalone tests.
Inline test inputs
Each deterministic primitive accepts tests={Input(...): expectation}. Input models one event payload; set the fields the target event reads. All fields default to None.
| Field | Type | Feeds |
|---|---|---|
| command | str |
Bash command for PreToolUse and PostToolUse |
file |
str \| FileFixture |
The tool’s file path; FileFixture materializes a real temp file |
content |
str |
New file content for Edit/Write |
old |
str |
Pre-edit content for Edit/diff_lint |
| tool | str |
Tool name when no condition pins it |
tool_input |
dict |
Verbatim raw tool input; an escape hatch that wins over the input synthesized from the other fields |
prompt |
str |
An Agent/Task call’s prompt, or UserPromptSubmit text |
script |
str |
A Workflow tool’s script source; synthesizes a Workflow call |
agent_type |
str |
Subagent type, an Agent/Task call’s subagent_type |
| agent_id | str |
Subagent/teammate id; presence makes evt.is_subagent and FromSubagent() true |
model |
str |
An Agent/Task call’s model input field |
output |
str |
The tool result surfaced to PostToolUse as evt.tool_response |
| error | str |
The failure text surfaced to PostToolUseFailure as evt.error |
reason |
str |
SessionEnd reason |
| source | str |
SessionStart source, one of startup, resume, clear, compact |
permission_mode |
str |
Plan-mode and permission gating |
| skip_permissions | bool |
Pre-seeds evt.skip_permissions, replacing the process-tree walk; None keeps the real walk |
offset |
int |
Read call offset |
limit |
int |
Read call limit |
transcript |
Path \| TranscriptFixture \| list[dict] |
Session history for transcript conditions; build entries with T |
| tasks | list[dict] |
The native task list read via evt.tasks |
cwd |
str |
The session working directory, surfaced as evt.cwd |
| llm | dict |
Per-test LLM verdict stub overrides, e.g. llm={"fire": False} |
FileFixture(size=N) fills a temp file with N bytes, content=... writes exact text, and name=... sets the file name an extension- or path-based condition matches. Use it when the hook stats or reads the file; a plain file="app.py" is a virtual path with no bytes behind it.
T builds transcript entries — T.user("...") and T.assistant(...) lines, T.tool(name, **input) call blocks, T.result(...) results, T.thinking(text), and T.tool_turn(name, result=..., **input) for a paired call-and-result — in the exact JSONL shape a live session carries. Raw dicts are accepted unchanged and mix with T lines in one list.
| Expectation | Asserts | Optional pattern |
|---|---|---|
| Allow() | The hook returned None or action allow |
none |
Allow(explicit=True) |
The hook returned an actual allow result; None fails, so a PermissionRequest hook must have answered the dialog itself |
none |
Block(pattern=...) |
The hook blocked | Regex against the block message |
Warn(pattern=...) |
The hook warned | Regex against the warning message |
Rewrite(pattern=...) |
The hook rewrote the command | Substring of updated_input["command"] |
| Ask() | The hook returned no result (None); for PermissionRequest, the dialog shows, and a warn fails |
none |
Verdict types
| Type | Fields | Returned by |
|---|---|---|
| GateVerdict | block: bool, reasoning: str |
llm_gate |
| NudgeVerdict | fire: bool, reasoning: str |
llm_nudge |
| PromptCheckVerdict | action: "ok" \| "warning" \| "block", reason: str |
prompt_check |
| SafetyVerdict | safe: bool, reasoning: str |
llm_approve |
Prompt builder
Prompt assembles a system prompt, XML-tagged context blocks, and an ask, then renders via str(prompt).
Prompt().system("...").context("diff", diff_text).ask("Is this a real bug?")
Prompt.from_template("Review {file} for {issue}", file="x.py", issue="leaks")
Prompt.load("review/bug", base=None, **vars) # loads prompts/review/bug.md beside the calling modulefrom_template and load raise KeyError on a missing placeholder; load raises FileNotFoundError when the .md file isn’t found.