Summary
The documented message hook contract says message_sent observes final success/failure and message hook contexts expose stable correlation fields when available (ctx.sessionKey, ctx.runId, ctx.messageId, etc.). In the current implementation, the normal Slack inbound assistant reply path does not appear to emit message_sent, and the generic outbound path emits message_sent without passing available session/run correlation into the canonical hook context.
This makes plugins unable to reliably observe the Slack-delivered assistant message timestamp (response.ts) for normal Slack agent replies. One concrete downstream impact: plugins that need to react to the final assistant Slack message cannot use message hooks as documented and may accidentally fall back to transcript-local message IDs, which are UUIDs and not valid Slack timestamps.
Evidence
Normal Slack inbound reply path appears to bypass generic outbound hook emission:
extensions/slack/src/monitor/message-handler/dispatch.ts calls Slack monitor reply delivery (deliverNormally / deliverReplies).
extensions/slack/src/monitor/replies.ts calls sendMessageSlack(...).
extensions/slack/src/send.ts posts via Slack chat.postMessage.
- I did not find a
message_sent emission on that normal Slack reply path.
Generic outbound does emit message_sent, but drops available correlation:
src/infra/outbound/deliver.ts computes sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key.
createMessageSentEmitter(...) builds canonical sent context with to, content, success, channelId, accountId, conversationId, and messageId, but does not pass sessionKey or runId.
src/hooks/message-hook-mappers.ts supports sessionKey and runId in buildCanonicalSentMessageHookContext, and toPluginMessageSentEvent / toPluginMessageContext will expose them if provided.
Slack delivered message ID is available:
extensions/slack/src/send.ts returns Slack response.ts as messageId for posted Slack messages.
- Generic
deliverOutboundPayloads passes delivery messageId into emitMessageSent.
Transcript message IDs are not a safe substitute:
src/config/sessions/transcript-append.ts generates a random UUID for transcript entries.
- That UUID is what session transcript update consumers see as the transcript message ID; it is not a channel-delivered Slack timestamp.
Expected behavior
- Normal Slack assistant reply delivery should emit
message_sent after final delivery success/failure.
- For successful Slack sends,
ctx.messageId / event.messageId should be the Slack message timestamp returned by Slack (response.ts).
- When OpenClaw has correlation fields available,
message_sent should include them in first-class fields:
ctx.sessionKey / event.sessionKey
ctx.runId / event.runId
- ideally typed
threadId / replyToId on message sent/sending surfaces where relevant, matching the docs guidance to prefer typed fields before channel-specific metadata.
- Generic outbound delivery should pass available
params.session?.key and any available run id into buildCanonicalSentMessageHookContext.
Why this matters
Plugins need a stable way to associate delivered channel messages with the agent session/run that produced them. For Slack, the delivered timestamp is required for follow-up actions such as adding reactions, editing, deleting, or threading against a specific assistant message. Without message_sent on normal Slack replies, or without session/run correlation on emitted hooks, plugins have to rely on brittle transcript/session-store inference.
Suggested fix
- Emit
message_sent from the normal Slack inbound assistant reply path, including the Slack delivered messageId.
- Pass available
sessionKey and runId through generic outbound createMessageSentEmitter into buildCanonicalSentMessageHookContext.
- Add regression tests for:
- normal Slack agent reply emits
message_sent with delivered Slack messageId;
- generic
deliverOutboundPayloads includes sessionKey when params.session.key is present;
- failure emits
message_sent with success: false and a useful error without leaking secrets;
- hook payload shape aligns with the documented first-class correlation fields.
Summary
The documented message hook contract says
message_sentobserves final success/failure and message hook contexts expose stable correlation fields when available (ctx.sessionKey,ctx.runId,ctx.messageId, etc.). In the current implementation, the normal Slack inbound assistant reply path does not appear to emitmessage_sent, and the generic outbound path emitsmessage_sentwithout passing available session/run correlation into the canonical hook context.This makes plugins unable to reliably observe the Slack-delivered assistant message timestamp (
response.ts) for normal Slack agent replies. One concrete downstream impact: plugins that need to react to the final assistant Slack message cannot use message hooks as documented and may accidentally fall back to transcript-local message IDs, which are UUIDs and not valid Slack timestamps.Evidence
Normal Slack inbound reply path appears to bypass generic outbound hook emission:
extensions/slack/src/monitor/message-handler/dispatch.tscalls Slack monitor reply delivery (deliverNormally/deliverReplies).extensions/slack/src/monitor/replies.tscallssendMessageSlack(...).extensions/slack/src/send.tsposts via Slackchat.postMessage.message_sentemission on that normal Slack reply path.Generic outbound does emit
message_sent, but drops available correlation:src/infra/outbound/deliver.tscomputessessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key.createMessageSentEmitter(...)builds canonical sent context withto,content,success,channelId,accountId,conversationId, andmessageId, but does not passsessionKeyorrunId.src/hooks/message-hook-mappers.tssupportssessionKeyandrunIdinbuildCanonicalSentMessageHookContext, andtoPluginMessageSentEvent/toPluginMessageContextwill expose them if provided.Slack delivered message ID is available:
extensions/slack/src/send.tsreturns Slackresponse.tsasmessageIdfor posted Slack messages.deliverOutboundPayloadspasses deliverymessageIdintoemitMessageSent.Transcript message IDs are not a safe substitute:
src/config/sessions/transcript-append.tsgenerates a random UUID for transcript entries.Expected behavior
message_sentafter final delivery success/failure.ctx.messageId/event.messageIdshould be the Slack message timestamp returned by Slack (response.ts).message_sentshould include them in first-class fields:ctx.sessionKey/event.sessionKeyctx.runId/event.runIdthreadId/replyToIdon message sent/sending surfaces where relevant, matching the docs guidance to prefer typed fields before channel-specific metadata.params.session?.keyand any available run id intobuildCanonicalSentMessageHookContext.Why this matters
Plugins need a stable way to associate delivered channel messages with the agent session/run that produced them. For Slack, the delivered timestamp is required for follow-up actions such as adding reactions, editing, deleting, or threading against a specific assistant message. Without
message_senton normal Slack replies, or without session/run correlation on emitted hooks, plugins have to rely on brittle transcript/session-store inference.Suggested fix
message_sentfrom the normal Slack inbound assistant reply path, including the Slack deliveredmessageId.sessionKeyandrunIdthrough generic outboundcreateMessageSentEmitterintobuildCanonicalSentMessageHookContext.message_sentwith delivered SlackmessageId;deliverOutboundPayloadsincludessessionKeywhenparams.session.keyis present;message_sentwithsuccess: falseand a useful error without leaking secrets;