Aletheia

Aletheia hooks reference: events, payloads, redaction and fail-open behavior

In Aletheia (aletheia-mem), a hook is a command that a host, the AI coding tool, runs at a session event. The command is aletheia hook <tool> <event>. It reads one JSON payload on stdin, stores a redacted memory, and never blocks the host.

The examples on this page were run on 2026-10-29 against version 0.1.10 in a temporary home and data directory. Paths are shortened with .... The payloads are written by hand from the fields each adapter reads. The repository holds no recorded payload files; its tests write payloads inline.

What a hook does

aletheia hook claude-code PostToolUse does these steps, in order. Source: src/hub/hooks/run.mjs.

  1. Read stdin. Empty or broken JSON becomes an empty payload.
  2. Pick the adapter for the host. An unknown host name returns at once.
  3. Work out the project from the payload's working directory, without starting git.
  4. Skip the item if the project is in ALETHEIA_EXCLUDED_PROJECTS. Nothing is written.
  5. Build the memory from the payload and redact it. This step touches no database.
  6. Open hub.db with a 50 ms busy timeout, drain the spool, then store the memory, queue compression or mark the session ended.
  7. For a session-start event only, print the context block on stdout.
  8. Exit 0.

A hook makes no network call and no model call. Compression only queues a job. A worker started by aletheia-server or aletheia compress runs it later.

Verification status

Payload field names are the ones the adapter source cites, from host documentation the project author fetched on 2026-10-26. I did not fetch any host documentation on 2026-10-29. UNVERIFIED: that Cursor, Gemini CLI and Windsurf send exactly these fields, because no hook ran in them. A real hook run or a fresh read of each host's hooks page would verify it. See Tools overview.

Events per host

aletheia install --list prints the events each installer wires (the note lines are left out):

tool          capability   verification     detected  events
claude-code   full         mcp-verified      no        SessionStart, UserPromptSubmit, PostToolUse, Stop, SessionEnd
command-code  full         mcp-verified      no        SessionStart, PostToolUse, Stop
cursor        full         from-docs-only    no        sessionStart, beforeSubmitPrompt, postToolUse, afterAgentResponse, stop, sessionEnd
codex         full         mcp-verified      no        SessionStart, UserPromptSubmit, PostToolUse, Stop, SessionEnd
gemini-cli    full         from-docs-only    no        SessionStart, BeforeAgent, AfterTool, AfterAgent, SessionEnd
windsurf      full         from-docs-only    no        pre_user_prompt, post_run_command, post_write_code, post_cascade_response
opencode      mcp+rules    mcp-verified      no        none

In this column mcp-verified means only the line about the real CLI listing the MCP server above. from-docs-only means config written from docs, not tested on a real install. detected is no here because the temporary home has no tool folders.

OpenCode and Claude Desktop have no hooks. For OpenCode the installer writes the MCP entry and the rules block only. Claude Desktop has an MCP entry only; see Claude Desktop.

What each event captures

Every row is from the adapter in src/hub/hooks/. "Summary" is a memory of kind summary; "queues compression" adds a job for the extraction worker; "ends the session" records a session-end row.

HostEventWhat Aletheia doesPayload fields read
claude-codeSessionStartPrints context. Prints nothing when source is resume.source
claude-codeUserPromptSubmitStores the prompt.prompt
claude-codePostToolUseStores a tool event.tool_name, tool_input, tool_response
claude-codeStop, SessionEndStores a summary, queues compression, ends the session.last_assistant_message
command-codeSessionStartPrints context. Nothing on resume.source
command-codePostToolUseStores a tool event.tool_name, tool_display_name, tool_input, tool_response
command-codeStopQueues compression, ends the session. Stores no memory.none
codexSessionStartPrints context. Nothing on resume.source
codexUserPromptSubmitStores the prompt.prompt
codexPostToolUseStores a tool event.tool_name, tool_input, tool_response
codexStopStores a summary, queues compression, ends the session.last_assistant_message
codexSessionEndQueues compression, ends the session. No memory.none
cursorsessionStartPrints context as {"additional_context": ...}.workspace_roots
cursorbeforeSubmitPromptStores the prompt.prompt
cursorpostToolUseStores a tool event.tool_name, tool_input, tool_output
cursorafterAgentResponseStores a summary.text
cursorstop, sessionEndQueues compression, ends the session.none
gemini-cliSessionStartPrints context. Nothing on resume.source
gemini-cliBeforeAgentStores the prompt.prompt
gemini-cliAfterToolStores a tool event.tool_name, tool_input, tool_response
gemini-cliAfterAgentStores a summary, queues compression.prompt_response
gemini-cliSessionEndQueues compression, ends the session.none
windsurfpre_user_promptStores the prompt.tool_info.user_prompt
windsurfpost_run_commandStores a Shell event.tool_info.command_line
windsurfpost_write_codeStores a Write event.tool_info.file_path, tool_info.edits
windsurfpost_cascade_responseStores a summary, queues compression.tool_info.response

Command Code has no UserPromptSubmit or SessionEnd hook, so prompts and end-of-session summaries are not captured there. Windsurf has no session-start event, so it receives no context. Cursor's stop payload carries no assistant text, so afterAgentResponse stores the summary.

Shared fields:

The hook command and its timeouts

aletheia install <tool> --write adds this command to the host's config. The trailing comment is the marker the uninstaller looks for:

.../.bun/bin/bun .../data/aletheia.bundle.mjs hook claude-code PostToolUse # aletheia-managed

The installer builds aletheia.bundle.mjs in the data directory so a hook loads one file, not a dozen. If the build fails it falls back to the source CLI. The timeout the installer writes into each host's config:

Hosttimeout writtenMatcher written
claude-code10SessionStart `startup\clear\compact; PostToolUse *`
command-code10none
codex10, SessionEnd 3SessionStart `startup\clear\compact\fork`
cursor10none
gemini-cli10000none
windsurfnonenone

The code notes that Gemini CLI counts milliseconds. UNVERIFIED: the unit the other hosts use for their timeout (seconds is likely). Verify it in each host's hooks documentation.

Aletheia also has its own limit. bin/aletheia.mjs starts a 150 ms watchdog and exits 0 when it fires. A PostToolUse hook took 0.04 to 0.05 s on a small database. One that waited on a held write lock took about 0.1 s.

Fail-open behavior

A hook prints nothing on stdout and exits 0 on every error. A broken hook never stops a session.

$ echo '{}' | aletheia hook nosuch Foo          # unknown host: no output, exit 0
$ echo 'not json' | aletheia hook claude-code PostToolUse   # exit 0
$ aletheia hook claude-code Stop < /dev/null    # exit 0

A real run with hub.db replaced by a directory, then repaired:

$ echo '{"hook_event_name":"PostToolUse","cwd":".../shop","session_id":"demo-3","tool_name":"Bash","tool_input":{"command":"ls -la"},"tool_response":"ok"}' | aletheia hook claude-code PostToolUse
$ cat .../data2/spool/hook-spool.jsonl
{"kind":"memory","memory":{"project":"shop","tool":"claude-code","sessionId":"demo-3","kind":"event","type":"event","title":"Bash: ls -la","narrative":"input: {\"command\":\"ls -la\"}\n\noutput: ok"},"at":1793303306263}
$ aletheia doctor | grep spool
FAIL spool                1 item(s) at .../data2/spool/hook-spool.jsonl
$ rmdir .../data2/hub.db
$ echo '{... "command":"pwd" ...}' | aletheia hook claude-code PostToolUse
$ aletheia search "ls -la" --project shop
#1 2026-10-29 [event] Bash: ls -la (shop, claude-code)
$ aletheia doctor | grep spool
ok   spool                empty

A second run held the write lock from another process with BEGIN IMMEDIATE. The hook spooled its item, and the next hook stored both.

Redaction and limits

The adapter redacts each field before it is spooled or stored. <private>...</private> blocks become [private]. Patterns replace API keys (Anthropic, OpenAI-style, Stripe), GitHub, GitLab, npm and Slack tokens, JWTs, AWS key ids, Google keys, private key blocks, bearer headers, passwords in URLs, and password, secret, token or api_key assignments. The list is in src/hub/redact.mjs. See Privacy and redaction.

$ echo '{"hook_event_name":"UserPromptSubmit","cwd":".../shop","session_id":"p1","prompt":"Rotate the key <private>hunter2-very-secret</private> before Friday, token=abcd1234efgh"}' | aletheia hook claude-code UserPromptSubmit
$ aletheia search "Rotate the key"
#20 2026-10-29 [note] Rotate the key [private] before Friday, token=[redacted] (shop, claude-code)

Stored sizes: a prompt keeps 8000 characters, a tool event 1500 (input: then output:), a summary 4000. Titles are cut to 120, 160 and 140 characters.

Test a hook by hand

Pipe a payload into the command the installer wrote, or into aletheia hook directly. Use a temporary data directory so your own memory stays untouched.

$ export ALETHEIA_DATA_DIR=$(mktemp -d)
$ echo '{"hook_event_name":"SessionStart","cwd":".../shop","session_id":"demo-1","source":"startup"}' | aletheia hook claude-code SessionStart
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"<aletheia-memory>\nRecalled memory from earlier sessions. ... # Memory for shop\n\n## Pinned\n- #1 19:46 [D] Use pnpm, not npm, in the shop repo ... </aletheia-memory>"}}
$ echo '{"hook_event_name":"Stop","cwd":".../shop","session_id":"demo-1","last_assistant_message":"Fixed the failing test and switched the repo to pnpm."}' | aletheia hook claude-code Stop
$ aletheia search "failing test" --project shop
#5 2026-10-29 [summary] Session summary: Fixed the failing test and switched the repo to pnpm. (shop, claude-code)

The same check ran for Cursor, Gemini CLI, Windsurf, Command Code and Codex with hand-written payloads, and each stored the expected row. That tests the adapter code. It does not show that those hosts send these fields. Payload shapes for every host are in test/e2e-more-hosts.test.mjs.

To check a whole session in a data directory, run bun scripts/hook-check.mjs <data-dir> --tool claude-code from a source checkout. It reads hub.db without writing, scans for secrets that escaped redaction, and prints PASS or FAIL per event. It supports claude-code, codex and command-code.

Check hooks with aletheia doctor

aletheia doctor reads the host config files and counts the expected hook commands in each. It does not run a hook. A tool that is not installed is not a failure. A present config with missing hooks is.

$ aletheia doctor
ok   host                 127.0.0.1 (loopback)
ok   port                 37913
FAIL server               not reachable at http://127.0.0.1:37913/healthz
ok   hooks claude-code    5/5 in .../home/.claude/settings.json
ok   hooks cursor         6/6 in .../home/.cursor/hooks.json
ok   tool opencode        mcp+rules; no hook system shipped
ok   verify cursor        verified: verified 2026-10-26 — Cursor docs fetched 2026-10-26; Cursor is not installed on this machine
ok   spool                empty
ok   permissions          data dir 0700, db 0600
ok   last capture         no capture yet
ok   queue                0 pending, 0 running, 0 failed

Verify, uninstall and the manifest

aletheia install <tool> --write records each file it changed, with a SHA-256 hash and a backup name, in <data>/install-manifest.json. Every changed file also gets a copy named <file>.aletheia-bak-<time>.

$ aletheia install codex --verify
  ok        mcp      .../home/.codex/config.toml
  drift     hooks    .../home/.codex/hooks.json
  ok        rules    .../home/.codex/AGENTS.md
codex: drifted from the recorded install

--verify never writes. It exits 1 when a tool is not installed, or when any file is missing, drift or unreadable. With no tool name it checks every tool in the manifest.

aletheia uninstall <tool> is a dry run until --write. It removes the MCP entry, the hook entries marked aletheia-managed and the managed rules block, and keeps every other entry in each file.

$ aletheia uninstall codex --write
Codex CLI (codex)
  remove mcp      .../home/.codex/config.toml
  BLOCK  hooks    .../home/.codex/hooks.json  (file changed since install; rerun with --force)
  remove rules    .../home/.codex/AGENTS.md
Refused: a file changed after install. Review it, then rerun with --force to remove our entries anyway.

Two behaviors of 0.1.10 to know. First, when one file has drifted, the command exits 1 and prints Refused, but it has already cleaned the other files: the MCP entry and the rules block were gone afterward, and only the hooks file kept its entries. Second, before this fix the hooks line of an uninstall could carry the note (no aletheia hooks) even when the line said remove; the entries were removed. It now shows the note only when there is nothing to remove.

Next steps