# Primitives & verdicts

Every built-in primitive, its signature, and the verdict types the LLM primitives use. The [Guide](../../docs/guide/how-it-works.md) how-tos teach which one to reach for.


# At a glance

| Primitive | Action | Default events | Inline tests? | Use |
|----|----|----|:--:|----|
| [block_command](../../reference/primitives.md#captain_hook.block_command) | block | `PreToolUse` | ✓ | command |
| [warn_command](../../reference/primitives.md#captain_hook.warn_command) | warn | `PostToolUse` | ✓ | command |
| [rewrite_command](../../reference/primitives.md#captain_hook.rewrite_command) | rewrite (or block) | `PreToolUse` | ✓ | command |
| `rewrite_command_occurrences` | rewrite (or block) | `PreToolUse` | ✓ | command |
| [gate](../../reference/primitives.md#captain_hook.gate) | block | `Stop \| SubagentStop` | ✓ | completion |
| [nudge](../../reference/primitives.md#captain_hook.nudge) | warn (or block) | `PreToolUse`/`PostToolUse`/`Stop \| SubagentStop` | ✓ | advice |
| [lint](../../reference/primitives.md#captain_hook.lint) | warn (or block) | `PostToolUse` | ✓ | edit (string/AST) |
| [diff_lint](../../reference/primitives.md#captain_hook.diff_lint) | warn (or block) | `PostToolUse` | ✓ | edit delta |
| [rewrite_code](../../reference/primitives.md#captain_hook.rewrite_code) | rewrite | `PreToolUse` | ✓ | edit (AST) |
| `set_tool_input` | rewrite | `PreToolUse` | ✓ | tool input |
| [llm_gate](../../reference/primitives.md#captain_hook.llm_gate) | block | `Stop \| SubagentStop` | ✓ | judgment |
| [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) | warn | `PostToolUse` | ✓ | judgment |
| [prompt_check](../../reference/primitives.md#captain_hook.prompt_check) | warn/block | called in a handler | SKIP | handler |
| [workflow](../../reference/state-sessions.md#captain_hook.workflow) | block | `SubagentStop` | ✓ | checklist |
| [approve](../../reference/primitives.md#captain_hook.approve) | allow | `PermissionRequest` | ✓ | permission dialog |
| [deny](../../reference/primitives.md#captain_hook.deny) | block (deny) | `PermissionRequest` | ✓ | permission dialog |
| [llm_approve](../../reference/primitives.md#captain_hook.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](../../reference/primitives.md#captain_hook.llm_gate), `fire=True` for [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge), `safe=False` for [llm_approve](../../reference/primitives.md#captain_hook.llm_approve)), so a gate expects [Block()](../../reference/testing.md#captain_hook.Block), a nudge [Warn()](../../reference/testing.md#captain_hook.Warn), and an approve [Ask()](../../reference/testing.md#captain_hook.Ask). Override the stub per test with `Input(llm={...})`. [nudge](../../reference/primitives.md#captain_hook.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](../../docs/guide/primitives.md) and [LLM Hooks](../../docs/guide/llm-hooks.md) show which one to reach for, with worked examples.


# Commands

``` python
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](../../reference/primitives.md#captain_hook.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](../../reference/prompt-contexts.md#captain_hook.Excerpts.block) | `to` maps the event to its new command under the `[Tool("Bash"), *only_if]` gate. A `None` return blocks with [block](../../reference/prompt-contexts.md#captain_hook.Excerpts.block) when set, otherwise passes the command through. `note` may be a callable. |

Both forms surface `note` as `additionalContext` and assert with [Rewrite](../../reference/testing.md#captain_hook.Rewrite) for a substring match or [Block](../../reference/testing.md#captain_hook.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](../../reference/prompt-contexts.md#captain_hook.Excerpts.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](../../reference/prompt-contexts.md#captain_hook.Excerpts.block) is set, and passes otherwise. [block](../../reference/prompt-contexts.md#captain_hook.Excerpts.block) may itself be a callable `(evt, cl) -> str` over the full [CommandLine](../../reference/files-commands.md#captain_hook.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 `cd`s), whether the words are quote-free (`plain_words`), and whether a rewrite can splice back ([spliceable](../../reference/files-commands.md#captain_hook.Call.spliceable)). Each call returns one of four verdicts: a [HookResult](../../reference/events-results.md#captain_hook.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](../../reference/prompt-contexts.md#captain_hook.Excerpts.block)/`note` -- blocking and notes ride on the verdicts.


# Nudges & gates

``` python
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

``` python
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](../../reference/primitives.md#captain_hook.lint) infers string- vs AST-mode from the check's first parameter type. `{violations}` in the message is replaced with the joined results. [lint](../../reference/primitives.md#captain_hook.lint) fires on `.py` edits and writes (`Edit|Write`); [diff_lint](../../reference/primitives.md#captain_hook.diff_lint) fires on `.py` edits only ([Edit](../../reference/prompt-contexts.md#captain_hook.Edit)). Both skip test files.


# LLM primitives

``` python
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](../../reference/primitives.md#captain_hook.llm_gate) defaults to `max_fires=1`, [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) to `max_fires=3`; budgets count per agent context, so the main agent and each subagent get the full allowance. [prompt_check](../../reference/primitives.md#captain_hook.prompt_check) is called from a handler and returns a [HookResult](../../reference/events-results.md#captain_hook.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](../../reference/configuration-prompts.md#captain_hook.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](../../reference/prompt-contexts.md#captain_hook.BeforeEdit)/[AfterEdit](../../reference/prompt-contexts.md#captain_hook.AfterEdit)/[Introduced](../../reference/prompt-contexts.md#captain_hook.Introduced) and other [PromptContext](../../reference/prompt-contexts.md#captain_hook.PromptContext)s -- 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

``` python
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](../../reference/state-sessions.md#captain_hook.workflow) registers a `SubagentStop` guard (`max_fires=1`); with `on_start` it also registers a `SubagentStart` hook. [Step](../../reference/state-sessions.md#captain_hook.Step) and [Artifact](../../reference/state-sessions.md#captain_hook.Artifact) are keyword-only dataclasses; `text_matches(pattern)` builds a transcript check for `Step.check`.


# Permissions

``` python
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](../../reference/primitives.md#captain_hook.approve) and [deny](../../reference/primitives.md#captain_hook.deny) answer on `PreToolUse | PermissionRequest` by default, [llm_approve](../../reference/primitives.md#captain_hook.llm_approve) on `PermissionRequest`; none carries a fire cap, so every matching event is answered. Answering on `PreToolUse` matters -- a [deny](../../reference/primitives.md#captain_hook.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](../../reference/primitives.md#captain_hook.approve) answers with *allow*, so the tool runs and the user never sees the dialog. [deny](../../reference/primitives.md#captain_hook.deny) answers with *deny*, surfacing `reason` as the message. [llm_approve](../../reference/primitives.md#captain_hook.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.

> **Warning: Scope these with conditions**
>
> An unconditioned [approve()](../../reference/primitives.md#captain_hook.approve) answers every dialog, the equivalent of a permanent `--dangerously-skip-permissions`. An unconditioned [deny()](../../reference/primitives.md#captain_hook.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](../../reference/primitives.md#captain_hook.deny) beats a pack's [approve](../../reference/primitives.md#captain_hook.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

``` python
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](../../reference/testing.md#captain_hook.Input) models one event payload; set the fields the target event reads. All fields default to `None`.

| Field | Type | Feeds |
|----|----|----|
| [command](../../reference/files-commands.md#captain_hook.Call.command) | `str` | Bash command for `PreToolUse` and `PostToolUse` |
| `file` | `str \| FileFixture` | The tool's file path; [FileFixture](../../reference/testing.md#captain_hook.FileFixture) materializes a real temp file |
| `content` | `str` | New file content for [Edit](../../reference/prompt-contexts.md#captain_hook.Edit)/`Write` |
| `old` | `str` | Pre-edit content for [Edit](../../reference/prompt-contexts.md#captain_hook.Edit)/[diff_lint](../../reference/primitives.md#captain_hook.diff_lint) |
| [tool](../../reference/testing.md#captain_hook.T.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](../../reference/state-sessions.md#captain_hook.Workflow) tool's script source; synthesizes a Workflow call |
| `agent_type` | `str` | Subagent type, an Agent/Task call's `subagent_type` |
| [agent_id](../../reference/events-results.md#captain_hook.BaseHookEvent.agent_id) | `str` | Subagent/teammate id; presence makes `evt.is_subagent` and [FromSubagent()](../../reference/conditions.md#captain_hook.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](../../reference/tool-calls.md#captain_hook.OtherCall.error) | `str` | The failure text surfaced to `PostToolUseFailure` as `evt.error` |
| `reason` | `str` | `SessionEnd` reason |
| [source](../../reference/files-commands.md#captain_hook.Call.source) | `str` | `SessionStart` source, one of `startup`, `resume`, `clear`, `compact` |
| `permission_mode` | `str` | Plan-mode and permission gating |
| [skip_permissions](../../reference/events-results.md#captain_hook.BaseHookEvent.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](../../reference/testing.md#captain_hook.T) |
| [tasks](../../reference/events-results.md#captain_hook.BaseHookEvent.tasks) | `list[dict]` | The native task list read via `evt.tasks` |
| `cwd` | `str` | The session working directory, surfaced as `evt.cwd` |
| [llm](../../reference/events-results.md#captain_hook.BaseHookEvent.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](../../reference/testing.md#captain_hook.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](../../reference/testing.md#captain_hook.T) lines in one list.

| Expectation | Asserts | Optional `pattern` |
|----|----|----|
| [Allow()](../../reference/testing.md#captain_hook.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()](../../reference/testing.md#captain_hook.Ask) | The hook returned no result (`None`); for `PermissionRequest`, the dialog shows, and a warn fails | none |


# Verdict types

| Type | Fields | Returned by |
|----|----|----|
| [GateVerdict](../../reference/primitives.md#captain_hook.GateVerdict) | `block: bool`, `reasoning: str` | [llm_gate](../../reference/primitives.md#captain_hook.llm_gate) |
| [NudgeVerdict](../../reference/primitives.md#captain_hook.NudgeVerdict) | `fire: bool`, `reasoning: str` | [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) |
| [PromptCheckVerdict](../../reference/primitives.md#captain_hook.PromptCheckVerdict) | `action: "ok" \| "warning" \| "block"`, `reason: str` | [prompt_check](../../reference/primitives.md#captain_hook.prompt_check) |
| [SafetyVerdict](../../reference/primitives.md#captain_hook.SafetyVerdict) | `safe: bool`, `reasoning: str` | [llm_approve](../../reference/primitives.md#captain_hook.llm_approve) |


# Prompt builder

[Prompt](../../reference/configuration-prompts.md#captain_hook.Prompt) assembles a system prompt, XML-tagged context blocks, and an ask, then renders via `str(prompt)`.

``` python
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](../../reference/configuration-prompts.md#captain_hook.Prompt.from_template) and [load](../../reference/configuration-prompts.md#captain_hook.Prompt.load) raise `KeyError` on a missing placeholder; [load](../../reference/configuration-prompts.md#captain_hook.Prompt.load) raises `FileNotFoundError` when the `.md` file isn't found.
