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, nudge, or 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 and the primitives reference. The events you can target live in the events reference, and the conditions you filter on live in the conditions reference.
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:
- Set
max_firesto cap calls per session. llm_gate defaults to1; llm_nudge defaults to3. - Add a narrow
only_ifso the hook reaches the model only on events that could matter. ATool("Edit")condition skips every Bash and read event for free. - Keep
model="small"(the default). Move up to"medium"or"large"only when the small model misjudges. - 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.
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 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 blocks the agent. The model returns a GateVerdict, and the default verdict function fires on block=True.
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 warns without blocking. The model returns a NudgeVerdict, and the default verdict function fires on fire=True.
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 runs inside your own handler when you need to inspect the event first. It returns a HookResult to block or warn, or None to allow. Build the prompt with Prompt.from_template so the placeholders fill from your event data.
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 raises KeyError when a placeholder has no matching keyword, so a typo fails loud instead of leaking braces into the prompt. The full Prompt builder API lives in the primitives reference.
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.
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 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.
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 and llm_nudge carry fire caps and a per-turn guard, 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 pair.
Attach the working-tree diff
A review hook that reads only the transcript sees scattered 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:
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.
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, NudgeVerdict, and PromptCheckVerdict, are defined in the primitives reference.
Test an LLM hook
uvx capt-hook test runs inline tests={...} without a live model. For 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.
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.
llm_gate and 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 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 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(), gate(), llm_gate(), and llm_nudge().
Match a literal phrase with a regex Signal
A 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 field is keyword-only.
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
An NlpSignal matches a grammatical relationship instead of a fixed string. It parses each message with spaCy and checks whether a 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 holds one or more lemmas, so Phrase("run", "rerun") matches either verb.
from captain_hook import NlpSignal, Clause, Phrase
NlpSignal(
clauses=[
Clause(noun=Phrase("test"), verb=Phrase("run")),
],
weight=3,
)A 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:
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 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.
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":
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
),
)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 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.
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.
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:
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, sonamed("Bash")also matchesExecute..touching(*globs)filters to calls touching matching files;.under(*prefixes)to calls under path prefixes..failed()narrows to errored calls;.with_errorswidens 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, anre.Pattern, or a predicate callable.
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.
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-insensitivehas_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:
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 turnRead the current turn
t.current_turn is the slice from the last user message onward, with shortcuts for the two things you usually want:
turn = t.current_turn
turn.prompt # the user text that opened the turn
turn.edits # one Edit per file modified this turnExtract text and commands
When a check needs raw material instead of tool calls:
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 objectsRead 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.
@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.
@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 pathdiff() 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() by hand, covered above.
See also
- Writing hooks covers the static primitives and conditions these build on.
- Test hooks inline covers what the test runner stubs and skips.
- The primitives reference lists the full llm_gate, llm_nudge, and prompt_check option set; the conditions reference every signal type.
- LLM cost control is a runnable cost-bounded gate, and Test integrity is a runnable prompt_check hook.