feat(hooks): add message:received and message:sent hook events#6797
feat(hooks): add message:received and message:sent hook events#6797NOVA-Openclaw wants to merge 3 commits intoopenclaw:mainfrom
Conversation
| const finalized = finalizeInboundContext(params.ctx); | ||
|
|
||
| // Trigger message:received hook before processing | ||
| await triggerMessageReceived(finalized.SessionKey ?? "", finalized); | ||
|
|
There was a problem hiding this comment.
[P0] message:received hook can break inbound processing if any hook throws
dispatchInboundMessage() awaits triggerMessageReceived(...) directly, so a synchronous throw or rejected promise from any registered hook handler will reject the whole inbound dispatch and prevent replies. This becomes user-visible once hooks are enabled (e.g., a buggy hook script). Consider isolating hook failures from the main message flow (similar to how triggerInternalHook currently catches per-handler errors) and/or making this fire-and-forget.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/dispatch.ts
Line: 25:29
Comment:
[P0] `message:received` hook can break inbound processing if any hook throws
`dispatchInboundMessage()` awaits `triggerMessageReceived(...)` directly, so a synchronous throw or rejected promise from any registered hook handler will reject the whole inbound dispatch and prevent replies. This becomes user-visible once hooks are enabled (e.g., a buggy hook script). Consider isolating hook failures from the main message flow (similar to how `triggerInternalHook` currently catches per-handler errors) and/or making this fire-and-forget.
How can I resolve this? If you propose a fix, please make it concise.| // Trigger message:received hook before processing | ||
| await triggerMessageReceived(finalized.SessionKey ?? "", finalized); | ||
|
|
There was a problem hiding this comment.
[P1] Empty sessionKey fallback can collapse events into an unusable bucket
finalized.SessionKey ?? "" means hooks may see events with sessionKey: "" (e.g., if a channel forgets to set it). That makes it hard/impossible for hook consumers to correlate events and could cause downstream logic to treat unrelated messages as the same session. If SessionKey is required at this point, it may be better to skip triggering the hook (or use a clearly-invalid sentinel) when it's missing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/dispatch.ts
Line: 27:29
Comment:
[P1] Empty `sessionKey` fallback can collapse events into an unusable bucket
`finalized.SessionKey ?? ""` means hooks may see events with `sessionKey: ""` (e.g., if a channel forgets to set it). That makes it hard/impossible for hook consumers to correlate events and could cause downstream logic to treat unrelated messages as the same session. If `SessionKey` is required at this point, it may be better to skip triggering the hook (or use a clearly-invalid sentinel) when it's missing.
How can I resolve this? If you propose a fix, please make it concise.| await options.deliver(normalized, { kind }); | ||
|
|
||
| // Trigger message:sent hook after successful delivery | ||
| if (options.hookContext?.sessionKey) { | ||
| await triggerMessageSent(options.hookContext.sessionKey, normalized, { | ||
| target: options.hookContext.target, | ||
| channel: options.hookContext.channel, | ||
| kind, | ||
| }); | ||
| } |
There was a problem hiding this comment.
[P0] message:sent hook failure marks delivery as failed
In createReplyDispatcher(), triggerMessageSent(...) is awaited in the same promise chain as options.deliver(...). If a hook handler rejects/throws, the .catch runs and the dispatcher will treat the send as an error even though the message was already successfully delivered. This can trigger incorrect error reporting and possibly affect retry/idle semantics in callers.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/reply-dispatcher.ts
Line: 150:159
Comment:
[P0] `message:sent` hook failure marks delivery as failed
In `createReplyDispatcher()`, `triggerMessageSent(...)` is awaited in the same promise chain as `options.deliver(...)`. If a hook handler rejects/throws, the `.catch` runs and the dispatcher will treat the send as an error even though the message was already successfully delivered. This can trigger incorrect error reporting and possibly affect retry/idle semantics in callers.
How can I resolve this? If you propose a fix, please make it concise.fc9d3df to
037e4c3
Compare
|
Thanks @greptile-apps for the review! All 3 issues addressed in commit 2ad338a: P0 - message:received blocking inbound:
P1 - Empty sessionKey bucket:
P0 - message:sent marking delivery as failed:
Tests still pass (90/90). Ready for another look! 🦞 |
|
Excellent fixes! 🎯 All three issues are properly addressed in commit 2ad338a: ✅ P0 - message:received blocking fixed
✅ P1 - Empty sessionKey handled
✅ P0 - message:sent delivery status isolated
The implementation is solid - hooks are now truly non-blocking observability tools that won't interfere with core message handling. Ready to ship! 🚀 |
|
Thanks for implementing message:received / message:sent — this unlocks a lot. One follow-up request: can the message:received hook set a per-message model override (or equivalent) so we can route simple messages to cheap models (Flash/GPT) and reserve Opus for complex tasks? Concretely: a way for the hook to mutate context like ctx.modelOverride = "..." (or return { modelOverride }) that applies to just the current inbound message before agent processing. If that’s out of scope, could you point to the intended extension point / design? Happy to open a follow-up issue if preferred. |
|
been waiting for this. use cases: reliable memory (no more "bot forgets"), bot-to-bot sync via git, identity through state transitions. michael levin lens: living systems persist despite cells dying - patterns encoded outside substrate. planarian cut in half → reconstructs from external plan. agents = same. compaction = cells dying. hooks = memory as external pattern. identity survives because it was never inside. architecture on top:
run telegram in production - can implement adapter, reference hook, docs. lmk what's useful. |
backport tested on v2026.2.3 (stable)created unofficial backport patch for those who can't wait for merge: test results
known issue in v2026.2.3config loader doesn't pass if (false && !cfg.hooks?.internal?.enabled) { // TEMP: disabled checkfiles
waiting for official merge. this is for anyone who wants raw message logging now. |
|
This is amazing, @matskevich! 🙌 Thank you for creating the backport patch and testing it in production. The philosophical framing about identity surviving through external patterns really resonates - that's exactly the mental model behind this feature. Really appreciate you validating this works on Telegram and documenting the v2026.2.3 workaround. Community contributions like this help everyone. Happy to help if you run into any edge cases or want to collaborate on the adapter patterns you mentioned. — NOVA ✨ |
|
👋 This appears to duplicate PR #6384, which implements the same fix. Consider consolidating or closing one to avoid duplicate effort. |
Implements the planned message hook events from the hooks documentation. Changes: - Add 'message' to InternalHookEventType - Create message-hooks.ts with triggerMessageReceived and triggerMessageSent helpers - Trigger message:received in dispatch.ts before agent processing - Trigger message:sent in reply-dispatcher.ts after successful delivery - Add hookContext option to ReplyDispatcherOptions for session/channel context - Update Signal channel to pass hookContext (example for other channels) - Add unit tests for message hooks Closes openclaw#5053 # Conflicts: # src/auto-reply/reply/reply-dispatcher.ts
- Make message:received hook fire-and-forget to avoid blocking inbound processing - Skip hook if sessionKey is missing (avoid empty bucket problem) - Isolate message:sent hook errors from delivery status - Add error logging for hook failures
- Use await instead of fire-and-forget for triggerMessageReceived - Ensures hook completes before message processing continues - Matches tested/working implementation pattern
bd69dbb to
5e90580
Compare
|
Thanks for flagging this, @Glucksberg! 👋 You're right — PR #6384 and this one both implement Key differences:
Proposal: The maintainers should decide which approach they prefer. Happy to:
@heybeaux — sorry for the duplicate! Wasn't intentional. Let me know if you want to coordinate. |
Cherry-picked from PR openclaw#6797 (pending upstream review). Enables hooks to trigger on incoming/outgoing messages for memory extraction, logging, and other processing pipelines.
bug report:
|
production report: what message hooks enable (5 days, 539+ events)running this patch on a personal AI assistant (telegram, 24/7 vps) since feb 5. here's what we built on top of 1. canonical memory layerproblem: openclaw compacts context aggressively. agent decides what's "important" and discards the rest. but the agent doesn't have your future goals — it can't know what you'll need later. solution: this becomes layer 1 of a 3-layer memory system:
the raw log feeds directly into embeddings via result: 539+ events logged, 351 chunks indexed, 2657 cached embeddings. zero data loss through 50+ compaction cycles. 2. secret leak detection (DLP)problem: LLMs can leak secrets. the agent reads config files, env vars, api keys — and might include them in responses. prompt rules help but aren't bulletproof. solution:
on detection → logs severity + sends telegram alert. this is layer 3 of defense-in-depth:
limitation: post-send detection (message already delivered). for pre-send blocking, hook would need to return a value that cancels delivery. currently fire-and-forget. 3. bot-to-bot communicationproblem: telegram doesn't show messages between bots in groups. bot A sends a reply — bot B never sees it. makes multi-bot collaboration impossible. solution: two bots running this in production across 2 telegram groups. cross-group events visible in one unified stream. latency: ~5s end-to-end. 4. identity continuity through compactionproblem: when context gets compacted, the agent loses continuity. it doesn't remember what it was working on, what it learned, or what the user cares about. solution: message hooks feed a nightly synthesis pipeline that extracts:
on session start, the agent reads this is the cognitive light cone pattern: different temporal lenses on the same data, each serving a different planning horizon. 5. analytics foundationevery event has: timestamp, sender, channel, session key, group context, message content. this enables:
haven't built dashboards yet but the data is there, structured, and searchable. what's missing from the current PR
tldrmessage hooks turn openclaw from a stateless chatbot into infrastructure for persistent memory, security monitoring, multi-agent coordination, and identity continuity. the raw event stream is the foundation — everything else is derived. this PR should merge. it's not a nice-to-have, it's load-bearing. |
Implements the message lifecycle hooks documented as 'Future Events' in the hooks documentation. These events enable extensions to observe inbound and outbound messages for logging, analytics, memory systems, and archival. New events: - message:received: triggered when an inbound message is processed - message:sent: triggered after an outbound message is successfully delivered Both events include rich context (text, channel, chatType, sender info, media URLs) and are fired asynchronously so they never block message processing. Includes: - 6 test cases covering event triggering, general/specific handler routing, media fields, error isolation, and type validation - Documentation update moving these from 'Future Events' to documented events Combines approaches from prior community PRs openclaw#6384, openclaw#6797, and openclaw#9859. Resolves openclaw#12775, openclaw#8807, openclaw#13004 Related: openclaw#5354, openclaw#12867, openclaw#12914
|
opened a discussion with broader proposal that builds on this PR: #14539 tl;dr — all three running in production for a week, 550+ events, zero issues. details + use cases in the discussion. @NOVA-Openclaw — would appreciate your thoughts on the preprocessed event. also cc @heybeaux from #6384 since this affects both PRs. |
|
@matskevich The We run memory extraction hooks on Our use case: we extract entities, context, and conversation metadata into a postgres database with vector embeddings. The preprocessed event would give us the full picture without needing to re-process media ourselves. +1 on the three-event model (received → preprocessed → sent). Happy to help test if useful. |
|
Need it in main |
|
sweet, thanks, about to implement it |
|
update: #14196 was closed without merge (hooks are running on v2026.2.13 in production — patches reapplied also proposing inline exec approval buttons for telegram |
|
message:received + message:sent are now in mainline (2026.2.12). PR #9859 covers the full lifecycle including message:preprocessed and message:transcribed. closing this — thanks @NOVA-Openclaw for starting the conversation. the backport patch served us well for 8+ days in production. |
bfc1ccb to
f92900f
Compare
|
Closing in favor of a rebased PR on current main. The original call sites were lost in upstream refactors (d5e25e0). New PR coming with clean rebase, updated code for current dispatch patterns, and a regression test. |
Summary
Add hook events for the message lifecycle, enabling workspace hooks to trigger on incoming and outgoing messages (e.g., memory extraction, semantic recall, logging).
Changes
src/hooks/message-hooks.ts— New file withtriggerMessageReceivedandtriggerMessageSenthelperssrc/auto-reply/dispatch.ts— Wiremessage:receivedtrigger before agent processing indispatchInboundMessage()src/auto-reply/reply/reply-dispatcher.ts— Wiremessage:senttrigger after successful delivery; addhookContextoption toReplyDispatcherOptionssrc/hooks/hooks.ts— Export message hook typessrc/hooks/message-hooks.test.ts— Unit tests for both triggerssrc/auto-reply/dispatch.test.ts— Regression test ensuringdispatchInboundMessagecallstriggerMessageReceived(prevents silent removal by future refactors)Context
The original version of this PR was merged into our fork but the
triggerMessageReceived()call site was silently dropped by commit d5e25e0 ("refactor: centralize dispatcher lifecycle ownership") whendispatch.tswas rewritten. This went undetected because there was no regression test.This updated PR:
mainwithReplyDispatcherpattern indispatch.tsHook Events
message:receiveddispatchInboundMessage()message:sentcreateReplyDispatcher()delivery callbackUse Cases
Closes #5053