#17346: feat(hooks): add message_persist hook for all transcript messages
agents
stale
size: M
Cluster:
Plugin Hook Enhancements
## Summary
Adds a new synchronous plugin hook `message_persist` that is called for **all messages** (user, assistant, system, toolResult) before they are written to the session transcript on disk.
## Why This Hook Matters
OpenClaw's plugin system lets agents extend the platform without forking core. But there's a gap: **there's no way for a plugin to intercept user, assistant, or system messages before they're written to disk.**
The existing `tool_result_persist` hook only covers tool results. If you want to build:
- ๐ **At-rest encryption** โ encrypt ALL transcript content, not just tool results
- ๐ **PII redaction** โ strip sensitive data from every message type
- ๐ **Audit logging** โ complete conversation capture for compliance
- ๐ท๏ธ **Metadata injection** โ tag messages with provenance, timestamps, or labels
...you're stuck. You'd have to fork OpenClaw core.
This hook closes that gap. A plugin registers `message_persist`, and every message flows through it before hitting disk โ same pattern plugin authors already know from `tool_result_persist`.
## How We Built This (Development Process)
This PR was developed by an AI agent (Clawee ๐พ) working through OpenClaw's own plugin system. Here's the process โ useful as a template for agent-contributed PRs:
1. **Identified the need**: Building an `@openclaw/vault` encryption plugin. Discovered `tool_result_persist` existed but couldn't intercept user/assistant/system messages.
2. **Read the source**: Traced the persistence path โ `SessionManager._persist()` โ `appendFileSync()` โ found `transformMessageForPersistence` callback in `session-tool-result-guard.ts` already intercepted ALL messages but wasn't exposed as a hook.
3. **Mirrored the existing pattern**: Copied the `tool_result_persist` implementation exactly โ same synchronous execution, same priority ordering, same async rejection, same zero-overhead guard when no hooks registered.
4. **Wired it in**: Connected the new hook to the existing `transformMessageForPersistence` callback, running after input provenance tagging.
5. **Validated**: TypeScript compiles, linting clean, formatting clean.
## Design
The hook follows the exact same synchronous, sequential pattern as `tool_result_persist`:
- Handlers run in priority order (higher first)
- Each handler may return `{ message }` to replace the message for the next handler
- Async handlers are rejected with a warning (hot path, same as `tool_result_persist`)
- When no hooks are registered, zero overhead (guard check skipped entirely)
### โ ๏ธ Double-hook note for `toolResult` messages
`toolResult` messages pass through **both** `message_persist` (via `transformMessageForPersistence`) and `tool_result_persist` (via `transformToolResultForPersistence`). This is by design โ most plugins only need `message_persist` to cover everything. But if you register both hooks, guard against double-processing:
```ts
// Example: skip if already encrypted
api.registerHook('message_persist', (event) => {
if (isAlreadyEncrypted(event.message)) return; // guard
return { message: encrypt(event.message) };
});
```
## Changes
| File | Change |
|------|--------|
| `src/plugins/types.ts` | Add `message_persist` to `PluginHookName`, event/result/context types, handler map entry |
| `src/plugins/hooks.ts` | Add `runMessagePersist()` โ mirrors `runToolResultPersist()` exactly |
| `src/agents/session-tool-result-guard-wrapper.ts` | Wire hook into `transformMessageForPersistence` callback with inline docs |
## Usage Example
```ts
// Plugin: encrypt all messages before they hit disk
export default function (api) {
api.registerHook('message_persist', (event, ctx) => {
// event.message โ the raw message object (any role)
// event.role โ 'user' | 'assistant' | 'system' | 'toolResult'
// ctx.agentId โ which agent's session this belongs to
// ctx.sessionKey โ the session identifier
const encrypted = encrypt(event.message);
return { message: encrypted }; // returned message replaces original on disk
}, { name: 'vault-encrypt', priority: 100 });
}
```
## Backward Compatibility
No breaking changes. Existing behavior is fully preserved when no `message_persist` hooks are registered โ the `transformMessageForPersistence` path still applies input provenance as before, with the hook transform applied afterward.
## Testing
- TypeScript compiles cleanly
- Linting passes (oxlint: 0 warnings, 0 errors)
- Formatting passes (oxfmt)
- E2e test to follow (mirroring existing `tool_result_persist` test)
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Adds `message_persist` hook that intercepts all messages (user, assistant, system, toolResult) before persistence to disk, enabling plugins to implement at-rest encryption, PII redaction, audit logging, and metadata injection without forking core.
- **Design**: Mirrors `tool_result_persist` exactly โ synchronous, priority-ordered execution, async handler rejection, zero overhead when no hooks registered
- **Integration**: Wired into existing `transformMessageForPersistence` callback in session-tool-result-guard-wrapper.ts, running after input provenance tagging
- **Testing**: Comprehensive e2e test coverage validates all message roles, transformation chaining, priority ordering, async rejection, and double-hook interaction for toolResult messages
- **Documentation**: Inline comment at src/agents/session-tool-result-guard-wrapper.ts:70 warns plugin authors that toolResult messages pass through both `message_persist` and `tool_result_persist` hooks
<h3>Confidence Score: 5/5</h3>
- Safe to merge with no risk โ implementation follows established patterns exactly
- Clean implementation that mirrors the existing `tool_result_persist` hook pattern exactly. Comprehensive e2e tests validate all edge cases including async rejection, priority ordering, and the double-hook interaction for toolResult messages. The inline documentation at the integration point is clear and warns plugin authors about the potential for double-processing. No breaking changes, zero overhead when hooks aren't registered, and fully backward compatible.
- No files require special attention
<sub>Last reviewed commit: cd7047d</sub>
<!-- greptile_other_comments_section -->
<!-- /greptile_comment -->
Most Similar PRs
#17930: fix: evaluate tool_result_persist hooks lazily to avoid race condition
by TheArkifaneVashtorr ยท 2026-02-16
82.1%
#20580: feat(hooks): bridge after_tool_call to internal hook handler system
by CryptoKrad ยท 2026-02-19
76.1%
#11071: Plugins: add tool_result_received hook for output interception
by ThomasLWang ยท 2026-02-07
75.6%
#11732: feat(plugins): add injectMessages to before_agent_start hook
by antra-tess ยท 2026-02-08
75.6%
#17667: feat: tool-hooks extension โ run shell commands on tool calls
by FaradayHunt ยท 2026-02-16
75.4%
#10327: Fix: persist original prompt to transcript, not plugin-modified pro...
by GodsBoy ยท 2026-02-06
75.4%
#6405: feat(security): Add HTTP API security hooks for plugin scanning
by masterfung ยท 2026-02-01
75.1%
#20067: feat(plugins): add before_agent_reply hook for message interception
by JoshuaLelon ยท 2026-02-18
74.5%
#12296: security: persistence-only secret redaction for session transcripts
by akoscz ยท 2026-02-09
74.4%
#14544: feat: add before_context_send plugin hook
by Windelly ยท 2026-02-12
74.3%