# LLM hooks, signals & the transcript

An LLM hook sends transcript context to a large language model and acts on its verdict. Reach for one when the judgment needs to read intent, tone, or reasoning quality, and a regex cannot express it. This page covers the four LLM primitives, the signal scoring that keeps them cheap, and the transcript query API both build on.


# Decide: LLM or static

Default to a static hook. It is free, instant, and deterministic, so a [block_command](../../reference/primitives.md#captain_hook.block_command), [nudge](../../reference/primitives.md#captain_hook.nudge), or [lint](../../reference/primitives.md#captain_hook.lint) covers most guards. Move to an LLM hook only when the condition turns on meaning that a literal string or regex cannot capture.

| Reach for a static hook when | Reach for an LLM hook when |
|----|----|
| The trigger is a literal string or regex | The trigger needs semantic understanding |
| You need instant, deterministic evaluation | You can tolerate a few seconds of latency |
| The condition is structural, like a tool name or file path | The condition is about meaning, like excuses or speculation |
| Cost matters more than subtlety | Accuracy on subtle behavior earns the cost |

The static primitives live in [Writing hooks](../../docs/guide/primitives.md) and [the primitives reference](../../docs/reference/primitives.md). The events you can target live in [the events reference](../../docs/reference/events.md), and the conditions you filter on live in [the conditions reference](../../docs/reference/conditions.md).


# Bound the cost first

An LLM hook calls a model on every qualifying event, so set its cost ceiling before you tune the prompt. Four levers do the work:

1.  Set `max_fires` to cap calls per session. [llm_gate](../../reference/primitives.md#captain_hook.llm_gate) defaults to `1`; [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) defaults to `3`.
2.  Add a narrow `only_if` so the hook reaches the model only on events that could matter. A `Tool("Edit")` condition skips every Bash and read event for free.
3.  Keep `model="small"` (the default). Move up to `"medium"` or `"large"` only when the small model misjudges.
4.  Short-circuit with `signals`. Cheap regex and natural-language-processing (NLP) scoring runs first, and the model is invoked only when the score meets the threshold.

``` python
from captain_hook import Signal, Signals, llm_gate

llm_gate(
    "Is the agent making excuses for not finishing the task? "
    "Look for blame-shifting to external services or claims that something "
    "is impossible without evidence.",
    message=lambda r: f"Excuse detected: {r.reasoning}",
    signals=Signals(
        patterns=[
            Signal(pattern=r"external.*service", weight=2),
            Signal(pattern=r"impossible|cannot be done", weight=2),
        ],
        threshold=2,
        window=15,
        vetoes=[Signal(pattern=r"investigating")],
    ),
    max_fires=1,
)
```

The signal patterns score the recent transcript. The prompt reaches the model only when that score clears `threshold=2`, so a clean session costs nothing. [Score patterns with signals](#score-patterns-with-signals) below explains the scoring pipeline.


# Pick the primitive

Three primitives wrap the same LLM call. They differ in what they do with the verdict and where you call them. Here is one judgment, "is this edit a real test or just structure," expressed through each.

[llm_gate](../../reference/primitives.md#captain_hook.llm_gate) blocks the agent. The model returns a [GateVerdict](../../reference/primitives.md#captain_hook.GateVerdict), and the default verdict function fires on `block=True`.

``` python
from captain_hook import Event, Tool, llm_gate

llm_gate(
    "Does this edit add a test that asserts behavior, or only that code exists?",
    message=lambda r: f"Weak test: {r.reasoning}",
    only_if=[Tool("Edit")],
    events=Event.PostToolUse,
    verdict=lambda r: r.block,
)
```

[llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) warns without blocking. The model returns a [NudgeVerdict](../../reference/primitives.md#captain_hook.NudgeVerdict), and the default verdict function fires on `fire=True`.

``` python
from captain_hook import Event, Tool, llm_nudge

llm_nudge(
    "Does this edit add a test that asserts behavior, or only that code exists?",
    message=lambda r: f"Consider stronger assertions: {r.reasoning}",
    only_if=[Tool("Edit")],
    events=Event.PostToolUse,
    verdict=lambda r: r.fire,
)
```

[prompt_check](../../reference/primitives.md#captain_hook.prompt_check) runs inside your own handler when you need to inspect the event first. It returns a [HookResult](../../reference/events-results.md#captain_hook.HookResult) to block or warn, or `None` to allow. Build the prompt with [Prompt.from_template](../../reference/configuration-prompts.md#captain_hook.Prompt.from_template) so the placeholders fill from your event data.

``` python
from captain_hook import Event, Prompt, Tool, on, prompt_check

@on(Event.PostToolUse, only_if=[Tool("Edit")])
def review_test_quality(evt):
    content = evt.content or ""
    if "def test_" not in content:
        return None
    return prompt_check(
        evt,
        Prompt.from_template(
            "Evaluate this test:\n{code}\n\nDoes it assert behavior, or only that code exists?",
            code=content,
        ),
        prefix="Test quality",
        suffix=" Assert behavior, not that code exists.",
    )
```

[Prompt.from_template](../../reference/configuration-prompts.md#captain_hook.Prompt.from_template) raises `KeyError` when a placeholder has no matching keyword, so a typo fails loud instead of leaking braces into the prompt. The full [Prompt](../../reference/configuration-prompts.md#captain_hook.Prompt) builder API lives in [the primitives reference](../../docs/reference/primitives.md).

`evt.llm` is the raw form of the same call: ask the model from any handler and get a typed answer back, with no hook registration and no verdict plumbing. The return type follows what you pass -- a bare prompt returns `str | None`, `bool` and `int` return the parsed answer, and a Pydantic model class returns a validated instance. `None` always means the call was skipped -- already fired this turn, vetoed by `when`, or signals below threshold; a call that still fails after the final retry raises.

``` python
verdict = evt.llm("Rate the risk of this action.", RiskVerdict)
risky = evt.llm("Does this diff delete a public API?", bool, diff=True)
```


# Auto-answer permission dialogs

[llm_approve](../../reference/primitives.md#captain_hook.llm_approve) is the fourth LLM primitive, and it answers whether a pending tool call is safe to run without asking. It fires on `PermissionRequest`, the event Claude Code raises when a permission dialog would appear, and sends the pending tool call to a safety judge. A `safe` verdict answers the dialog with *allow*, so the tool runs silently. An unsafe verdict, or any LLM failure, returns nothing, and the real dialog shows; it never auto-denies on a model's say-so.

``` python
from captain_hook import FromSubagent, SkipPermissions, Tool, llm_approve

llm_approve(
    "safe teammate commands",
    rubric="Approve read-only inspection. Never approve anything that writes outside the repo.",
    only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()],
)
```

The judge's rubric is seeded from `claude auto-mode defaults`, the rule set behind Claude Code's own auto-approve mode. That native classifier isn't programmatically invocable, so captain-hook replicates it. The seeded rules are cached globally and refreshed when `claude --version` changes; when the binary or the verb is unavailable, a static built-in rubric stands in. Your `rubric` text appends to whichever base is used.

Where [llm_gate](../../reference/primitives.md#captain_hook.llm_gate) and [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) carry fire caps and a per-turn guard, [llm_approve](../../reference/primitives.md#captain_hook.llm_approve) has neither, so a matching dialog is always judged. That makes `only_if`/`skip_if` the whole cost story. Every matching ask is an LLM round-trip and a few seconds of latency before the dialog either disappears or shows. Scope it to the tools and origins you actually want silenced, and put the cheap deterministic conditions first. For dialogs a regex can already classify, prefer the free [`approve`/`deny`](../../docs/guide/primitives.md#approve) pair.


# Attach the working-tree diff

A review hook that reads only the transcript sees scattered [Edit](../../reference/prompt-contexts.md#captain_hook.Edit) blocks, never the cumulative change, and never edits made outside the agent. Pass `diff=True` to attach a compact diff of the uncommitted change as a `<diff>` block, so the model reviews the real change:

``` python
from captain_hook import Event, llm_gate

llm_gate(
    "The working-tree diff is in <diff>. Does it introduce a correctness bug?",
    message=lambda r: f"Fix before stopping: {r.reasoning}",
    diff=True,
    events=Event.Stop,
)
```

`diff=True` calls `evt.ctx.diff()`, which prefers cc-context's token-budgeted `ccx diff` and falls back to a plain `git diff` when `ccx` is unavailable, so the hook works in any repo. Diff against another source with `diff="staged"` or `diff="HEAD~1"`. For a scoped or custom-budget diff, call `evt.ctx.diff(scope="pkg/", budget=2000)` directly and fold the result into your prompt. An empty diff skips the model call entirely and consumes no fire, so a clean tree costs nothing.

A gate or nudge also attaches a recent window of the transcript by default, not the whole session, since the diff already carries the full change. Pass `transcript="full"` when a check needs the entire history, or `transcript=30` to size the window in events.


# Write the verdict function

The `verdict` parameter maps the model's response to a boolean. `True` fires the hook, and `False` stays quiet. Its signature is `(verdict_model) -> bool`. The defaults read the obvious field, `r.block` for a gate and `r.fire` for a nudge, so you override `verdict` only when you supply your own `response_model` or want a richer rule.

``` python
from pydantic import BaseModel
from captain_hook import llm_gate

class RiskVerdict(BaseModel):
    risk: str
    reasoning: str

llm_gate(
    "Rate the security risk of this action as low, medium, or high.",
    message=lambda r: f"Blocked ({r.risk}): {r.reasoning}",
    response_model=RiskVerdict,
    verdict=lambda r: r.risk == "high",
)
```

`message` takes the same verdict model when you pass a callable, so you can quote the model's reasoning back to the agent. The verdict types, [GateVerdict](../../reference/primitives.md#captain_hook.GateVerdict), [NudgeVerdict](../../reference/primitives.md#captain_hook.NudgeVerdict), and [PromptCheckVerdict](../../reference/primitives.md#captain_hook.PromptCheckVerdict), are defined in [the primitives reference](../../docs/reference/primitives.md).


# Test an LLM hook

`uvx capt-hook test` runs inline `tests={...}` without a live model. For [Input](../../reference/testing.md#captain_hook.Input)-keyed tests it stubs the model call with a verdict that fires by default, so the test exercises your `only_if` narrowing and message wiring, not the model's judgment. `Input(llm={"fire": False})` (or `{"block": False}` for a gate) overrides the stub verdict for that test, wire-testing the judge-declines path. Legacy string-keyed tests report `SKIP` when no recorded session fixture exists. Validate the LLM judgment itself by running the hook against real sessions.

> **Warning: Inline tests don't test the model**
>
> The stubbed verdict fires unless a test overrides it with `Input(llm=...)`, so a passing inline test proves your narrowing and message wiring work -- never that the model judges correctly. The judgment is the part that can be wrong in production. Verify it by replaying real sessions, not by adding more inline tests.

> **Note: One fire per turn**
>
> [llm_gate](../../reference/primitives.md#captain_hook.llm_gate) and [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) share a per-turn guard. Once one fires during a dispatch cycle, the other returns without calling the model, which stops several expensive hooks from stacking on the same event. [prompt_check](../../reference/primitives.md#captain_hook.prompt_check) runs inside your own handler and does not participate.


# Score patterns with signals

Signals score recent transcript text so a hook fires only when a pattern repeats hard enough to matter. You pass a [Signals](../../reference/conditions.md#captain_hook.Signals) bundle to a primitive's `signals=` parameter, set a `threshold`, and let the cumulative score decide. Use this when a single match is noise but a run of matches is a real behavior, like a retry loop or a wave of blame-shifting. The same `signals=` parameter works on [nudge()](../../reference/primitives.md#captain_hook.nudge), [gate()](../../reference/primitives.md#captain_hook.gate), [llm_gate()](../../reference/primitives.md#captain_hook.llm_gate), and [llm_nudge()](../../reference/primitives.md#captain_hook.llm_nudge).


## Match a literal phrase with a regex [Signal](../../reference/conditions.md#captain_hook.Signal)

A [Signal](../../reference/conditions.md#captain_hook.Signal) is a regex worth `weight` points. A matching signal contributes `weight` to the score once, however many times its pattern hits. Pattern weights must be positive; suppress false positives with `vetoes` (shown below) instead of negative weights. Every [Signal](../../reference/conditions.md#captain_hook.Signal) field is keyword-only.

``` python
import re
from captain_hook import Signal

Signal(pattern=r"let me try again", weight=2)
Signal(pattern=r"retry|retrying", weight=1)
Signal(pattern=r"external.*service", weight=2, flags=re.IGNORECASE)
```


## Match meaning with an [NlpSignal](../../reference/signals.md#captain_hook.NlpSignal)

An [NlpSignal](../../reference/signals.md#captain_hook.NlpSignal) matches a grammatical relationship instead of a fixed string. It parses each message with spaCy and checks whether a [Clause](../../reference/signals.md#captain_hook.Clause) you describe appears in the dependency tree. This catches paraphrases that a regex would miss.

A `Clause(noun=..., verb=...)` means "this noun is the subject or object of this verb." So `Clause(noun=Phrase("test"), verb=Phrase("run"))` matches "run the test," "the tests ran," and "running a test," because spaCy links `test` to `run` in each. A [Phrase](../../reference/signals.md#captain_hook.Phrase) holds one or more lemmas, so `Phrase("run", "rerun")` matches either verb.

``` python
from captain_hook import NlpSignal, Clause, Phrase

NlpSignal(
    clauses=[
        Clause(noun=Phrase("test"), verb=Phrase("run")),
    ],
    weight=3,
)
```

A [Clause](../../reference/signals.md#captain_hook.Clause) accepts more than a noun and verb. Add `adj=` to match an adjective on the noun, set `negated=True` to require a negation, or pass a compound noun phrase on its own:

``` python
from captain_hook import Clause, Phrase

Clause(noun=Phrase.expand("issue"), adj=Phrase("existing", "previous"))
Clause(noun=Phrase.expand("change"), verb=Phrase("cause", "introduce"), negated=True)
```

`Phrase.expand("issue")` pulls in WordNet synonyms of "issue" so the clause matches "problem," "bug," and other near-synonyms without you listing each one. The bare `Phrase("existing", "previous")` stays exact.


## Bundle signals with a threshold and window

A [Signals](../../reference/conditions.md#captain_hook.Signals) bundle groups patterns, sets the score `threshold` that triggers the hook, and bounds the `window` of recent transcript events to score. The window is a rolling slice of the last N events, so old matches age out as the conversation moves on.

``` python
from captain_hook import Signals, Signal

Signals(
    patterns=[
        Signal(pattern=r"retry", weight=2),
        Signal(pattern=r"again", weight=1),
    ],
    threshold=3,  # fire when a single message scores 3
    window=10,    # look back over the last 10 events
    vetoes=[Signal(pattern=r"investigating")],  # any match in the window cancels the fire
)
```

By default (`scope="text"`) each text is scored on its own: every signal that matches contributes its weight once, and the hook fires when a single text reaches the threshold. The window only bounds how far back that text may sit. "Let me retry that again" scores 3 and fires; "retry" in one message and "again" in another does not, because neither message reaches 3 alone. That's deliberate. Pooling matches across unrelated turns lets quoted or referenced vocabulary sum to the threshold and misfire.

When one tell legitimately splits across adjacent messages -- a wall-of-text option dump whose preamble lands in one message and the options in the next -- opt into pooling with `scope="window"`. Union scoring counts each distinct matching signal's weight once across the whole window, however many entries it matches, so "retry" and "again" in two separate messages now sum to 3 and fire. Under either scope, a `vetoes` match anywhere in the window holds the hook back.

Set `window="turn"` instead of a count to score the whole current turn. Tool calls and their results eat window slots, so a fixed window at `Stop` reaches only the last few tool exchanges. The `"turn"` sentinel reaches back to the prompt that opened the turn and catches a tell dropped hundreds of events earlier.

You can also pass a bare `list[Signal]` to a primitive. The framework wraps it with `threshold=1`, so any single match triggers.


## Choose whose words count with `origin`

`origin` selects which texts become scoring candidates in the first place -- an upstream filter, orthogonal to `scope`, which decides how the threshold is met among the candidates. The default `origin="assistant"` keeps only the agent's own prose: assistant messages, thinking blocks, and prose-carrying tool calls. User messages drop out before scoring, so a stance hook watching the agent's behavior doesn't fire because the user quoted the trigger vocabulary back at it. Harness-injected events -- teammate-message relay banners, scheduled-task injections -- never count under either origin.

Stamp `origin="any"` when the hook exists to score the human's words. A correction detector reads the user's prose by definition, and a `UserPromptSubmit` hook that scores the just-submitted prompt needs the stamp, because the prompt joins the scan only under `"any"`:

``` python
import re
from captain_hook import Event, Signal, Signals, nudge

nudge(
    "That reads like a durable correction -- run `uvx capt-hook status` to mine it into a hook.",
    events=Event.UserPromptSubmit,
    signals=Signals(
        patterns=[
            Signal(pattern=r"\bno\b|actually", weight=1, flags=re.IGNORECASE),
            Signal(pattern=r"\b(never|always)\b", weight=1, flags=re.IGNORECASE),
        ],
        threshold=2,
        window=0,
        origin="any",  # the prompt is user prose; the default drops it
    ),
)
```

> **Warning: An un-stamped `UserPromptSubmit` hook is silently dead**
>
> Under the default `origin`, the just-submitted prompt is excluded from the scan. A `UserPromptSubmit` bundle with `window=0` then has no candidates at all -- it never fires and never errors. Stamp `origin="any"` on any bundle that scores the prompt.


## Wire a bundle into a primitive

Pass the bundle to `nudge(signals=...)` or `gate(signals=...)` -- [gate](../../reference/primitives.md#captain_hook.gate) when crossing the threshold should block the turn instead of warning. The primitive pulls every prose source the bundle's `origin` allows from the window -- each assistant message's visible text, each thinking block, and the prose fields of `ReportFindings`, `TaskCreate`/`TaskUpdate`, and `TodoWrite` calls, plus user messages under `origin="any"` -- scores each one, and fires when the threshold is met. Each hook keeps its own consumed ledger, so the same triggering text doesn't fire that hook twice in a row.

``` python
import re
from captain_hook import nudge, Signals, Signal

nudge(
    "Stop retrying. Narrow the failing test first, then rerun it once.",
    signals=Signals(
        patterns=[
            Signal(pattern=r"let me try again", weight=2, flags=re.IGNORECASE),
            Signal(pattern=r"retry|retrying", weight=1, flags=re.IGNORECASE),
        ],
        threshold=3,
        window=10,
        vetoes=[Signal(pattern=r"investigating", flags=re.IGNORECASE)],
    ),
)
```

When a score crosses the threshold, the hook fires and appends a "Triggered by: …" line naming the text that set it off, so the agent sees why. For the full signal type list and field defaults, see the [conditions reference](../../docs/reference/conditions.md).


# Query the transcript

Your handler often needs to know what already happened this session: whether the agent ran the tests, which files it edited this turn, whether the user asked for the change you're about to block. Reach the transcript through `evt.ctx.t` inside any handler -- a typed, chainable query API.


## Query tool calls

`t.tool_calls` is a query over every tool invocation in the session. Narrow it with chainable methods and finish with a terminal:

``` python
from captain_hook import on, Event

@on(Event.Stop)
def check_tests(evt):
    t = evt.ctx.t
    edits = t.tool_calls.named("Edit").touching("**/*.py")
    return evt.block("No Python edits reviewed") if edits.any() else None
```

- `.named(spec)` filters by tool name -- pipe-delimited and alias-aware, so `named("Bash")` also matches `Execute`.
- `.touching(*globs)` filters to calls touching matching files; `.under(*prefixes)` to calls under path prefixes.
- `.failed()` narrows to errored calls; `.with_errors` widens the query to include failures, which are hidden by default.
- `.where(predicate)` takes a callable over the tool call for anything else; `.where_input(**rules)` matches raw input fields, each rule an exact value, an `re.Pattern`, or a predicate callable.

``` python
import re

deep = t.tool_calls.named("Bash").where_input(command=re.compile(r"rm\s+-rf"))
backgrounded = t.tool_calls.named("Task").where_input(run_in_background=True)
```

Terminals pull results out: `.count()`, `.any()`, `.first()`, `.last()`, `.files()` (a tuple of file references, one per file-touching call), or iterate the query directly with `list(edits)`.


## Ask a yes/no question

When you only need a boolean, the convenience checks read better than a full query. They walk subagent transcripts too.

``` python
t.has_tool("Bash")                    # alias-aware: matches Execute
t.has_command("uv", "run", "pytest")  # argv-prefix on literal tokens, wrapper-transparent
t.has_edit_to("**/*.py")
t.has_read("TESTING.md")
t.has_skill("codex")
t.user_said("fix", "bug")             # any keyword, case-insensitive
```

`has_command` takes argv tokens, not a regex -- `has_command("git", "push")` matches `sudo git push -f` because wrappers are stripped and the tokens prefix-match the argv.


## Slice the history

Slicing returns a smaller transcript you can query the same way. The tool-anchored slices take keyword arguments:

``` python
after_edit = t.after(tool="Edit")     # messages after the last Edit
before_test = t.before(tool="Bash")   # messages before the last Bash
recent = t.recent(5)                  # last five messages
prior = t.prior()                     # everything before the current turn
```


## Read the current turn

`t.current_turn` is the slice from the last user message onward, with shortcuts for the two things you usually want:

``` python
turn = t.current_turn
turn.prompt             # the user text that opened the turn
turn.edits              # one Edit per file modified this turn
```


## Extract text and commands

When a check needs raw material instead of tool calls:

``` python
t.assistant_text()      # recent assistant messages, truncated per message
t.commands()            # every Bash command string, in order
t.command_lines()       # the same commands, parsed into CommandLine objects
```


## Read the live task list

For a completion gate, such as a `Stop` hook that holds the agent until its work is done, read `evt.tasks` instead of replaying `TaskCreate` and `TaskUpdate` from the transcript.

``` python
@on(Event.Stop)
def task_gate(evt):
    if open_tasks := evt.tasks.open:
        return evt.block(f"{len(open_tasks)} of {len(evt.tasks)} tasks still open")
```

`evt.tasks` reads Claude Code's native task store keyed by the event's session, so it matches what `TaskList` shows. It includes updates made by subagents and teammates, drops deleted tasks, and carries lists across resumed sessions, none of which transcript replay can see. A session with no store has no tasks; it never borrows another session's. Filter by state with `.pending`, `.in_progress`, `.completed`, or `.open`, and check `.all_completed` for the whole list.


## Read the diff

When a check needs the actual change instead of the transcript's scattered edits, call `evt.ctx.diff()`. It returns a compact diff of the working tree, preferring cc-context's token-budgeted `ccx diff` and falling back to a plain `git diff` when `ccx` is unavailable.

``` python
@on(Event.Stop)
def review(evt):
    diff = evt.ctx.diff()                 # uncommitted working-tree diff
    staged = evt.ctx.diff("staged")       # only the staged change
    scoped = evt.ctx.diff(scope="src/")   # restrict to a path
```

[diff()](../../reference/events-results.md#captain_hook.HookContext.diff) returns `None` only when neither `ccx` nor `git` produces output. To feed the diff straight to a model, prefer the `diff=` flag on the LLM primitives over calling [diff()](../../reference/events-results.md#captain_hook.HookContext.diff) by hand, covered [above](#attach-the-working-tree-diff).


# See also

- [Writing hooks](../../docs/guide/primitives.md) covers the static primitives and conditions these build on.
- [Test hooks inline](../../docs/guide/testing.md) covers what the test runner stubs and skips.
- The [primitives reference](../../docs/reference/primitives.md) lists the full [llm_gate](../../reference/primitives.md#captain_hook.llm_gate), [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge), and [prompt_check](../../reference/primitives.md#captain_hook.prompt_check) option set; the [conditions reference](../../docs/reference/conditions.md) every signal type.
- [LLM cost control](../../docs/examples/llm-cost-control.md) is a runnable cost-bounded gate, and [Test integrity](../../docs/examples/test-integrity.md) is a runnable [prompt_check](../../reference/primitives.md#captain_hook.prompt_check) hook.
