Thin hook manifest
Have the hook config file declare only what events to subscribe to and route every one of them to a single handler binary; keep all branching, transformation, and policy in that handler.
When to use
- Building external tooling (telemetry, audit, policy enforcement) on top of a harness that exposes events through a user-edited config file (e.g. Claude Code's
.claude/settings.json). - You expect the integration's logic to evolve faster than the user's machine can be re-configured.
- You need typed payload handling, unit tests, or per-event branching that the manifest schema (
{matcher, command}) can't express. - Many event kinds want the same treatment ("log everything"), so per-event configuration would just be repetition.
When not to use
- A one-off shell hook that does one thing and never changes —
"command": "echo done >> ~/log"directly in the manifest is fine; wrapping it in a CLI is overkill. - The manifest schema is already expressive enough for the full policy.
- You don't control a fast distribution channel for the handler. If updating the handler is harder than updating the manifest, the leverage flips and the manifest is the right place for logic.
- Hot paths where per-event handler-process spawn cost matters more than the cost of fanning out matchers in the manifest — then push filtering into the manifest.
Context
The pattern is illustrative, not a law. It earns its keep when both of these are true: the manifest schema is too narrow for the real policy, AND the handler ships through a faster channel than the manifest does.
Anything baked into the manifest becomes a deployment artifact — changing it forces every user to re-install or hand-edit. Anything in the handler propagates the next time the user updates the package. Hook config schemas are also typically narrow on purpose: matcher + command. Real policy ("truncate this field, parse the transcript file, drop sub-agent events, count tokens") has nowhere to live in JSON.
Pattern
Two parts, with the asymmetry on purpose.
Manifest (thin). Every event of interest points at the same command. No per-event command, no matcher cleverness. Adding a new event type is one line.
{
"hooks": {
"PreToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "myagent hook" }] }],
"PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "myagent hook" }] }],
"SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "myagent hook" }] }],
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "myagent hook" }] }]
}
}
Handler (thick). One entry point. Every event lands here and branches on the event name in the payload. This is where typed parsing, transcript reads, network sends, truncation, and the never-block safeties live.
myagent hook (single binary)
├─ read JSON from stdin (with short timeout)
├─ branch on payload.hook_event_name
│ ├─ PreToolUse → record tool args
│ ├─ Stop → parse transcript, summarize
│ └─ ...
├─ ship to backend (detached, fire-and-forget)
└─ exit 0 always (try/finally)
Corollaries:
- Policy changes ship through the handler's package manager (
npm publish,pip upload), not user re-configuration. - Branching is a single-source-of-truth file; reading it tells the whole story of what the integration does.
- "Never block the harness" safeties — stdin timeout, detached I/O, catch-all exit 0 — are implemented once at the single sink, not duplicated per event.
- The handler's core functions (e.g.
buildPayload,convertEventType) are ordinary pure functions, easy to unit-test.
Trade-offs
- Process spawn per event. Every tool call spawns the handler. The cost is real (tens of ms each) and adds up on busy sessions. If measurement shows it matters, push a coarse matcher into the manifest to skip uninteresting events at the source.
- Opaque manifest. A reader looking at the user's
settings.jsonsees onlymyagent hookand learns nothing about what runs. You're trading manifest legibility for handler legibility — document the payload contract somewhere reachable. - Single point of failure. If the handler binary isn't on PATH, every event fails. A common mitigation is to make the
SessionStartentry self-bootstrapping (command -v myagent || install ...; myagent hook). - Coarse user controls. Users can't easily say "track Bash but not Read" — the manifest doesn't know about the distinction. If user-tunable filtering matters, expose it via the handler's own config or env vars, not the manifest.
Example
The same shape recurs across infrastructure: webhook receivers (URL registration vs. receiver code), Kubernetes (Service/Ingress YAML vs. controller), AWS Lambda + API Gateway (route vs. function), GitHub Actions (on: vs. step script), systemd (ExecStart= vs. binary). The manifest answers what/when, the handler answers how.
A textbook instance for Claude Code is the Argos telemetry CLI: its .claude/settings.json injects identical argos hook entries for every event type, and a single hook command in the CLI does all routing internally.
Related patterns
(none yet)