Quickstart

Write your first tested hook in under five minutes.

A hook is an event, some conditions, an action, and a verdict Claude Code has to obey. In the next five minutes you’ll scaffold a project, write a hook that blocks a dangerous command, and watch its inline tests pass.

1. Scaffold

From your repo root:

uvx capt-hook init

init creates .claude/hooks/ with a starter hook, wires Claude Code’s settings to dispatch every hook event through capt-hook, and registers the plugin so the authoring skills load from it (nothing is copied into your repo). Prefer uv tool install capt-hook if you want the bare capt-hook on your PATH, or uv add capt-hook only if you’re consuming it as a library — every command here uses the uvx form.

2. Read a hook

init scaffolds .claude/hooks/example.py with a tour of the primitives — skim it later. First, the shape of a hook, using the gate from the front page:

from captain_hook import TouchedFile, UsedSkill, gate

# A Stop gate: before the agent finishes, block if it edited UI files without doing a visual review.
gate(
    # the one-line reason shown to the agent when the gate fires
    "You edited UI files. Open them with agent-browser and verify they render before finishing.",
    # fires only if UI files changed
    only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
    # already reviewed this session -> don't block
    skip_if=[UsedSkill("agent-browser", scope="session")],
)

Read it top to bottom: the event is the agent trying to finish, only_if scopes it to sessions that touched UI files, skip_if stands it down once a visual review happened, and the message is what the agent sees when the gate holds.

3. Write your own

Create .claude/hooks/safety.py:

# .claude/hooks/safety.py
from captain_hook import Allow, Block, Input, block_command

block_command(
    ["git", "push", "--force"],
    reason="Force-pushing rewrites shared history",
    hint="Use `git push --force-with-lease` instead",
    tests={
        Input(command="git push --force"): Block(),
        Input(command="git push origin main"): Allow(),
    },
)

One declaration, and the block covers the whole command family — chained after &&, buried in a pipeline, even mentioned in quotes. The tests dict is the hook’s contract: each Input is a synthetic event, each value the verdict you expect.

4. Test it

uvx capt-hook test
PASS  safety.py::block_command  git push --force → Block
PASS  safety.py::block_command  git push origin main → Allow
5 tests: 5 passed, 0 failed, 0 errors, 0 skipped

That’s the whole loop: declare, test, done. The next git push --force an agent attempts dies at PreToolUse with your reason and hint; LLM-backed hooks run their tests against recorded verdicts the same way.

Where next