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) -> None

A 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=...) -> None

lint 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 | None

llm_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) -> None

workflow 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) -> None

approve 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.

WarningScope these with conditions

An unconditioned approve() answers every dialog, the equivalent of a permanent --dangerously-skip-permissions. An unconditioned deny() rejects every dialog, bricking every prompting tool. Always narrow with only_if/skip_if.

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) -> None

install_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 module

from_template and load raise KeyError on a missing placeholder; load raises FileNotFoundError when the .md file isn’t found.