Skip to content
github-actions[bot] edited this page Oct 12, 2026 · 1 revision

Hooks: record a coding agent's tool calls from its hook system

Claude Code (and other coding agents) can run a command before and after every tool call, with the event as JSON on stdin. witness-log-hook turns that event into a leaf. Because a hook is a fresh process each time, and a journal admits one writer, the hook does not open the journal itself: it sends the event to witness-log-hookd, a small daemon that holds the journal open behind a Unix socket. Both ship in the witness-log Python package (stdlib only; Unix sockets, so not Windows).

pip install witness-log
witness-log-hook install claude-code --journal ~/.witness-log/claude-code.jsonl

prints (and does not write) the entries for .claude/settings.json:

{"hooks": {
  "PreToolUse":  [{"matcher": "", "hooks": [{"type": "command", "command": "witness-log-hook --autostart --journal ~/.witness-log/claude-code.jsonl"}]}],
  "PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "witness-log-hook --autostart --journal ~/.witness-log/claude-code.jsonl"}]}]}}

--autostart starts the daemon on the first call if none is listening. From then on every tool call is an intent leaf (PreToolUse) and a result leaf (PostToolUse), fsynced before the hook returns.

What is recorded

Claude Code's event (session_id, transcript_path, cwd, hook_event_name, tool_name, tool_input, plus tool_response on PostToolUse and tool_use_id where the version provides it) becomes:

PreToolUse  -> intent leaf:  canonical_json({"profile": "hook.claude-code v1", "event": "PreToolUse",
                                              "session_id", "tool_name", "tool_input", ["tool_use_id"], ["cwd"]})
PostToolUse -> result leaf:  the same plus "tool_response"
other event -> memory leaf:  the same plus every other field of the event

transcript_path is never recorded. The generic shape {"tool": ..., "args": ..., "result": ...} becomes {"profile": "hook.generic v1", "tool", "args", ["result"]} (intent without result, result with it; "kind" in the event or --kind overrides). Events that carry tool_name/tool_input (and tool_response or output) without a hook_event_name are read the generic way. Anything else is recorded whole as a memory leaf under {"profile": "hook.generic v1", "event": ...}, so an unexpected shape is still on the record.

Payloads are RFC 8785 canonical JSON, salted per leaf: log.forget(i) erases one, log.disclose(i) reveals one to an auditor, and the daemon's export writes a dossier anyone can verify with witness-log verify.

Failure policy

The hook exits 0 whatever happens (daemon missing, socket refused, malformed stdin), writing the reason to stderr, so a problem with the audit trail never blocks the agent. With --strict it exits 2 instead; Claude Code then blocks the tool call and shows the model the reason. Use --strict where an unrecorded action is worse than a stopped one.

The daemon

witness-log-hookd --journal ~/.witness-log/claude-code.jsonl                 # foreground
witness-log-hookd --journal agent.jsonl --socket /run/user/1000/wl.sock \
    --directory trusted.json --submit-every 50                              # witness every 50 appends
  • Socket: --socket, default $XDG_RUNTIME_DIR/witness-log-hookd.sock or <tmpdir>/witness-log-hookd-<uid>.sock; created 0600. A stale socket file is replaced; a live one makes the second daemon refuse.
  • Journal: witness_log.open rules. WITNESS_LOG_IDENTITY_SEED_ENV in the daemon's environment seals it (the hook passes nothing about keys).
  • Protocol, one JSON object per line: {"op": "append", "leaf": "intent"|"result"|"memory", "payload": <JSON>} -> {"ok", "index", "tree_size", "root"}; {"op": "status"}; {"op": "export"} -> {"path", "sha256", "bytes"} (<journal>.dossier.json); {"op": "submit"} (needs --directory); {"op": "sync"}; {"op": "shutdown"}. witness_log.hook.request(socket, req) is a client for scripts.
  • Witnessing: with --directory, every --submit-every appends and on shutdown.

Cursor and Codex

witness-log-hook install cursor and install codex print templates. The command is the same; what differs is how each host names its events and where its hook configuration lives, and that changes between versions, so check the printed event names against your host's hooks documentation before relying on them. Whatever JSON the host delivers is recorded: with tool_name/tool_input-like fields under the generic profile, otherwise whole as a memory leaf. Pass --kind intent or --kind result per hook when the host does not distinguish before from after.

For a host whose tools are MCP servers, the MCP proxy records every call at the protocol boundary and needs no hook support at all.

Clone this wiki locally