Post-tool hooks for agents a proxy cannot reach
Some agents let you set a base URL and some do not. A post-tool hook needs none: the client hands distil a tool result the moment it is produced, and uses what distil hands back instead. Same digest as the proxy, the original kept on this machine, and nothing touches your credentials.
$ distil setup --hooks # every client found on this machine $ distil hook install --client gemini # or one: claude | cursor | gemini | codex | all $ distil hook status $ distil hook --selftest # the adapters, proven offline
Which tier runs: the proxy's billing policy
- Subscription (a Claude Pro/Max login, detected exactly as the proxy detects it;
DISTIL_SUBSCRIPTIONoverrides): lossless-only by default. Opt in to the digest explicitly withdistil hook install --digestordistil setup --hooks --digest. The opt-in is written into the hook's own command and recorded with a timestamp; re-installing without the flag returns to the default. - Metered API key: the digest runs by default, as on the proxy.
- Hooks installed before this release keep the lossless-only behaviour they were installed with, whatever the billing, until you re-install.
distil hook status shows, per client, which tier is active and why.
What the hook does
Tier-0 first (JSON minified, exact repeats collapsed), then — when the tier above allows it — for verbose line output the same decision-aware Tier-1 digest the proxy uses: head, tail, every error and verdict line kept, the quiet middle folded behind a handle. A digest is emitted only after the full original is written to distil's encrypted restore store and read back byte-exact; the stub ends with the command that recovers it:
<< +412 lines, handle=3f9a1c2e >> === 12 passed in 3.2s === << full output: `distil expand 3f9a1c2e` >>
The agent can run that in its own shell, or call distil_expand if the distil MCP server is connected. Output under 2 KB is left alone, and anything that would not come out smaller in tokens is left alone.
What it never touches
- Results the agent must quote back. File reads (
Read,read_file,grep, …, including as the tail of an MCP tool name) and shell whole-file reads (cat app.py,sed -n '1,80p' app.py): anEditlifted from a digest would not apply. Same provenance rule as the proxy. distil_expandresults, which would otherwise fold straight back into the stub they escaped.- Failures. A Claude Code command with stderr or an interrupt, a Gemini result carrying
error, an MCP result flaggedisError. - Spans you mark. Anything between
<distil:keep>and</distil:keep>passes through byte-exact, tags included — on every proxy path too.
Per client
Each contract was read from the client's own documentation on the date shown. A client whose hook cannot replace a result is listed as unsupported, not approximated.
| Client | Event → how the result is replaced | What is compressed | Config written | Source |
|---|---|---|---|---|
| Claude Code | PostToolUse → hookSpecificOutput.updatedToolOutput | Bash, MCP tools | ~/.claude/settings.json | docs (2026-09-25) |
| Cursor | postToolUse → updated_mcp_tool_output | MCP tools only: the docs scope the override to MCP, and afterShellExecution is observe-only | ~/.cursor/hooks.json | docs (2026-09-25) |
| Gemini CLI | AfterTool → decision: "deny" + reason, which "replaces the tool result sent back to the model" | run_shell_command, MCP tools | ~/.gemini/settings.json | docs (2026-09-25) |
| Codex CLI | PostToolUse → decision: "block" + reason: Codex "replaces the tool result with that feedback" | Bash, all-text MCP results | $CODEX_HOME/hooks.json (default ~/.codex) | docs (2026-09-25) |
| Windsurf | Unsupported. post_run_command and post_mcp_tool_use are observe-only: only pre-hooks can act (exit code 2 blocks), no output field replaces a result, and post_run_command does not receive the command's output. | docs (2026-09-25) | ||
/hooks in Codex once after installing. (Codex documents updatedMCPToolOutput as parsed but unsupported and marks the hook failed, so distil never emits it.)
How the config is written
Atomically: a 0600 temp file, fsynced, renamed over the original with its mode and owner kept; a symlinked config is written through, not replaced. Unreadable JSON, or a hooks entry in a shape distil does not recognise, is refused rather than overwritten. What distil created — the file, the hooks object, the event list — is recorded only after the write succeeds, and uninstall removes only distil's entry and the containers distil itself created. Your other hooks are never touched.
What it saved
distil hook --stats reads the hook's content-free receipts (client, tool, characters before and after). --selftest writes none.