hook — codebase reference
The write side: the thing Claude Code runs on every session event, which logs the session to CouchDB + S3 as it happens. It is a pure observer — every action is observe-only and every external call is wrapped, so the hook can never block or fail a session.
For installation steps see hook-setup.md; for the full list of Claude Code events and which ones we wire, see hooks.md; for what each action does, see actions.md.
Where the hook actually lives
In the CLI, at packages/cli/src/hook/. claude-transcripts hook run reads one payload on stdin and runs the bound actions; claude-transcripts hook install registers that command with Claude Code. A user therefore needs neither Bun nor a checkout — the binary is the hook (installation.md).
packages/cli/src/hook/
├── index.ts # dispatch: payload → bound actions (bindings read from the app model)
├── handlers.ts # one handler per action key
└── runtime.ts # hook runtime config, CouchDB + S3 clients, per-session state
hooks/ is a thin plugin wrapper for people who prefer Claude Code's plugin mechanism. It contains no logging code: scripts/dispatch.ts pipes the payload to claude-transcripts hook run and always exits 0.
hooks/
├── .claude-plugin/plugin.json # plugin manifest
├── hooks/hooks.json # events to register (generated by `bun run gen:hooks`)
└── scripts/dispatch.ts # shim → `claude-transcripts hook run`
One writer, deliberately
This used to be two implementations. The plugin carried its own handlers, its own CouchDB and S3 clients, and copies of sumTranscriptTokens and the chunking helpers kept byte-identical with @claude-transcripts/shared by hand — because a plugin directory can't resolve the workspace, so it couldn't import the real code (ADR 0004).
Once the CLI became the registered hook, that constraint stopped applying: an installed binary resolves the shared code itself. The duplicate was removed along with the "keep these two files identical" rule, which had been pure drift risk ever since.
Dispatch & routing
Event → action bindings come from the app model (@claude-transcripts/shared), so dispatch can't drift from the registration written into Claude Code's settings — both are projections of the same source. Actions for an event run concurrently and settled: one failing action never stops another, errors go to stderr, and the process always exits 0.
bun run gen:hooks projects the model into hooks/hooks/hooks.json (which events the plugin registers, and their timeouts). SessionStart / SessionEnd get 180 s because they do real work — seeding state, writing the summary, uploading the transcript; everything else gets 5 s, because it should never hold up a turn.
Runtime config
The hook reads its config from CT_HOOK_CONFIG, else the instance's config.json under the XDG config dir (configuration.md). No config means the hook does nothing, silently — that's the correct behaviour for an unconfigured machine, not an error.
Verifying it
claude-transcripts doctor drives the whole write→read path and reports what worked. claude-transcripts hook status shows what's registered with Claude Code and where.