# Under the hood

This is the conceptual layer: how a hook fires, why a hook is typed data rather than a shell snippet, where the trust boundary sits, and how the resident daemon makes dispatch fast. Nothing here is required to write your first hook -- the [quickstart](../../docs/getting-started/quickstart.md) and [Writing hooks](../../docs/guide/primitives.md) are the task-oriented paths -- but when you want to know what actually happens between a tool call and a verdict, it's all on this page.


# A hook firing

You ban `git stash` in three lines:

``` python
from captain_hook import block_command

block_command(r"git\s+stash", reason="git stash is not allowed; use jj shelve")
```

When Claude tries to run `git stash pop`, Claude Code pauses the tool call and runs your hooks first. The hook matches the command, returns a deny verdict, and Claude Code skips the call. Claude reads the reason and reaches for `jj shelve` instead.

That is the whole shape of a hook.


# The lifecycle

Claude Code processes a turn in a loop. It reads the prompt, calls a tool, reads the result, then decides whether to continue or stop. Each labeled point in that loop is an event, and a hook registered for that event runs when Claude Code reaches it.


*\[Rich HTML output -- view on the documentation site\]*


Four of these events let a hook change the agent's course. `PreToolUse` denies a tool call so Claude Code skips it. `PermissionRequest` fires when a permission dialog would appear, and a hook answers it by approving, denying, or rewriting the call so the user never sees the dialog; no verdict means the dialog shows as usual. `Stop` and `SubagentStop` run the loop the other way, forcing the agent to keep going when it tries to finish early. Every other event is advisory. The hook injects a message into Claude's context but cannot block.

Claude Code fires twelve events in total. For when each one fires, the event class it carries, and whether it can block, see [the events reference](../../docs/reference/events.md).


# The dispatch pipeline

One captain-hook process handles every event the same way, in four stages.

1.  **Parse the event.** Claude Code writes a JSON payload to stdin. The CLI reads it and builds a typed event object, so a `PreToolUse` payload becomes a [PreToolUseEvent](../../reference/events-results.md#captain_hook.PreToolUseEvent) with accessors like `tool_name` and the parsed [command](../../reference/files-commands.md#captain_hook.Call.command).
2.  **Match conditions.** Each registered hook is checked against the event. The event must be in the hook's event set, every `only_if` condition must pass, and no `skip_if` condition may pass. Hooks whose conditions all hold are matched. Conditions are covered in [the conditions reference](../../docs/reference/conditions.md).
3.  **Execute matching hooks.** Each matched hook produces a verdict. A handler hook runs its function; a declarative hook returns its configured message. Every matched hook runs, and a block from any of them beats an allow -- deny wins, mirroring Claude Code's own `deny > ask > allow` precedence. Among approvals the first still wins, and warnings from every hook accumulate.
4.  **Format the verdict.** The winning verdict is rendered as the JSON envelope Claude Code expects and written to stdout. A block becomes a deny, a warning becomes injected context, and no verdict means an empty response that leaves the action untouched.


# Tracing a blocked `git stash`

Take the same hook in its full declarative form, so every part the pipeline reads is visible:

``` python
from captain_hook import hook, Event, Tool
from captain_hook.types import Command

hook(
    Event.PreToolUse,
    only_if=[Tool("Bash"), Command(r"git\s+stash")],
    message="git stash is not allowed; use jj shelve",
    block=True,
)
```

[block_command](../../reference/primitives.md#captain_hook.block_command) expands to this same call. Watch it run the four stages when Claude tries `git stash pop`.

**Parse the event.** Claude Code pauses the tool call and writes the payload to stdin. The CLI parses that JSON into a typed [PreToolUseEvent](../../reference/events-results.md#captain_hook.PreToolUseEvent), where `evt.tool_name` reads `"Bash"` and `evt.command` is the parsed command -- `evt.command.raw` reads `"git stash pop"`, and [the walkable structure](../../docs/guide/commands.md) hangs off the same object. The exact stdin fields live in the [events reference](../../docs/reference/events.md).

**Match conditions.** `Event.PreToolUse` is in the hook's event set, so the event qualifies, and the `only_if` conditions then run against it. `Tool("Bash")` reads `evt.tool_name` and matches, while `Command(r"git\s+stash")` searches the command's raw text and each parsed command's argv join, and matches. No `skip_if` is registered, so with every condition holding, the hook is matched.

**Run the action.** This hook has no handler function, so it returns its configured message as a blocking verdict, `HookResult(action=Action.block, message="git stash is not allowed; use jj shelve")`. A block is final. The pipeline stops checking further hooks and carries the verdict forward.

**Format the verdict.** The block renders as a `PreToolUse` deny envelope on stdout, `permissionDecision: "deny"` with the message as `permissionDecisionReason`. Claude Code reads the deny and skips the tool call. Claude sees the reason and reaches for `jj shelve`.

Set `block=False` and the same trace ends in a warning instead. The envelope swaps `permissionDecision: "deny"` for `additionalContext`, so Claude reads the message as advice and the tool still runs.


# A hook is data, not a script

captain-hook treats a hook as typed, declarative data you can test inline and check with a type checker, not a shell snippet you hand-write in `settings.json`. Not every hook needs that -- [the honest scoping answer](#when-a-native-settingsjson-hook-suffices) is below -- but here is the case for the trade.

A native `settings.json` hook is a command string. Claude Code pipes the event to that command's stdin and reads a verdict from its stdout. The matcher, the event filter, and the verdict all live as text inside a JSON file. Nothing reads that text until Claude Code fires the event, so a typo, a wrong field name, or a broken regex stays invisible until the moment it should have fired.

Take the same `git stash` ban. Natively, it's a matcher block wrapping a shell command that greps stdin and emits the deny envelope by hand:

``` json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' | grep -qE 'git\\s+stash' && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Use jj instead of git stash\"}}'"
          }
        ]
      }
    ]
  }
}
```

captain-hook moves the same rule into Python objects. You register the hook by calling a typed function:

``` python
from captain_hook import block_command

block_command(
    ["git", "stash"],
    reason="Use jj instead of git stash",
)
```

Same rule, same verdict. The event target, the conditions, and the verdict are now values with types -- values the type checker, your editor, and the test runner all read.

**You can test a hook before it ships.** Each primitive carries inline `tests`, and `uvx capt-hook test` runs every case, exiting non-zero on the first failure -- so a broken matcher fails in your terminal or in CI instead of in a live session. A native `settings.json` hook has nowhere to put this: you discover a regex mistake when the hook either blocks the wrong command or stays silent on the right one, mid-conversation, with no record of why.

**The wiring is mechanical.** You never hand-edit a matcher block in `settings.json` -- it carries no hook wiring at all. The captain-hook plugin ships a static `hooks.json` that registers every Claude Code event once, globally, with the same `uvx --isolated capt-hook run <Event>` command on every machine. The wiring can't drift from your hook definitions because it doesn't vary with them: which hooks actually fire in a repo is decided by the wheel builtins (fixes/general/steering/performance always, go/python by detected build manifests) plus any pack an enabled Claude Code plugin ships, and an event no pack subscribes to dispatches as a clean no-op.

As a directory of native string-hooks grows, the cost of each compounds, because none of them composes, none carries a test, and none fails loudly when it breaks. captain-hook spends a dependency and a Python file to buy back that visibility. For a directory you maintain over months, that is the trade worth making.


# When a native `settings.json` hook suffices

The trade isn't always worth it. Reach for a native `settings.json` hook, and skip captain-hook, when:

- The hook is a one-liner you never change. A single `echo` on `Notification`, or a formatter that always runs after every edit, has nothing to test and nothing to type-check. A JSON string is the lighter tool.
- The logic already lives in a script you trust. If you run an existing linter or a vetted shell script, wrapping it in a Python hook adds a layer without adding a check.
- You can't take the dependency. captain-hook runs through `uvx`, which needs [uv](https://docs.astral.sh/uv/) on the machine. An environment without uv, or one that forbids fetching a tool at runtime, rules it out. A native hook needs only the shell.
- You are wiring a handful of hooks, once. The payoff comes from testability and mechanical wiring across many hooks over time. For two or three hooks you never touch again, the JSON is enough.

captain-hook earns its place when a hook directory grows, changes, and needs to stay correct across edits. When the hook is small, static, and trusted, a native hook is the right amount of machinery. Choose by how much the hook changes and how badly a silent break costs you.


# Security & trust

A hook is real Python that runs on your machine whenever Claude Code fires an event. The code runs for an AI agent, but it runs with your permissions, so the trust model matters.


## The trust boundary

Two distinct bodies of code meet at a hook.

The framework is captain-hook itself, published to PyPI as `capt-hook`. You run it with `uvx --isolated capt-hook run <Event>`, so you trust the maintainers and PyPI the same way you trust any other dependency.

Your hooks are the Python files under `.claude/hooks/`. You write them, so they carry whatever logic you put there. captain-hook loads every file in that directory and executes it on the matching event. The framework does not sandbox your hook code, because your hook code is yours.

The boundary, then, sits between the framework you install and the hooks you author. captain-hook gives the hooks a typed event and reads back a verdict. It never sees more of your machine than the hook itself does.


## `uvx` isolation as a safety guarantee

`uvx --isolated capt-hook` runs captain-hook from an ephemeral environment that `uv` builds on demand. The framework and its dependencies install into a cache that `uv` manages, separate from your project. Your project's virtualenv stays untouched: captain-hook never lands in your `pyproject.toml`, your lockfile, or your site-packages, so you can add a hook to any repository without changing a single dependency in it.

The `--isolated` flag is load-bearing. A bare `uvx capt-hook` prefers an installed tool when one exists: run `uv tool install capt-hook` once, and every bare invocation on that machine silently resolves to the environment that install froze -- `uv` documents this shortcut -- so hooks keep running a months-old framework while looking current. `--isolated` ignores installed tools entirely. Dispatch always resolves from `uv`'s ephemeral-environment cache instead: a cache hit needs no network, so hooks fire offline, and revalidation picks up a new release within about a day of publish. Version drift stays contained without a copy pinned into your project that silently rots.

Removing the wiring removes captain-hook. Disable the captain-hook plugin in `.claude/settings.json` and nothing in your project still references the framework, because nothing in your project ever did. This isolation is the guarantee: adding hooks to a repository is reversible and leaves no trace in the code you ship.


## Where LLM-hook credentials come from

[LLM hooks](../../docs/guide/llm-hooks.md) call a large language model to judge transcript context. captain-hook does not hold an API key and does not call a provider's HTTP API directly. Instead, it shells out to the CLI of the model the project configures through the hook's `specialty`, then reads the structured verdict back from that CLI's output.

So the credentials are the ones that CLI already carries on the machine. A hook configured for the Claude backend invokes the `claude` CLI, which authenticates the way it is already set up to. A hook configured for the Codex backend invokes the `codex` CLI on its own credentials. captain-hook passes a prompt in and reads a verdict out. It never reads, stores, or forwards the key.

The model size you ask for, `small` or `medium` or `large`, maps to a provider model name inside the backend, so the project chooses both the provider and the tier. No model runs that the project did not configure.


## Reviewing hooks like code

Because hooks are code, treat them like code. `.claude/hooks/` is committed to the repository, so a hook change shows up as a diff, and you review that diff in a pull request the same way you review the rest of the change.

This is the practical control on the trust boundary above. A hook can run any command and read any file the agent's session can reach, which is exactly why the diff belongs in front of a reviewer. The framework you install is audited at the PyPI layer. The hooks you author are audited at the pull-request layer, by you.


# The resident daemon

Every hook event normally costs a fresh Python process: interpreter startup, the `captain_hook` import, hook discovery, then dispatch -- roughly 380-490ms per event, two or three times per tool call, multiplied by every concurrent session on the machine. The resident daemon deletes that cost. One warm worker per project serves events over a Unix socket at a measured ~70ms p50, about 7x faster than cold, and the fork storm from many concurrent sessions collapses into a single process.

The daemon is opt-in. Nothing changes until you point a wired hook command at the [hook](../../reference/registration.md#captain_hook.hook) client, and the cold path stays fully supported; it is also the fallback whenever a worker is unreachable.


## Turn it on

Installing capt-hook gives you the full `capt-hook` CLI plus [hook](../../reference/registration.md#captain_hook.hook), a thin stdlib-only client binary that forwards events to the project's worker. To serve a project warm, swap the binary in its wired hook commands:

``` bash
# cold -- a fresh process per event
"$CLAUDE_PROJECT_DIR"/.venv/bin/capt-hook run PreToolUse

# warm -- forwards to the project's resident worker
"$CLAUDE_PROJECT_DIR"/.venv/bin/hook run PreToolUse
```

Flip each `run <Event>` line (and its `--async` twin) the same way. The client recognizes `run <Event>` and `ping`; every other invocation -- `review run`, or anything carrying an explicit `--hooks` -- passes through to the cold CLI untouched, so leaving those lines on either binary behaves identically.

On the first event the client spawns the worker, detached, and waits for its socket; every later event connects directly. You never start the daemon by hand.

Plugin consumers are unchanged for now: the plugin's shipped `hooks.json` keeps dispatching cold with `uvx --isolated capt-hook run`, so installing the plugin never boots a daemon. Opt in per project, in a repo where capt-hook runs from a project virtualenv -- the client has to be cheap to exec, and a project-relative `.venv` path keeps the committed command [machine-invariant](#committed-hook-commands-must-be-machine-invariant).


## One worker per project

A worker serves exactly one project root. Its identity is the root's real path plus a small, explicit environment subset: the state, log, tasks, and decisions-db overrides, the run directory, `XDG_CACHE_HOME`, and every `HOOKS_*` variable. Change any of those and you address a different worker; everything else -- per-session variables included -- is deliberately excluded, so one worker serves all of a project's sessions, and pooled accounts share it too. Each request carries its own environment, working directory, and session identity, and the worker binds them per dispatch, so state, logs, and tasks land exactly where a cold run would put them.

Workers are disposable. One exits after two hours without a request (`HOOKS_DAEMON_IDLE_S`), and the next event respawns it, paying roughly one cold dispatch. A watchdog also restarts the worker when the installed capt-hook build changes, so an upgrade -- or an edit to an editable checkout -- never leaves stale code serving events.


## When a worker is unreachable

Whether the request was fully sent decides what a failure does. Before the request reaches the worker -- no reachable socket, a spawn that dies at startup, a connect error, a pre-send deadline -- the worker provably never dispatched, so the client applies `CAPT_HOOK_DAEMON_FALLBACK`: `cold` (the default) reruns the event in a fresh process, `open` exits 0, and `closed` exits 1, which is how CI keeps daemon bugs loud instead of masked by cold.

Once the request is sent, the worker may already be running your hooks. Any failure after that point -- a crash mid-response, a truncated or malformed response, a deadline that expires while waiting -- fails open: the client exits 0 with no output instead of rerunning cold, because a cold rerun would double-fire hooks that carry side effects. A hook never runs twice for one event; the trade is that a post-send failure can drop one verdict.

Two response statuses refine this. `rejected` -- a protocol-version mismatch or a saturated worker shedding load -- is a provable non-dispatch, so the client reruns cold even though the request was sent. [error](../../reference/tool-calls.md#captain_hook.OtherCall.error) means a hook raised: the worker's output is relayed verbatim, exactly as the cold CLI would print it, and never rerun.

One exception overrides the fallback mode entirely: an untrusted run directory (wrong owner, wrong mode, a symlink) or a socket whose peer is not your uid forces the cold path unconditionally. A planted socket must never be able to swallow a gate's deny by failing open.

Set `CAPT_HOOK_NO_DAEMON=1` to skip the daemon entirely and run every event cold, without touching the wired commands.


## Operate workers

The `hookd` binary, installed alongside `capt-hook` and [hook](../../reference/registration.md#captain_hook.hook), is the ops surface. It is also the worker itself -- the client spawns it as `hookd run --root <project>`, so you never start it by hand:

``` bash
hookd status              # this project's workers: root, pid, build, uptime, state
hookd status --all --json # every worker on the machine, machine-readable
hookd stop                # shut down over the socket; clean a dead worker's files
hookd restart             # drain: exit after in-flight work; the next event respawns
hookd logs --tail 40      # the worker's boot log and rotated daemon log
```

`status`, `stop`, and `logs` are connect-only: inspecting a project never boots a worker. `restart` drains instead of killing: in-flight dispatches finish first, so a slow LLM gate is never cut off mid-decision.

Logs live in three places. Per-session hook logs keep the same files and format as cold, so `uvx capt-hook logs` works unchanged (see [Troubleshooting](../../docs/guide/troubleshooting.md)). The worker's own records go to a rotated `daemon-<key>.log` in the log directory, and its boot stdio -- spawn failures from before logging comes up -- to `<key>.log` in the run directory; `hookd logs` prints both.


## Environment reference

| Variable | Default | Effect |
|----|----|----|
| `CAPT_HOOK_NO_DAEMON` | unset | `1` makes the client run every event cold, skipping the daemon entirely. |
| `CAPT_HOOK_DAEMON_FALLBACK` | `cold` | What a pre-send failure does: `cold` reruns the event in a fresh process, `open` exits 0, `closed` exits 1. Post-send failures always fail open regardless. |
| `CAPT_HOOK_CLIENT_TIMEOUT` | `30` (`20` for UserPromptSubmit) | End-to-end client deadline in seconds. The UserPromptSubmit default undercuts Claude Code's own 30s prompt-hook timeout. |
| `CAPT_HOOK_RUN_DIR` | `$XDG_CACHE_HOME/captain-hook/run`, else `~/.cache/captain-hook/run` | Where worker sockets, spawn locks, meta files, and boot logs live. Part of the worker key. |
| `HOOKS_DAEMON_IDLE_S` | `7200` | Seconds without a request before a worker exits. The next event respawns one. |
| `CAPT_HOOK_CLIENT_BUILD` | derived from `client.py` | Overrides the build fingerprint the client sends with each request; a testing knob for exercising client/worker version skew. |

The worker key also includes `CAPTAIN_HOOK_STATE_DIR`, `CAPTAIN_HOOK_LOG_DIR`, `CAPTAIN_HOOK_TASKS_DIR`, `CAPT_HOOK_DECISIONS_DB`, `XDG_CACHE_HOME`, and every `HOOKS_*` variable: sessions that differ on any of these get separate workers, by design.


## Committed hook commands must be machine-invariant

Claude Code unions the `hooks` blocks across every settings file (`settings.json`, `settings.local.json`, managed policy) and deduplicates by exact command string. A committed command that only resolves on one machine -- an absolute path into your home directory, a tool shim -- strands every other clone, and the same hook spelled two ways fires twice. Commit only project-relative commands, like the `"$CLAUDE_PROJECT_DIR"/.venv/bin/...` shape shown here; keep machine-local experiments in `settings.local.json`, which stays out of git.


## Parity limitations

Warm dispatch is byte-identical to cold -- stdout, stderr, and exit code -- and a 12-case parity suite enforces that on every commit. Three edges sit outside the parity contract; each was reviewed and accepted:

- A hook that spawns its own thread and logs from it writes those records to the daemon log, not the per-session log or the request's stderr. The routing context lives in a `ContextVar`, and Python does not propagate context to threads the hook creates. Logging from the hook's own call stack routes correctly.
- An uncaught hook error served warm prints a traceback whose framework frames are the daemon's, where a cold run shows the CLI's. The exception type and message, the hook's own frame, and the exit code all match; only the surrounding framework frames differ.
- Events from one session that land at the same moment serialize on the worker -- one dispatch per session at a time -- but not necessarily in arrival order. Cold has the same non-guarantee: Claude Code spawns independent processes and the kernel schedules them. Events from different sessions interleave freely.


## Daemon security posture

The daemon is same-user local tooling, not a privilege boundary. The worker listens on a 0600 socket inside a 0700 run directory, the worker refuses connections from other uids, and the client verifies the kernel-reported peer uid of any socket it connects to. An untrusted run directory or a foreign peer forces the cold path unconditionally. Everything a warm dispatch can do, running the cold CLI as the same user could already do; the daemon adds speed, not capability.


## Daemon troubleshooting

**Everything runs cold.** The client leaves one breadcrumb line per fallback on stderr, naming the reason. Run `hookd status` -- no worker listed means spawns are failing; check the boot log via `hookd logs`. Also check that `CAPT_HOOK_NO_DAEMON` isn't set in the session's environment.

**A worker won't start.** The boot log (`hookd logs`, or `<key>.log` in the run directory) captures the worker's startup stderr. The usual cause is a project venv whose capt-hook can't import -- a broken editable checkout, a half-finished `uv sync`. The client detects the early exit and runs cold instantly, so hooks keep working while you fix the venv.

**A stale socket after `kill -9`.** Harmless. The next event's spawn unlinks the dead socket and boots a fresh worker; `hookd stop` reports such workers as `cleaned` and removes their files.

**A worker seems stale.** Hook-file edits and pack changes invalidate the worker's registry cache automatically, and the build watchdog restarts it when the installed capt-hook changes. When in doubt, `hookd restart` drains the worker and the next event gets a fresh one.

**Force cold.** Set `CAPT_HOOK_NO_DAEMON=1` to bypass the daemon for a session, or flip the wired commands back from [hook](../../reference/registration.md#captain_hook.hook) to `capt-hook` to opt the project out.

For hook-level failure modes -- a hook that doesn't fire, a stale capt-hook install, missing NLP resources -- see [Troubleshooting](../../docs/guide/troubleshooting.md).


# See also

[Limitations](../../docs/guide/limitations.md) is the blunt list of what captain-hook cannot do. [Troubleshooting](../../docs/guide/troubleshooting.md) covers hook-level failure modes. The [events](../../docs/reference/events.md), [conditions](../../docs/reference/conditions.md), and [primitives](../../docs/reference/primitives.md) references carry the exhaustive facts this page links past.
