Agent hooks
August 4, 2026 · View on GitHub
Portability: ✅ the
velesdb-memoryMCP server and its tools work in any MCP client — nothing on this page is needed to use them · ⚠️ hooks are a harness feature, not part of MCP, so everything here is per-harness and non-portable by construction · ⚠️ replacing a tool result (updatedToolOutput) is Claude Code only.
Wiring the velesdb-memory MCP server into an agent (see
crates/velesdb-memory/README.md)
gives it the tools. It does not make the agent actually call
load_working_context at the start of every session or
save_working_context before every one ends — that only happens if the
agent remembers to, which it won't reliably do on its own. This directory
automates load/save, recall-before-edit, pre-compaction advice, and tool-result
compression for Claude Code (five tested hooks); load/save and
recall-before-edit for Codex CLI (four tested hooks; tool-result
replacement remains unavailable); and a partial reminder for
Windsurf (one tested hook, with a delivery caveat). The parity
table below says exactly what exists where, and why each gap is a gap.
Parity across harnesses
Status, not aspiration. ✅ = shipped here and asserted by
test/hooks.test.sh · ⚠️ = shipped but weaker than the
Claude Code equivalent · ❌ = not shipped, with the reason spelled out.
| Loop step | Claude Code | Windsurf | Codex CLI |
|---|---|---|---|
| Load working context at session start | ✅ session-start.sh on SessionStart | ⚠️ pre-user-prompt.sh on pre_user_prompt, once per trajectory_id — advisory text, see the delivery caveat below | ✅ codex/hooks/session-start.sh on SessionStart, via additionalContext |
| Save/checkpoint working context | ✅ first Stop plus every later covered edit batch when opted in; otherwise the first Stop keeps the legacy save reminder | ⚠️ no end-of-session event is used: the same first-prompt reminder also asks for the save, hours in advance | ✅ same session-and-edit checkpoint as Claude; otherwise the first Stop keeps the legacy save reminder |
| Require successful recall before the first repository edit | ✅ pre-tool-use.sh refuses Edit/Write; post-tool-use.sh marks only a successful VelesDB recall | ❌ not implemented | ✅ codex/hooks/pre-tool-use.sh refuses apply_patch; its PostToolUse partner applies the same success rule |
| Compile the transcript before compaction | ✅ pre-compact.sh on PreCompact | ❌ Windsurf documents no compaction event. A VERIFIER: whether Cascade compacts at all in a way any hook can observe | ⚠️ no pre-compaction hook is possible. PreCompact/PostCompact support neither additionalContext nor a documented decision/reason, so nothing a hook prints there reaches the model. codex/hooks/session-start.sh compensates after the fact on source == "compact" |
| Replace an oversized tool result | ✅ post-tool-use.sh via hookSpecificOutput.updatedToolOutput | ❌ API gap. Windsurf post-hooks cannot alter or block a result — "post-hooks cannot block since the action has already occurred" — and no documented field replaces one | ❌ API gap as documented. Codex PostToolUse can add additionalContext, or decision: "block" to substitute feedback for the result, but documents no equivalent of updatedToolOutput — it cannot hand back a compiled version of the real output |
Why the Windsurf gap is smaller than it looks — and the caveat that matters more
Earlier revisions of this page asserted that Windsurf exposes a single
lifecycle hook. That is wrong. The Cascade hooks reference (checked
2026-07-25) documents twelve events: pre_read_code, post_read_code,
pre_write_code, post_write_code, pre_run_command, post_run_command,
pre_mcp_tool_use, post_mcp_tool_use, pre_user_prompt,
post_cascade_response, post_cascade_response_with_transcript, and
post_setup_worktree. So the missing end-of-session reminder is unwritten
work, not a platform limit — though the fit is imperfect:
post_cascade_response fires after every response rather than once at the
end, and post_cascade_response_with_transcript hands over a JSONL
transcript path, which is exactly the input compile_transcript wants.
Neither is implemented here, and neither should be shipped until someone has
run it against a real install.
⚠️ Delivery caveat — read this before trusting the Windsurf hook. Windsurf's
documented contract sends a hook's stdout/stderr to the Cascade UI when
show_output is true, and states that the agent sees a message only when
a pre-hook exits with code 2 — which also blocks the action.
pre-user-prompt.sh prints to stdout and exits 0, so on the documented
contract its reminder reaches the human, not the model. It was not
switched to exit 2 because that would block the user's prompt. A VERIFIER, on
a real Windsurf install: whether pre_user_prompt stdout is additionally
prepended to the model's context. Until someone checks, treat the Windsurf
integration as a user-facing nudge, not an enforced loop.
What Codex can and cannot do
The Codex hooks reference (checked 2026-08-04) lists SessionStart,
SessionEnd, PreToolUse, PermissionRequest, PostToolUse, PreCompact,
PostCompact, UserPromptSubmit, SubagentStart, SubagentStop and Stop,
configured from ~/.codex/hooks.json, <repo>/.codex/hooks.json or a
[hooks] table in config.toml, with a Claude-Code-shaped
event → matcher → handler structure. The stdin payload carries
session_id, transcript_path, cwd, hook_event_name, model and
permission_mode, plus event-specific fields such as source on
SessionStart, tool_name/tool_input/tool_response on tool events, and
stop_hook_active/last_assistant_message on Stop. The common field names
match Claude Code, which is why codex/hooks/ can share the
sentinel and config-resolution logic.
Two limits shape what is shipped there, and both are documented, not guessed:
hookSpecificOutput.additionalContextis injected forSessionStart,SubagentStart,PreToolUse,PostToolUse,UserPromptSubmit,SubagentStopandStop— but not forSessionEnd,PreCompact,PostCompactorPermissionRequest. SincePreCompactalso has no documenteddecision/reasonoutput, a Codex pre-compaction hook has no channel that reaches the model at all. Nothing is shipped for it; theSessionStarthook compensates after the fact viasource == "compact".PostToolUsecannot replace the tool output. It can add context, ordecision: "block"to substitute feedback for the result — which discards the result rather than compressing it. So the one hook that makes Claude Code sessions structurally cheaper has no Codex counterpart.
This was also measured on a real codex-cli 0.146.0-alpha.9.2 build with the
stable hooks feature: strict config accepted the hook table, PostToolUse
received both a real shell call and a real MCP call, and the MCP payload carried
tool_name, tool_input, tool_response, tool_use_id, and
isError: false. That establishes the success-side sentinel design; the
scripts still fail closed for missing or error responses.
Sources checked 2026-08-04 (living documents — re-check before relying on
any ❌ above): Cascade hooks reference,
https://docs.devin.ai/desktop/cascade/hooks (docs.windsurf.com redirects
there); Codex hooks reference, https://learn.chatgpt.com/docs/hooks
(developers.openai.com/codex/hooks redirects there).
Install — Claude Code
Both variants need bash and jq on PATH. The installer refuses to create
an inert hook tree without jq, and the drift check reports if an installed
tree later loses it. If the dependency disappears at runtime, PreToolUse
exits with the host's blocking code before any covered edit; the other
lifecycle reminders may still be treated as failed hooks by the host.
Global (recommended for continuous CLI usage — every project, one-time
setup). One command does both halves — the scripts and the five
settings.json entries:
python3 scripts/sync-agent-hooks.py --install
It touches only entries whose command contains .claude/hooks/velesdb-memory/,
merges at hook granularity (a foreign hook may share a group with ours), backs
settings.json up before writing, replaces it atomically, and never prints its
contents. Three more modes are worth knowing:
python3 scripts/sync-agent-hooks.py --check --strict # in step / drifted / absent, per artefact
python3 scripts/sync-agent-hooks.py --install --dry-run # say what would change, write nothing
python3 scripts/sync-agent-hooks.py --uninstall # remove ours, and only ours
An installed copy that drifts from this repository is the failure mode this exists for: the hooks a session runs live outside any repository, and measured on 2026-08-02 they had diverged in both directions at once — the repository ahead on the model-facing text, the install ahead on function.
Doing it by hand
mkdir -p ~/.claude/hooks/velesdb-memory
cp /path/to/velesdb/integrations/agent-hooks/claude-code/hooks/*.sh ~/.claude/hooks/velesdb-memory/
cp -r /path/to/velesdb/integrations/agent-hooks/claude-code/hooks/lib ~/.claude/hooks/velesdb-memory/
chmod +x ~/.claude/hooks/velesdb-memory/*.sh
Then merge this into ~/.claude/settings.json's "hooks" key — note the
absolute path, not $CLAUDE_PROJECT_DIR (there is no single project to
be relative to for a global install):
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "bash '/Users/you/.claude/hooks/velesdb-memory/session-start.sh'" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "bash '/Users/you/.claude/hooks/velesdb-memory/stop.sh'" }] }
],
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "bash '/Users/you/.claude/hooks/velesdb-memory/pre-compact.sh'" }] }
],
"PreToolUse": [
{ "matcher": "^(Edit|Write)$", "hooks": [{ "type": "command", "command": "bash '/Users/you/.claude/hooks/velesdb-memory/pre-tool-use.sh'" }] }
],
"PostToolUse": [
{ "hooks": [{ "type": "command", "command": "bash '/Users/you/.claude/hooks/velesdb-memory/post-tool-use.sh'" }] }
]
}
}
PostToolUse additionally needs a velesdb-memory binary on PATH that
knows the compile-stdin subcommand. Until you have one, the hook is
an identity transform — it detects the missing capability and passes every
tool result through untouched. The hook process still has a small local
latency cost even though it saves no paid-model tokens on that path.
Works with zero further setup (each project defaults to
project = basename(cwd), session = "rolling") — drop a
.velesdb-hooks.json (format below) in a project root only where you want a
deliberate project label instead of the directory name. The blocking
recall-before-edit guard is stricter: it stays disabled unless that file sets
enforce_learning_loop to true.
Per-project (vendor the scripts into one repo, e.g. to check them in for teammates):
mkdir -p .claude/hooks/velesdb-memory
cp /path/to/velesdb/integrations/agent-hooks/claude-code/hooks/*.sh .claude/hooks/velesdb-memory/
cp -r /path/to/velesdb/integrations/agent-hooks/claude-code/hooks/lib .claude/hooks/velesdb-memory/
chmod +x .claude/hooks/velesdb-memory/*.sh
Then merge claude-code/settings-snippet.json
(which uses $CLAUDE_PROJECT_DIR-relative paths — only correct when the
scripts are vendored inside that project) into the project's own
.claude/settings.json. Finally, drop a .velesdb-hooks.json at the
project root (format below).
⚠️ Pasting the per-project snippet's $CLAUDE_PROJECT_DIR-relative command
into ~/.claude/settings.json does not give you a global install by
itself — that path only resolves inside a project that also has its own
vendored copy of the scripts. Use the global pattern above instead.
Install — Windsurf
mkdir -p ~/.codeium/windsurf/hooks/velesdb-memory
cp /path/to/velesdb/integrations/agent-hooks/windsurf/hooks/pre-user-prompt.sh ~/.codeium/windsurf/hooks/velesdb-memory/
cp -r /path/to/velesdb/integrations/agent-hooks/windsurf/hooks/lib ~/.codeium/windsurf/hooks/velesdb-memory/
chmod +x ~/.codeium/windsurf/hooks/velesdb-memory/*.sh
Merge this into ~/.codeium/windsurf/hooks.json's "hooks" key:
{
"hooks": {
"pre_user_prompt": [
{ "command": "bash /Users/you/.codeium/windsurf/hooks/velesdb-memory/pre-user-prompt.sh", "show_output": true }
]
}
}
Same .velesdb-hooks.json config format as Claude Code (below) — the hook
walks up from the payload's cwd looking for one.
Only pre_user_prompt is wired here — Windsurf documents eleven other
events (see Parity across harnesses), but none is
a Stop/PreCompact equivalent and none is implemented yet. So
pre-user-prompt.sh folds BOTH halves of the loop into its single
first-of-session reminder: load working context now, and save it again
before the session ends — because no second reminder is wired.
trajectory_id from Windsurf's payload is the once-per-session sentinel key
(falls back to the parent PID if ever absent). The hook also reads cwd from
the payload and falls back to $PWD; cwd is not a documented common field
for pre_user_prompt, so in practice the fallback is what resolves
.velesdb-hooks.json.
⚠️ On Windsurf's documented contract this reminder is shown to you, not injected into the model's context — see the delivery caveat in the parity section before relying on it.
Install — Codex CLI
python3 scripts/sync-agent-hooks.py --install --client codex
The installer atomically copies the four scripts and reconciles only entries
whose command contains .codex/hooks/velesdb-memory/; foreign hooks remain
untouched. Codex requires review of changed non-managed hooks: open /hooks,
inspect these definitions, and trust them. The installer never bypasses that
security step. Use --client all to reconcile Claude and Codex together.
The equivalent manual snippet and config.toml form, the MCP server wiring,
and the full rationale for the remaining tool-output gap are in
codex/README.md.
Same .velesdb-hooks.json config format as Claude Code (below); the Codex
payload documents cwd as a common field, so the walk-up lookup works from
the payload rather than from a $PWD fallback.
The structural constraint that shapes this whole design
velesdb-memory's store is mono-process, guarded by an flock. While a
Claude Code session is running, its own velesdb-memory MCP server
process holds that lock for the whole session. A hook is a plain shell
command Claude Code shells out to — if that hook script tried to open the
same store itself (a second velesdb-memory invocation, or any direct
file access), it would block on, or fail to acquire, a lock already held
by the session's own server process. Two processes cannot both hold the
lock; a hook that tries becomes a second process.
So hooks in this directory never touch the store. They drive the model, not the store: each hook reads its JSON payload from stdin and prints a JSON instruction that tells the model — which already holds an MCP connection to the one server allowed to touch the store — to call a specific tool itself. The lock is never contended because there is only ever one process (the session's own MCP server) that ever opens the store.
This is why, for example, the SessionStart hook cannot pre-load context
and hand it to the model directly (that would require opening the store
from the hook) — it can only tell the model to call
load_working_context itself.
The one thing the constraint does not forbid. The lock guards the
store, and it is taken at store-open time only (Database::open, keyed by
the data directory). The deterministic context compiler is a different
animal: ContextCompiler::compile is pure — no store, no index, no
embeddings, no clock — and velesdb-memory's context feature is
persistence-free by design. So a hook can compile text in a separate
process, as long as that process never opens a store. That is precisely what
velesdb-memory compile-stdin does: it short-circuits in main before the
store open, the same way --version does. PostToolUse below is the one hook
that uses it. The three advisory hooks drive the model; PreToolUse can deny
an edit but does not compile content.
The five Claude Code hooks
(Windsurf's one wired hook, pre_user_prompt, is documented in its own
install section above — it folds the same load/save loop into one event.)
| Event | What it does | Mechanism |
|---|---|---|
SessionStart | Fires on every session start (new, resume, clear, or post-compact). Emits additionalContext telling the model to call load_working_context(project, session) as its first action if it hasn't already. | hookSpecificOutput.additionalContext — supported by SessionStart. |
Stop | Fires whenever Claude is about to stop responding, not only at final session exit. In an opted-in repository it blocks on the first Stop and after each later covered edit batch with the four-step checklist and save_working_context; each continuation passes. Without enforcement it retains the legacy first-Stop save reminder. | {"decision":"block","reason":"..."} plus repository-and-session first-Stop markers, a session-wide queue carrying every edited repository identity, and an atomic pending/delivered manifest that makes an interrupted checklist recoverable. |
PreCompact | Fires before the transcript is compacted (manual or auto-triggered). The first PreCompact per session is blocked with a reason telling the model to compile_transcript the about-to-be-compacted transcript (deterministic compression, not lossy compaction) and save_working_context first; later ones pass through. | Its own block-once-then-pass sentinel, separate from edit checkpoints. |
PreToolUse | In an opted-in project, refuses the first Edit/Write until the same host session has completed a successful VelesDB recall in that repository. | Exit 2 with an actionable reason; the refused target is queued so a successful recall remains bound to it even if the host cwd is elsewhere. |
PostToolUse | Marks a successful VelesDB recall for the edit guard. Separately, when a schema-valid Bash result exceeds the size threshold, compiles it through velesdb-memory compile-stdin and replaces the view only if fidelity and net-savings gates pass. | Success-gated sentinel plus shape-preserving hookSpecificOutput.updatedToolOutput for compression. |
Design note — the only hook that reduces the payload itself. The three
lifecycle continuations can only ask the model to call a tool; whether context
actually shrinks is the model's decision. PostToolUse is different: its output
schema replaces the tool result before it ever enters the transcript. A
300 KB Bash result compiled here is 300 KB that never gets re-sent on
every later turn — the compression is structural, not advisory.
Because it runs on every tool call and replaces content, its safety
rules are strict, and each is covered by test/hooks.test.sh:
- Nothing is deleted. The complete original Bash output object is
serialized as JSON under
$TMPDIR/velesdb-agent-hooks-$UID/tool-output/and its path is quoted in the replacement, so the agent canReadit back — the out-of-store equivalent of a retrieval handle. - Identity fallback everywhere. Missing
jq, missing binary, a binary too old to knowcompile-stdin, a compilation error, an empty compiled result — each emits{}and leaves the tool result exactly as it was. - Bounded. A velesdb-memory released before
compile-stdinignores the subcommand and starts the MCP server on the piped stdin. A pure-bash watchdog (notimeout, absent from stock macOS) bounds the call, and a cached capability probe tells old binaries from new ones without guessing versions. - Schema allowlist, not denylist. Only
Bashis enabled because Claude's{stdout, stderr, interrupted, isImage}output is documented and pinned by fixtures.GrepandWebFetchstay off until their schemas are verified;ReadandEditstay excluded because their value is the exact bytes. - Positive net gain.
risk: lowormediumis necessary, not sufficient: the compiler's gross saving must also cover every footer byte plus a configurable net margin. Otherwise the original passes through.
| Env var | Default | Meaning |
|---|---|---|
VELESDB_HOOK_COMPRESS_TOOLS | Bash | May disable Bash; naming another tool does not opt an unverified output schema into replacement. |
VELESDB_HOOK_MIN_BYTES | 12000 | Below this, pass through — compiling would cost more than it saves. |
VELESDB_HOOK_MIN_SAVED_TOKENS | 128 | Conservative minimum net saving after counting each footer byte as one token. |
VELESDB_HOOK_TOKEN_BUDGET | 2000 | Token budget handed to compile-stdin. |
VELESDB_HOOK_TOKEN_BUDGET_MAX | twice VELESDB_HOOK_TOKEN_BUDGET | Ceiling a risk: high compilation may retry at. Set it equal to the budget to forbid the retry. |
VELESDB_MEMORY_BIN | velesdb-memory on PATH | Binary to invoke. |
VELESDB_HOOK_PROBE_TIMEOUT | 10 | Seconds the capability probe may take. |
Fidelity. A compilation the compiler reports as risk: high is refused,
not shipped: high means at least one fragment it classifies as critical — a
code fence, a negative constraint, an exact value, a URL — did not survive
verbatim. The hook retries once at the ceiling first, because the budget is
usually what is too tight rather than the content being incompressible; a
268 KB cargo log measures high at 2 000 tokens and medium at 4 000. When
the ceiling does not rescue it — a 584 KB thread-stack sample stays high at
2 000, 4 000, 8 000 and 16 000 — the tool result is left byte-identical and the
reason goes to stderr.
The wire check is fail-closed too: only explicit risk: low and
risk: medium results may replace a tool result. A missing field, an unknown
enum value, or a value of the wrong JSON type leaves the original untouched.
Claude also ignores a replacement whose value does not match the built-in
tool's output shape. The hook therefore preserves the complete host-provided
Bash object and changes only stdout/stderr; the fixture follows the
current Claude hooks contract.
Archiving the original is not a substitute for this. On the compile-stdin
path the compiler runs with no store and no bridge, so the ctx://source/…
handles it mints resolve to nothing; the temp file is the only way back,
and a model that was never told to look will not look.
This gate lives where the compression does, so it is Claude Code only. Codex
cannot host the compression hook at all (see the parity table above), and there
the same discipline exists only as guidance in the velesdb-context-optimizer
skill.
Design note — why PreCompact blocks instead of using
additionalContext: the original plan for this feature assumed
PreCompact could carry additionalContext like SessionStart/Stop.
Checking the actual hook output schema shows it cannot —
PreCompact's output only supports the top-level decision + reason
pair (no hookSpecificOutput wrapper at all for this event). Blocking
once per session with reason is the only channel that reaches the
model, so that's what's implemented; blocking every PreCompact was
rejected as unsafe (auto-compaction can retrigger repeatedly on a long
session, and refusing it every time risks the transcript never
compacting).
.velesdb-hooks.json config format
Place at your project root. Lifecycle hooks walk up from the payload's cwd;
the edit guard instead walks from Claude's file_path or every Codex patch
target (Add, Update, Delete, and Move), falling back to cwd only when
the documented target is absent. Every lookup is bounded to 20 directories:
{
"project": "my-project",
"session": "rolling",
"enforce_learning_loop": true
}
project— a stable label for this codebase/product. Defaults tobasename(cwd)if the file or field is missing.session— a stable slot id, not a fresh id per run. Defaults to"rolling". Using a stable id (rather than each hook's ownsession_id) is deliberate: it makesload_working_context/save_working_contextaccumulate one continuously-updated state across every Claude Code session on this project, instead of fragmenting into one throwaway slot per session that nothing else ever reads back.enforce_learning_loop— explicit opt-in for recall-before-edit and the four-step Stop checklist. Defaults tofalse; missing or malformed config must never block an unrelated repository.
When the target repository differs from cwd, the refused edit records that
repository as pending. The next successful supported VelesDB recall in the
same host session promotes it only when the target is unambiguous: the recall
runs from that root, explicitly filters that project, or it is the sole pending
target from an unconfigured cwd. A multi-repository patch may therefore need
one refused-attempt/recall cycle per unseen repository. After the edit passes,
independent per-repository records let concurrent hooks feed a session-wide
Stop checkpoint without losing an identity.
Policy discovery canonicalizes the nearest existing parent directory. A final symlink that crosses an opted-in repository boundary is refused even after recall; invoke the edit through its physical path instead.
All three fields are optional. With no config file, load/save and compiler
guidance still work (defaulting project to the directory name and session
to "rolling"), but recall-before-edit remains disabled. This keeps a global
hook install from blocking unrelated repositories.
Testing
bash test/hooks.test.sh
Simulates the stdin payload each harness documents for each event and asserts
the exact JSON the script prints back (including edit-triggered Stop
checkpoints, PreCompact's block-once/pass-after behaviour, and the Codex
source == "compact" branch). It also shellchecks every script and rejects
hardcoded home paths.
Run it after touching any script in claude-code/hooks/, windsurf/hooks/
or codex/hooks/.
What it cannot do: prove every harness version sends those fields. The Claude Code assertions are backed by hooks run for real; Codex MCP and shell payloads were measured on the version recorded above; Windsurf remains contract-only.
Why PreCompact only nudges compile_transcript, never calls it directly
Given the mono-process flock constraint above, PreCompact cannot call
compile_transcript itself — only the model's own MCP connection can. The
hook's reason tells the model to call it with the transcript about to be
compacted, trading lossy compaction for a deterministic, auditable one.