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 thirteen events in total. For when each one fires, the event class it carries, and whether it can block, see the events reference. A fourteenth event, ResourcePressure, never comes from Claude Code. The capt-hookd host fires it when a session’s child process stays resource-heavy. See host events.

The dispatch pipeline

One captain-hook process handles every event the same way, in five 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. The matched hooks are independent, so they all start at once on a shared, bounded thread pool and the event costs the slowest hook rather than the sum — five packs listening on one PostToolUse no longer queue behind each other. Registrations that share a state key are the exception: one max_fires counter and one state directory sit behind them, so they run in registration order within their group while the groups run side by side. 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. Combine the verdicts. Only the starting is concurrent. The verdicts are folded in registration order, never completion order, so the envelope is the one a hook-at-a-time dispatch would have produced however the hooks interleaved: a blocking verdict wins wherever it landed, and messages join in the order the hooks were registered. Two consequences of running them together are worth knowing. A hook still queued when a hook ahead of it blocks is skipped as before, but one already running is left to finish and its verdict dropped. And a hook still running when the caller’s deadline arrives is abandoned, so the reply lands in time.
  5. 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, Runs, Tool

hook(
    Event.PreToolUse,
    only_if=[Tool("Bash"), Runs("git", "stash")],
    message="git stash is not allowed; use jj shelve",
    block=True,
)

block_command expands to the same shape, with a Command regex where this one names the command structurally. 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 Runs("git", "stash") compares its tokens against the argv prefix of every command in the parsed line, and matches — ["git", "stash", "pop"] starts with ["git", "stash"].

The regex condition would have matched here too: Command(r"git\s+stash") searches the command’s raw text and each parsed command’s argv join. It is the wrong reach for this job, because raw text includes text that is not a command. echo "git stash", a heredoc documenting the ban, and a --message quoting it all match the regex and none of them run git stash. The structural condition reads the same line the shell would, so a mention stays a mention. Save Command for what an argv prefix cannot name: a pipeline, a redirect, a flag’s value.

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. Any handler-backed hook still waiting for a thread is skipped, and the fold carries the deny forward whatever else came back.

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 client dispatches to the one signed host, which runs the wheel at its own build; 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/graphite/performance/steering 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 host materializes the wheel at exactly its own build, resolved by uv with registry hash checking. You trust the maintainers, PyPI, the GitHub release, and the Homebrew tap as one release path.

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 host runs its Python workers from the capt-hook wheel at exactly its own build, in a dedicated per-version environment under ~/.daemonkit that capt-hook helper install materializes with uv. The signed app is the single version authority, 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 normally dispatches through one per-user Go host and long-lived Python product workers.

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.

Exact dispatch path

The plugin registers one command for every event:

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

bin/hook is a committed wrapper that hands the arguments to binrun with the descriptor beside it, bootstrapping the one pinned, sha256-verified runner on a machine without it. The descriptor names the installed app’s Contents/Helpers/capt-hookd and sets copy_exec, so binrun runs a cached copy outside the bundle a deploy replaces. It requires app 12.31.0, the original binrun floor; the plugin sends no command that host lacks.

A missing app never reaches binrun. bin/hook checks for the bundle’s Info.plist first, and without it every event exits 0. The session’s first event, recognized by the payload’s session_id, carries a systemMessage naming uvx capt-hook helper install, while a PreToolUse or PermissionRequest still runs the guard literal below.

An older app gets binrun’s brew upgrade error and exit 1, except for a PreToolUse or PermissionRequest. binrun refuses before reading stdin, so bin/hook runs the plugin’s bundled guard literal under the python3 on PATH. A call that names a session-ending program runs under your permissions with a host-unavailable skip note and exit 0, and any other call keeps the non-blocking exit 1. When no python3 can run the literal, nothing can classify the payload, so every PreToolUse and PermissionRequest gets the static dependency-unavailable skip note and exit 0. A host that ran and then failed has already read stdin, so its own exit code passes through untouched.

The client reads the event from stdin, takes the project root from the first of CLAUDE_PROJECT_DIR, FACTORY_PROJECT_DIR, and the payload’s cwd, and sends one request over daemonkit’s length-framed persistent transport. While the host refuses the event before dispatch, because nothing is listening or the host is starting, draining, or out of session slots, the client resends it within its own deadline. If the host transport fails without a reply, the client evaluates a mandatory event locally once. Non-mandatory events still exit 1 on client failures; exit 2 belongs to a hook’s blocking verdict alone.

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 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 hands every event to its worker as it arrives, with no ordering between events. A worker holds at most 64 outstanding events, counting ones whose caller already gave up; a further event waits for a reply to free a slot, up to its own deadline. A worker serves concurrent events over one framed channel; response IDs keep their output and exit codes separate. The worker refuses an event whose deadline has already passed.

Failure is terminal for that dispatch

There is no replay or compatibility protocol. The host never runs an event twice. The client retries a mandatory PreToolUse or PermissionRequest flagged by the session guard prefilter once after transport-timeout. When the host never answers, the client runs one local evaluation after a host-unavailable, transport-refused, transport-timeout, or transport-error failure. It runs this build’s installed Python runtime in a child process with python -P -m captain_hook.worker evaluate, passing the same request prepared for the host. Non-mandatory events never evaluate locally.

The child returns the same EventResponse as a host worker, and the client checks guard completion the same way. A completed guard’s allow or deny passes through unchanged, as does a worker’s deny or blocking exit 2 without guard completion. Other incomplete replies return a skip note with the matching failure kind and exit 0. If this build’s tool environment is missing or the child crashes or times out, the skip note keeps the original transport kind. An answered event never gets a local evaluation, even if its guard did not complete.

The skip note leaves the call under your permissions. PreToolUse stdout puts it in top-level systemMessage and hookSpecificOutput.additionalContext, with no permissionDecision; PermissionRequest returns only systemMessage, with no decision. The note says the session guard did not complete, names the failure kind, and says the call ran without its check for session-ending programs. On macOS it points to capt-hook helper status, then capt-hook helper install if the host is missing or outdated. On Linux it points to capt-hookd status, then to reinstalling the host.

A mandatory hook that raises, misses its deadline, or is left unrun adds a note beside a deny the other hooks reached. Any other output, including an allow, a rewrite, or advisory context, gives way to the bare note, so a skipped guard never carries another hook’s allow and Claude Code’s own permission prompt still applies. The note names the unfinished hooks and cause, says they did not check the call, and points to capt-hook logs. A block any mandatory hook settled survives a sibling’s failure and a later advisory hook’s failure.

Local evaluation drops background work, including async hooks and reviewer dispatch. Transcript evidence still comes through the host’s transcript-client, so hooks that need it keep their existing missing-evidence behavior when the host is unreachable.

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. 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 deadline for each host transport attempt and the one-shot local evaluation 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. After replying to SessionStart, 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.

The upgrade deploys the app, copies its client, and installs the matching wheel, so it is complete on its own. 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 project-local hook run <Event> 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. Any process running as your user may send events; only the signed host may drain it. 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 never runs the Python CLI instead. With no app, each event exits 0 and the session’s first event notes the install command; a PreToolUse or PermissionRequest naming a session-ending program runs under your permissions with a skip note and exit 0. 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.