Under the hood

The dispatch pipeline, verdict precedence, the trust model, and the signed host.

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 signed host makes dispatch fast. Nothing here is required to write your first hook — the quickstart and Writing hooks 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:

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.

flowchart TD
    W[Session starts] -->|SessionStart| A[User submits prompt]
    A -->|UserPromptSubmit| B[Agent processes prompt]
    B --> C{Needs a tool?}
    C -->|Yes| D[PreToolUse]
    D --> E{Hook blocks?}
    E -->|Yes| F[Tool skipped]
    E -->|No| G[Tool executes]
    E -.->|Dialog would show| X[PermissionRequest]
    X -->|Hook allows| G
    X -->|Hook denies| F
    X -.->|No verdict| Y[Dialog shows: user decides]
    Y --> G
    Y --> F
    G --> H{Tool succeeded?}
    H -->|Yes| I[PostToolUse]
    H -->|No| J[PostToolUseFailure]
    I --> C
    J --> C
    F --> C
    C -->|No| K{Agent stopping?}
    K -->|Yes| L[Stop]
    L --> M{Hook blocks stop?}
    M -->|Yes| B
    M -->|No| N[Done]

    B -->|Launches subagent| O[SubagentStart]
    O --> P[Subagent runs]
    P --> Q[SubagentStop]
    Q --> R{Hook blocks stop?}
    R -->|Yes| P
    R -->|No| C

    B -->|Context too large| S[PreCompact]
    S --> T[Context compacted]
    T --> B

    B -.->|System notification| U[Notification]
    N -.->|Session exits| V[SessionEnd]

    style D fill:#f9f,stroke:#333
    style X fill:#f9f,stroke:#333
    style I fill:#9f9,stroke:#333
    style J fill:#f99,stroke:#333
    style L fill:#99f,stroke:#333
    style Q fill:#99f,stroke:#333
    style A fill:#ff9,stroke:#333
    style S fill:#9ff,stroke:#333
    style U fill:#ddd,stroke:#333
    style V fill:#ddd,stroke:#333
    style W fill:#ddd,stroke:#333

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.

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 with accessors like tool_name and the parsed 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.
  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:

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 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, where evt.tool_name reads "Bash" and evt.command is the parsed command — evt.command.raw reads "git stash pop", and the walkable structure hangs off the same object. The exact stdin fields live in the events reference.

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 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:

{
  "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:

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 "${CLAUDE_PLUGIN_ROOT}/bin/hook" run <Event> command on every machine. That wrapper dispatches through the fixed signed helper at the exact build the helper itself names; it cannot start a project-local daemon or substitute the Python CLI. 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 needs macOS 15 or newer, the signed Captain Hook.app, and uv. An environment without any one of them, 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 has two exact-version parts: the capt-hook wheel published to PyPI and the Developer ID-signed, notarized Captain Hook.app bundled into the Homebrew formula. The plugin’s bin/hook wrapper materializes the wheel at exactly the build the installed app names — resolved by uv with registry hash checking — and the wheel’s hook shim execs ~/Applications/Captain Hook.app/Contents/Helpers/capt-hookd. You trust the maintainers, PyPI, the GitHub release, and the Homebrew tap as one release path; the wrapper’s tiny runner, binrun, is itself pinned by sha256 in the committed plugin script.

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.

The app names the wheel

Dispatch never resolves “latest”. The plugin’s bin/hook wrapper asks the installed helper for its build and runs the capt-hook wheel at exactly that version, from a dedicated per-version environment under ~/.daemonkit that uv materializes on first use. The signed app is the single version authority: the same binary that answers the version question verifies the build at dispatch, so the wheel and app cannot drift apart. A build needs the network once, when its environment is first materialized; after that, hooks fire offline.

That environment is separate from your project. The Python worker and its dependencies come from it, never from your repo: 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.

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 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 signed host

Every hook event used to pay for a fresh Python interpreter, framework import, and hook discovery. The current runtime has one per-user Go host and long-lived Python product workers. It is the only dispatch path, not an opt-in accelerator beside a cold path.

The host executable is fixed at ~/Applications/Captain Hook.app/Contents/Helpers/capt-hookd. The app release embeds a universal binary, signs it as nested code, and ships it with the wheel and plugin at the same version. A hook event with a different wheel build is rejected before Python runs.

Exact dispatch path

The plugin registers this command for every event (plus its --async twin):

"${CLAUDE_PLUGIN_ROOT}/bin/hook" run PreToolUse

bin/hook is a committed wrapper that hands the arguments to binrun with the descriptor committed beside it. The descriptor’s version is dynamic: binrun runs capt-hookd version, reads the installed build, and execs the hook entrypoint from a capt-hook environment materialized at exactly that version. On a machine without binrun, the wrapper bootstraps the one pinned runner release, sha256-verified, before handing off.

hook is a stdlib-only exec shim. It accepts only [--root ROOT] run EVENT [--async], resolves the current Python executable and wheel version, then replaces itself with the fixed capt-hookd run client. It does not accept arbitrary capt-hook commands and never passes an unknown command through. Everything in front of the engine — wrapper, runner, shim — exits 1 on its own failures; exit 2 belongs to a hook’s blocking verdict alone.

The signed Go client requires the deployment-owned exact host, then sends the event over daemonkit’s length-framed persistent transport. The host selects a Python worker and exchanges multiplexed, length-framed messages over that child’s stdin and stdout. Python does not listen on a socket, create a PID file, detach itself, or spawn another daemon.

The formula’s deployment transaction starts the host and app from the fixed signed bundle. Daemonkit owns listener takeover, admission, draining, process identity, and ordered shutdown. An exact newer formula replaces the prior generation; request dispatch never starts or repairs it.

One host, semantically keyed workers

There is one host socket per user, at ~/Library/Caches/captain-hook/host-v1/capt-hookd.sock. Projects do not receive private sockets, spawn locks, PID files, or lifecycle processes.

A Python worker key contains the canonical project root, exact Python executable, exact build, and the environment values that change product semantics: XDG_CACHE_HOME, CAPTAIN_HOOK_STATE_DIR, CAPTAIN_HOOK_LOG_DIR, CAPTAIN_HOOK_TASKS_DIR, CAPT_HOOK_DECISIONS_DB, and every HOOKS_* value. Sessions and pooled accounts that share those facts share a worker. Session, Claude, and Factory variables stay request-scoped and cannot leak from the first client into the worker’s base environment.

The host serializes events from the same session while allowing different sessions to overlap, with a global maximum of 16 product dispatches. A worker can serve concurrent sessions over one framed channel; response IDs keep their output and exit codes separate.

Failure is terminal for that dispatch

There is no cold fallback, fail-open mode, replay, or compatibility protocol. A missing app, build mismatch, malformed frame, unavailable host, or expired deadline returns a non-zero hook process. Once an event is admitted, no transport outcome causes captain-hook to run it a second time.

Workers are disposable. When a worker call times out or its protocol fails, the host retires that exact generation, sends TERM, escalates to KILL after the bounded grace period, waits for it, and reaps it before releasing the semantic lane. The next event may create a clean worker; the failed event is not replayed.

Operate the host

The fixed helper exposes a small exact operations surface:

host="$HOME/Applications/Captain Hook.app/Contents/Helpers/capt-hookd"
"$host" version          # exact schema and release build
"$host" status           # host PID and every live worker generation as JSON
"$host" restart-workers  # stop and reap every worker; keep the host
"$host" shutdown         # ordered host and worker shutdown

The wheel’s hookd command is only an exec shim for the same helper. Status and control commands never start a missing host. Only event dispatch ensures it is running.

Per-session hook logs keep their existing files and remain visible through uvx capt-hook logs. Host startup and worker stderr go to ~/Library/Caches/captain-hook/host-v1/capt-hookd.log. Durable worker identity lives beside it in workers.json so daemonkit can recover and reap a leftover generation after a host crash.

A state store whose on-disk schema definitively no longer matches cannot wedge the host. On startup the host renames the mismatched file aside with a timestamped .bak suffix, logs one line naming the archive, and continues with a fresh store. Only that definitive mismatch is archived — a transient read or permission error still fails loudly, so a healthy store is never discarded for a recoverable reason.

Environment and state

CAPT_HOOK_CLIENT_TIMEOUT sets the end-to-end event deadline in seconds; the default is 30. There are no daemon-disable, fallback-mode, run-directory, idle-timeout, or client-build overrides. Host state always uses the fixed per-user host-v1 directory. Product state and logs remain configurable through their existing CAPTAIN_HOOK_*, CAPT_HOOK_DECISIONS_DB, XDG_CACHE_HOME, and HOOKS_* variables, which participate in the worker key as described above.

The host keeps itself current

A new release reaches your machine without a manual brew upgrade. On the async SessionStart pass the dispatcher checks — at most once per 12 hours across all your sessions — whether the latest GitHub release is newer than the installed host. When it is, it detaches a background brew upgrade --formula (retried as a formula reinstall, which also repairs the deployment) and posts a notification banner when the upgrade lands or fails. The check never blocks a hook and never touches a verdict; every failure becomes a log line, not an exit code.

Because dispatch resolves the wheel from the installed app’s build, the upgrade is complete on its own: the next event materializes the matching wheel automatically. Set HOOKS_UPDATE_ENABLED=false to turn the check off, or HOOKS_UPDATE_INTERVAL_MINUTES to change the throttle window.

Do not hand-wire dispatch

The plugin owns the one canonical hook command. A surviving capt-hook run <Event> or old project-local hook entry is a second registration, so Claude Code can dispatch the event twice. Delete those entries rather than trying to make their strings match. Disable the plugin when you want captain-hook out of a project; there is no supported cold or private-daemon mode.

Security posture

The host is same-user local tooling, not a sandbox for hook code. Its stable Developer ID-signed, hardened executable owns the lifecycle boundary; the state directory is mode 0700 and the global socket is mode 0600. Daemonkit attaches kernel peer identity to the accepted session, and the host rejects an event whose claimed client PID differs from that authenticated peer. Python workers receive framed events over owned pipes and never open the host socket themselves.

Hooks still run with your permissions and can do anything your Claude Code session can do. The signed topology prevents an accidental project daemon or stale executable from becoming the runtime owner; it does not make untrusted hook code safe.

Host troubleshooting

The signed host is missing. Install or repair it with uvx capt-hook helper install. Dispatch fails non-zero — the version probe has nothing to ask — and never runs the Python CLI instead.

A build-mismatch error appears. Plugin dispatch cannot skew — the wheel is resolved from the installed app’s own build — so a mismatch names a dispatch entry outside the plugin’s wrapper: a hand-wired legacy command, or a development checkout running its own wheel. Troubleshooting walks both.

A worker is stuck or stale. Run "$host" status, inspect capt-hookd.log, then use "$host" restart-workers. Restart kills and reaps the current worker generations; it is not a cache invalidation hint or a graceful watchdog cycle.

The host was killed. The next event starts a fresh host generation and daemonkit recovers any durable worker records before admitting work. Do not delete the socket or workers.json by hand.

For hook-level failure modes — a hook that doesn’t fire, stale release artifacts, missing NLP resources — see Troubleshooting.

See also

Limitations is the blunt list of what captain-hook cannot do. Troubleshooting covers hook-level failure modes. The events, conditions, and primitives references carry the exhaustive facts this page links past.