Skip to main content
Hooks are scripts the agent harness runs around tool calls. They are the one part of agent governance that cannot be forgotten, because the harness executes them, not the model. They live in .agents/hooks/ and are wired in .claude/settings.json. Hooks have no dependencies beyond Node. A hook either denies a tool call with a reason the agent sees, or adds context to the conversation. None of them edits files.

What runs today

_io.mjs holds the shared stdin and output helpers. It is not a hook.

session-context

Runs on session start, resume, compaction and clear. It prints seven invariants: package location and naming, the tier tag and presets every member declares, versions only from catalogs, tests beside the code, docs in apps/docs, the secrets rule, and what done means. The point is that compaction cannot silently drop the laws. If an invariant changes on a docs page, change it here in the same commit.

guard-bash

Each rule is scoped to the operand of a command rather than a bare keyword. Heredoc bodies are stripped before matching, so a file whose prose mentions rm -rf does not trip a rule.

guard-paths

Refuses writes to paths a tool other than the agent owns: .env* and secret-named files, pnpm-lock.yaml, CHANGELOG.md (Changesets owns it), dist/, Encore’s generated encore.gen/ and .encore/ (the ~encore/* clients), apps/mobile/{ios,android} (Expo prebuild), uniwind-types.d.ts, the .claude/agents and .opencode/agents projections, and the generated rules block in AGENTS.md. Edit the source instead and re-run the generator.

post-write-checks

Reports a written TS/JS/JSON/MD/CSS/YAML file that oxfmt would reformat. Best-effort and never blocking: the write already happened.

Wiring

.claude/settings.json registers each hook by command with a five second timeout (ten for the formatter):
The path is .agents/hooks/, the one source. A hook is never copied into .claude/. The settings file is currently wired by hand, and the one canonical .agents/ source projected to tool folders (see the adoption plan) is what will keep it in step.

prompt-hints

UserPromptSubmit: one-line reminders keyed on what the prompt is about. Cheap, never blocks. Registered in .claude/settings.json beside the hooks above.

Testing a hook

A hook that no test exercises is a comment with a runtime cost. Every hook has a test in .agents/hooks/__tests__/ (pnpm test:hooks, part of pnpm validate). The pattern to follow:
  1. Pipe a fixture into the script as the harness would: {"tool_input":{"command":"git reset --hard"}} on stdin.
  2. Assert the output is a deny decision for every rule, and empty for a near miss. Include the near misses: git reset --soft, rm -r without -f, a heredoc that mentions a forbidden command.
  3. Run the test inside pnpm verify, so a regex edit that loosens a rule fails the gate.
Add a case in the same change as every new rule.

Hooks versus permissions

A hook denies by pattern, which is easy to get subtly wrong. Treat it as a seatbelt, not a boundary. The boundary is the human owning secrets and merges, the gitleaks scan in lefthook.yml, and pnpm verify in CI. Never write a hook that turns a denial into a prompt for the agent to retry with different quoting. Related: Secrets, Concurrent sessions, Rules that don’t rot.