Hooks
Tool event handlers that fire on SessionStart, PreToolUse, PostToolUse, and Stop, loading message contexts enforcing git isolation and plan discipline, with a tested runtime contract and per-user state.
Hooks fire on tool events and load message contexts that the
assistant treats as additional system prompts. Wiring lives in
the Claude Code settings.json hook block (engine install) or the plugin's
hooks.json (plugin install), the Codex hooks.json hook file,
the Qwen Code settings.json hooks map, the CodeBuddy
.codebuddy/settings.local.json hooks key (SessionStart and PostToolUse only),
the Kiro hook file .kiro/hooks/apothem.json (SessionStart only), the
GitHub Copilot hook file .github/hooks/apothem.json (sessionStart and
postToolUse only), the Windsurf (Devin Local) .devin/config.local.json hooks
key (SessionStart and PostToolUse only), and the Cursor project hook file
.cursor/hooks.json (sessionStart only).
| Event | Timeout | Purpose |
|---|---|---|
| SessionStart | 30 seconds | Read memory, check plan state, report readiness; after compaction, deliver the recovery context; flag a knowledge base that changed since its baseline |
| PreCompact | 30 seconds | Verify state externalized before compaction (Codex, Qwen Code; not registered on Claude Code) |
| PostCompact | 30 seconds | Restore working state after compaction (Codex, Qwen Code; not registered on Claude Code) |
| Stop | 60 seconds | Session-end protocol, opt-in |
| PostToolUse | 10 seconds | Context-size advisory: save state at the first mark, and at the second the operator is told the session is long; at most twice per compaction cycle; runs in the background on Claude Code |
PreToolUse (shell: Bash, PowerShell, run_shell_command) | 10 seconds | Git-artifact isolation, plans locality, dynamic-eval guard |
| PreToolUse (Write/Edit/NotebookEdit/apply_patch) | 10 seconds | Plan-reference isolation, authorship header, plans locality, dependency and dynamic-eval guards |
| PreToolUse (AskUserQuestion) | 10 seconds | (Recommended)-marker well-formedness |
Dispatcher
Engine-installed hook entries invoke the dispatcher by absolute script path
with an install-resolved interpreter. The templates carry the ${PYTHON_BIN}
and ${HARNESS_ROOT} tokens, which the installer renders at install time.
Claude Code and Qwen Code materialize the dispatcher under
<harness-root>/.apothem/support/hooks/; Codex under <harness-root>/hooks/.
Claude Code entries use exec form (command plus args), so they need no
shell. The Claude Code plugin runs each hook as
bash "${CLAUDE_PLUGIN_ROOT}/lib/apothem/hooks/lib/bootstrap.sh" <event> [<message-file> ...],
in Claude Code's default hook shell (sh on macOS and Linux, Git Bash on
Windows); the bootstrap locates a Python 3.10+ interpreter and runs the
bundled dispatcher. It caches the interpreter it found in interpreter-path
(interpreter-path-ps1 for bootstrap.ps1) under the hook state directory
(below) and reuses it while the interpreter still exists, so the PATH probe
runs once rather than before every tool call; delete the cache file to force a
new probe. Only an absolute state directory and an absolute cached path are
honoured.
Every surface registers one dispatcher entry per event and matcher, listing
every message that matcher evaluates: one process reads the payload once, runs
each guard, and answers with one envelope, so a Write starts one dispatcher
rather than one per guard. The Claude Code engine install also runs the
conformity gate (gate.py --hook) on Write and Edit as its own entry. On Windows without Git for Windows, use the engine install
(apothem install --harness claude-code), whose exec-form hooks need no shell.
The dispatcher is a standalone stdlib script: it bootstraps its own
sys.path, so no importable apothem package is needed on the host. From a source
checkout, the module form python -m apothem.hooks.dispatch <event> (with
PYTHONPATH=src) runs the same code for development. The dispatcher loads
hooks/lib/resolve_root.py for ecosystem-root resolution and delegates to
hooks/emit_hook_context.py and hooks/session_start_bootstrap.py.
Vendor contracts
A harness that is not Claude Code runs the same dispatcher with
--contract <id> after the script, for example
"<python>" "<project>/.apothem/support/hooks/dispatch.py" --contract codebuddy SessionStart.
The contract (hooks/lib/vendor_contract.py) translates the vendor's event and
tool-name spellings and payload keys into the Claude shape the handlers read,
rewrites the envelope into the shape the vendor reads, and drops an event the
vendor does not hand to the model. Without the flag nothing is translated and
the output is the Claude envelope, byte for byte. The flag is part of the
documented command line of the dispatcher: dispatch.py [--contract <id>] <Event> [<message> ...].
| Contract | Reads | Output | Delivered events |
|---|---|---|---|
codebuddy | Claude's stdin | the Claude envelope | SessionStart, UserPromptSubmit, PostToolUse |
devin | Claude's stdin with lowercase tool names (exec, write, edit); the project root from DEVIN_PROJECT_DIR | the Claude envelope | SessionStart, UserPromptSubmit, PostToolUse |
cursor | camelCase events, conversation_id, workspace_roots | {"additional_context": ...} | SessionStart |
copilot | camelCase sessionId, toolName, toolArgs, or the PascalCase snake_case form | {"additionalContext": ...} | SessionStart, PostToolUse, Notification |
kiro | Claude-like stdin, Kiro tool ids (execute_bash, fs_write, str_replace) | bare text | SessionStart, UserPromptSubmit |
claude | Claude's stdin | the Claude envelope | every event |
The codebuddy, copilot, cursor, devin and kiro contracts are installed
by an Apothem adapter today (devin by the Windsurf adapter); the claude
contract proves the default command line is unchanged. The dispatcher stays
advisory under every contract: no shape carries a permission, a decision, a
block or a rewritten tool input.
Cursor and Devin Local load the hooks Claude Code's settings files hold as well as
their own, so with the Apothem Claude Code install on the same machine two sets
can run. The SessionStart bootstrap is delivered once: the vendor's own command
(--contract cursor or --contract devin) and the Claude Code copy, which is
the default command line, share one marker per session and source in the hook
state directory, and whichever runs second is silent. The default command line
takes part only when the process environment names the host (CURSOR_PROJECT_DIR,
CURSOR_VERSION or DEVIN_PROJECT_DIR); under Claude Code, Codex and Qwen Code
it never reads the marker. The marker binds for ten seconds, so a later start of
the same session, such as a second compaction, still delivers its recovery
context. No PostToolUse counter is de-duplicated, so the context-size counter
runs in both places and reaches its thresholds early. The install prints a note
that names the vendor's switch for turning the import off (Cursor's "Include
Third-Party Plugins, Skills, and Other Configs"; Devin's
read_config_from.claude).
A command registers only where the vendor hands its output to the model. Kiro
adds a hook's stdout to the context for SessionStart and UserPromptSubmit
only, so the Kiro file registers SessionStart alone. Copilot reads
additionalContext from sessionStart and postToolUse and only permission
fields from preToolUse, so the Copilot file registers those two events, under
their camelCase names, and no guard. Each Copilot entry carries a bash and a
powershell command. Both files are Apothem-owned and name the interpreter and
project path of the machine that ran the install; the harness pages say what
that means for a file a repository commits.
Devin Local reads additionalContext from SessionStart, UserPromptSubmit and
PostToolUse and documents none for PreToolUse, so the Windsurf registration
holds SessionStart and PostToolUse, with no matcher (Devin's matcher is a
regular expression, and an omitted one matches every tool). It goes in the local
override .devin/config.local.json, which Devin documents as gitignored and
excludes from git itself through .git/info/exclude.
Cursor's sessionStart hook adds additional_context to the conversation's
initial context; the first version registers that event alone. Cursor's
.cursor/hooks.json is the one project hook file, so it is the operator's: the
install merges one entry into it, keeps the operator's entries and keys, refuses a
file it cannot rewrite without loss, and tells its own entry apart by the
dispatcher path in the command. Cursor's desktop app runs hook commands through
PowerShell on Windows, so on a Windows host the command starts with & .
Across all five files an entry is Apothem's when its command runs
.apothem/support/[<harness>/]hooks/dispatch.py with --contract <id>,
whichever project path it was written under. A renamed, moved or cloned project
therefore keeps one set of entries, and a hook of yours, such as a
hooks/dispatch.py in your own tree or python -m apothem.conformity.gate, is
never replaced or removed. apothem verify reports the registration missing once
none of Apothem's entries is left in the file, and an uninstall that cannot read
the file reports it as skipped. A project or interpreter path that holds $, a
backtick, a double quote or a backslash is refused at install, naming the path,
because the quoted command would run a different file.
Message contexts
Per-event Markdown contexts live under src/apothem/hooks/messages/:
- Session lifecycle:
sessionstart.md,skill-index.md,precompact.md,postcompact.md,stop.md,posttooluse-proactive-compaction.md - Shell calls:
pretooluse-bash.md,pretooluse-bash-plan-guard.md - File writes:
pretooluse-write.md,pretooluse-edit.md,pretooluse-notebookedit.md,pretooluse-write-plan-guard.md,pretooluse-write-header-guard.md,pretooluse-edit-header-guard.md,pretooluse-conformity.md - Guards on content:
pretooluse-dependency-guard.md,pretooluse-eval-guard.md - Questions:
pretooluse-askuserquestion-recommended.md
On Claude Code, postcompact.md is delivered by the SessionStart hook when the
session resumes after compaction, because Claude Code does not pass PreCompact
or PostCompact hook output to the assistant.
skill-index.md is generated, not written: scripts/dev/generate_skill_index.py
renders it from the skill and command catalog, one line per task group, with
manual skills shown as /apothem:<name>. On Claude Code the SessionStart hook
adds it to the session context when the file exists. It exists because Claude
Code drops skill descriptions from its listing once the listing outgrows its
character budget, and the index keeps every skill findable by task. A plugin
install shows manual skills as /apothem:<name>; an engine install drops the
prefix. The text stays under 1,600 characters, and generate_skill_index.py --check fails when the committed file no longer matches the catalog.
When Apothem runs as a Claude Code plugin alone, no rule file loads at launch.
The SessionStart hook then adds a short pointer to the bundled rules/
directory. If that directory holds apothem-rule-index.md, the pointer also
tells the session to read the four core rules first, because nothing loads them
in this posture (agnostic-posture, authority-inquiry, session-closure,
context-management), and names the index, which says which rule to read for
which task. The index text is not inlined.
When the project holds a knowledge base made by the knowledge-wiki skill, the
SessionStart hook compares its fingerprint with the one recorded at the last
baseline and, if they differ, adds one line that suggests the skill's drift mode
(--mode drift --kb <path>). The fingerprint is the page count plus the newest
log entry; the recorded copy is the newest baseline line in the base's
log.md, which only the drift mode writes. The hook looks at four paths under
the project root, in order: knowledge-base/, wiki/, docs/knowledge-base/
and docs/wiki/, and only a directory that holds both index.md and log.md.
A project without one pays under a millisecond. With one, the hook reads at
most the last 256 KiB of log.md and lists three flat directories; it never
walks the tree and never writes. The line carries a fixed path, a date and two
page counts, never text from the base. It stays silent when there is no base, no
baseline, no change, or a file it cannot read, and it repeats each session until
the drift mode records a new baseline. A base at any other path works in every
skill mode but gets no session-start line.
Each context file is loaded by the dispatcher when its event fires
and its context argument is registered in the settings file. The license
comment line and the closing ## Bindings section are removed before the text
reaches the assistant; both exist for maintainers and the conformity gate.
A guard that finds nothing injects nothing. A guard that fires injects only
the ## Advisory section of its message, filled with what it found (the path,
the identifier, the primitive), followed by Full procedure: <message-file>.
The rest of the file is the full procedure, which the assistant reads only
when it needs the detail. Guards judge "inside the project" against the git
root above the tool call's working directory, never against the tree the hook
is installed in.
Runtime contract
Every registered hook command meets the same contract, checked by
tests/hooks/test_hook_contract.py on the plugin, the Claude Code engine
install, Codex, Qwen Code, and the CodeBuddy install (the Kiro, Copilot, Cursor
and Windsurf files are checked against their contracts by
tests/hooks/test_native_hook_registrations.py and run as the vendors run them
by tests/integration/test_native_hook_files_roundtrip.py,
test_cursor_hooks_roundtrip.py and test_windsurf_hooks_roundtrip.py):
- Runs from a plain checkout. Exit 0, and standard output is empty or one JSON object. No hook depends on a file's executable bit.
- Registered once. Each event and matcher has one dispatcher entry per surface, listing every message it evaluates, so a guard never runs twice on one tool call.
- Bounded. No emitted text exceeds 10,000 characters. The dependency guard speaks only when a write targets a dependency manifest or lockfile, and the dynamic-eval guard only when the content or command contains an evaluation or unsafe-deserialization primitive. The compaction advisory fires at most twice per compaction cycle.
- Uses channels the harness delivers. PreToolUse guidance travels in
hookSpecificOutput; the AskUserQuestion guard returnsadditionalContext, orpermissionDecision: denyunder the strict opt-in. Claude Code discards PreCompact and PostCompact output, so on Claude Code the post-compaction recovery context arrives with the SessionStart event whose source iscompact. - Never runs project code. The bootstrap resolves its own installed tree and never takes a script from the opened project. Session-start guidance is read from the installed tree; the project directory is read only for plan and memory summaries and the knowledge-base drift check.
- Fails open. A missing message file, a missing interpreter, or corrupt state yields exit 0 and a valid envelope.
| Variable | Default | Effect |
|---|---|---|
APOTHEM_HOOKS_DISABLE | unset | 1, true, yes or on silences every dispatcher-routed hook and the engine install's conformity-gate hook |
APOTHEM_SESSION_END_ENABLED | off | 1, true, yes or on enables the session-end protocol on Stop. A Stop hook that returns context makes the assistant continue, so the protocol costs one extra turn per session; it never fires on a stop that already follows a hook-driven continuation |
APOTHEM_SESSION_END_MIN_STOPS | 3 | Turn-ends before the session-end protocol may fire |
APOTHEM_PROACTIVE_COMPACTION_ENABLED | on | 0, false, no or off silences the compaction advisory |
APOTHEM_CONTEXT_WINDOW_TOKENS | unset | Context-window size; with it (or Claude Code's CLAUDE_CODE_AUTO_COMPACT_WINDOW) the advisories fire at 50% and 75% of the window, otherwise at 120,000 and 250,000 tokens |
APOTHEM_PROACTIVE_COMPACTION_TOOL_THRESHOLD | 18 | Tool calls per advisory window, used only when the context size cannot be measured |
APOTHEM_PROACTIVE_COMPACTION_OUTPUT_THRESHOLD | 20000 | Tool-output bytes per advisory window, used only when the context size cannot be measured |
APOTHEM_HOOK_STATE_DIR | see below | Directory for per-session hook state |
The compaction advisory measures the context from the newest usage record in the session transcript (input plus cached tokens), so it reflects what the model actually holds. A sharp drop in that size marks a compaction and restarts the two-advisory budget. Where no transcript is available it counts tool calls and output bytes instead.
Per-session hook state lives in a per-user directory created with mode 0700:
APOTHEM_HOOK_STATE_DIR when set, else ${CLAUDE_PLUGIN_DATA}/state for the
Claude Code plugin, else $XDG_STATE_HOME/apothem, else
~/.local/state/apothem (%LOCALAPPDATA%\apothem\state on Windows).
Generated Hook Message Inventory
| Name | Description | Source |
|---|---|---|
postcompact | Post-compaction Blind Bootstrap. Restore working state from durable files; conversation context is now compressed. | src/apothem/hooks/messages/postcompact.md |
posttooluse-proactive-compaction | Proactive-compaction tracker for PostToolUse tool calls. | src/apothem/hooks/messages/posttooluse-proactive-compaction.md |
precompact | Pre-compaction protocol. Externalize all in-conversation state BEFORE the harness compresses context. | src/apothem/hooks/messages/precompact.md |
pretooluse-askuserquestion-recommended | Call-time (Recommended)-marker guard for AskUserQuestion tool calls. | src/apothem/hooks/messages/pretooluse-askuserquestion-recommended.md |
pretooluse-bash-plan-guard | Plans-Discipline write guard for Bash tool calls (companion to pretooluse-write-plan-guard.md). | src/apothem/hooks/messages/pretooluse-bash-plan-guard.md |
pretooluse-bash | Git write guard: plan-internal isolation, numeric-prefix discipline, human-only authorship. | src/apothem/hooks/messages/pretooluse-bash.md |
pretooluse-conformity | > Advisory: this hook reports; it does not block. Mechanical enforcement runs in CI. | src/apothem/hooks/messages/pretooluse-conformity.md |
pretooluse-dependency-guard | Dependency-manifest supply-chain guard (advisory). | src/apothem/hooks/messages/pretooluse-dependency-guard.md |
pretooluse-edit-header-guard | SPDX-header inject guard for Edit tool calls. | src/apothem/hooks/messages/pretooluse-edit-header-guard.md |
pretooluse-edit | Codebase isolation (Edit scope). | src/apothem/hooks/messages/pretooluse-edit.md |
pretooluse-eval-guard | Dynamic-eval / unsafe-deserialization guard — advisory prompt-injection defense. | src/apothem/hooks/messages/pretooluse-eval-guard.md |
pretooluse-notebookedit | Codebase isolation (notebook scope). | src/apothem/hooks/messages/pretooluse-notebookedit.md |
pretooluse-write-header-guard | SPDX-header inject guard for Write tool calls. | src/apothem/hooks/messages/pretooluse-write-header-guard.md |
pretooluse-write-plan-guard | Plans-Discipline write guard for Write / Edit / NotebookEdit tool calls. | src/apothem/hooks/messages/pretooluse-write-plan-guard.md |
pretooluse-write | Codebase isolation + frontmatter compliance. | src/apothem/hooks/messages/pretooluse-write.md |
sessionstart | Session-start conformity posture. | src/apothem/hooks/messages/sessionstart.md |
skill-index | Skills by task. /apothem:name = manual, run only on request. | src/apothem/hooks/messages/skill-index.md |
stop | Session-end protocol: externalize state; evaluate memory and artifacts. | src/apothem/hooks/messages/stop.md |