Summary
A Slack DM thread can split into two OpenClaw session buckets when an outbound message.send/delivery-mirror path is given Slack's native IM channel ID (D...) instead of the peer user ID (U...). Inbound Slack DM routing canonicalizes the same physical conversation as a direct user session, but outbound route reconstruction in the Slack plugin treats a bare D... target as a normal channel target.
Observed result for the same visible Slack DM thread and same Slack thread_ts:
# Old/context-rich delivery-mirror session created by outbound route reconstruction
agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649
# Later/current inbound DM session for the same physical Slack thread
agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649
This caused the assistant to lose prior Slack thread context even though the Slack UI still showed the same DM thread.
Why this appears to be a bug
The docs and inbound code both indicate Slack DMs should route as direct, not channel:
docs/channels/slack.md: “DMs route as direct; channels as channel; MPIMs as group.”
docs/channels/channel-routing.md: direct messages are controlled by session.dmScope; channels/groups are isolated as channel/group sessions.
- Inbound Slack code explicitly treats
D... channels as DMs, even if Slack sends a contradictory or missing channel_type.
The split is not explained by session.dmScope. In the observed config, session.dmScope was set to "per-channel-peer", which should produce user-scoped Slack DM keys such as:
agent:main:slack:direct:<user_id>
The problem occurs before dmScope can do the right thing: the outbound path classifies the peer as channel instead of direct.
Historical evidence from one affected install
Installed version originally investigated:
OpenClaw 2026.5.4 (325df3e)
The same issue still appears present in the published npm package for 2026.5.7.
Observed session files for the same physical Slack DM thread timestamp:
Old/context-rich transcript:
~/.openclaw/agents/main/sessions/3da21597-532c-43ed-beeb-de6ac38df46d.jsonl
New/context-poor transcript:
~/.openclaw/agents/main/sessions/1cb2f009-927d-4a16-bfa7-a6e6cfa7a8b3-topic-1778110574.653649.jsonl
Session index:
~/.openclaw/agents/main/sessions/sessions.json
A reset backup transcript showed the exact outbound tool call that created the delivery-mirror session:
~/.openclaw/agents/main/sessions/bef987a1-21a2-4227-88c0-38a7fcdb6668-topic-1778110574.653649.jsonl.reset.2026-05-08T18-01-24.680Z
Relevant record around 2026-05-08T02:24:48.802Z:
{
"name": "message.send",
"arguments": {
"action": "send",
"channel": "slack",
"target": "D0AEWSDHAQH",
"threadId": "1778110574.653649"
}
}
The bad delivery-mirror transcript starts immediately after that, around 2026-05-08T02:24:49.195Z, with an OpenClaw delivery-mirror assistant record around 2026-05-08T02:24:49.196Z.
That timing strongly points to this sequence:
message.send target=D0AEWSDHAQH threadId=1778110574.653649
-> outbound mirror route reconstruction
-> session key agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649
No gateway restart/reload/reconnect was observed around that creation window in the local gateway logs. The route flip was tied to the outbound delivery-mirror creation, not to restart recovery.
Slack API validation from the affected workspace
Using the configured Slack bot token, the workspace could map the D... IM channel to the peer user:
conversations.info(channel=D0AEWSDHAQH)
ok: true
channel.id: D0AEWSDHAQH
channel.is_im: true
channel.user: U09G2DJ0275
conversations.members(channel=D0AEWSDHAQH)
ok: true
members: ["U09G2DJ0275", "U0AEHT2L7B4"]
conversations.open(users=U09G2DJ0275)
ok: true
channel.id: D0AEWSDHAQH
channel.is_im: true
channel.user: U09G2DJ0275
So at least with the recommended/minimal Slack scopes in this install, OpenClaw had enough permission to resolve D... -> U....
Code path analysis, 2026.5.7 npm package
I inspected the actual published npm tarball, not just GitHub release notes:
npm pack [email protected] --pack-destination /private/tmp
mkdir -p /private/tmp/openclaw-npm-2026.5.7
tar -xzf /private/tmp/openclaw-2026.5.7.tgz -C /private/tmp/openclaw-npm-2026.5.7 --strip-components=1
jq -r '.version' /private/tmp/openclaw-npm-2026.5.7/package.json
# 2026.5.7
The built package still has the problematic outbound classification:
// dist/channel-Bf55xMfV.js
async function resolveSlackOutboundSessionRoute(params) {
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
if (!parsed) return null;
const isDm = parsed.kind === "user";
let peerKind = isDm ? "direct" : "channel";
if (!isDm && /^G/i.test(parsed.id)) {
const channelType = await resolveSlackChannelType({
cfg: params.cfg,
accountId: params.accountId,
channelId: parsed.id
});
if (channelType === "group") peerKind = "group";
if (channelType === "dm") peerKind = "direct";
}
const peer = { kind: peerKind, id: parsed.id };
...
}
The target parser makes this worse for bare IDs:
// dist/target-parsing-WE4Kvnq9.js
if (options.defaultKind) return buildMessagingTarget(options.defaultKind, trimmed, trimmed);
So a bare D0AEWSDHAQH becomes kind: "channel", id: "D0AEWSDHAQH". Because the type-check block only runs for G..., D... is never checked with conversations.info and remains a channel route.
Readable source shows the same logic:
// extensions/slack/src/channel.ts
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
const isDm = parsed.kind === "user";
let peerKind: "direct" | "channel" | "group" = isDm ? "direct" : "channel";
if (!isDm && /^G/i.test(parsed.id)) {
const channelType = await resolveSlackChannelType(...);
if (channelType === "group") peerKind = "group";
if (channelType === "dm") peerKind = "direct";
}
const peer: RoutePeer = { kind: peerKind, id: parsed.id };
Delivery-mirror creation path
The path that creates the delivery-mirror session is:
src/infra/outbound/message-action-runner.ts
handleSendAction(...)
-> prepareOutboundMirrorRoute(...)
src/infra/outbound/message-action-threading.ts
prepareOutboundMirrorRoute(...)
-> resolveOutboundSessionRoute({ target: params.to, threadId, currentSessionKey, ... })
-> ensureOutboundSessionEntry(...)
-> actionParams.__sessionKey = outboundRoute.sessionKey
src/infra/outbound/outbound-session.ts
resolveOutboundSessionRoute(...)
-> getChannelPlugin("slack").messaging.resolveOutboundSessionRoute(...)
extensions/slack/src/channel.ts
resolveSlackOutboundSessionRoute(...)
-> parse bare D... as channel
-> builds agent:main:slack:channel:d...[:thread:<thread_ts>]
Delivery mirrors themselves are intentional: appendAssistantMessageToSessionTranscript(...) writes provider: "openclaw", model: "delivery-mirror", and replay code treats those as transcript-only records. The issue is not that delivery mirrors exist; the issue is that their route can be non-canonical for Slack DMs.
Inbound path behaves differently/correctly
The inbound Slack path already has explicit D... handling:
// extensions/slack/src/monitor/channel-type.ts
if (trimmed.startsWith("D")) {
return "im";
}
...
// D-prefix channel IDs are always DMs — override a contradicting channel_type.
if (inferred === "im" && normalized !== "im") {
return "im";
}
Inbound routing then uses the Slack user as the direct peer:
// extensions/slack/src/monitor/message-handler/prepare-routing.ts
peer: {
kind: params.isDirectMessage ? "direct" : params.isRoom ? "channel" : "group",
id: params.isDirectMessage ? (params.message.user ?? "unknown") : params.message.channel,
}
For the same Slack DM, inbound therefore generates a slack:direct:<U...> session, while outbound delivery reconstruction can generate slack:channel:<D...>.
Reproduction / validation steps for a fresh session
A fresh Codex/OpenClaw maintainer can validate this without my local logs:
- Inspect
extensions/slack/src/channel.ts in resolveSlackOutboundSessionRoute.
- Confirm it parses
params.target with defaultKind: "channel".
- Confirm only
G... IDs are passed to resolveSlackChannelType.
- Confirm bare
D... IDs therefore become channel peers.
- With
session.dmScope: "per-channel-peer", evaluate outbound route reconstruction for:
{
channel: "slack",
agentId: "main",
target: "D0AEWSDHAQH",
threadId: "1778110574.653649",
cfg: { session: { dmScope: "per-channel-peer" }, channels: { slack: { ... } } }
}
Expected canonical result for a Slack IM channel whose peer is U09G2DJ0275:
agent:main:slack:direct:u09g2dj0275:thread:1778110574.653649
Current result from the route logic:
agent:main:slack:channel:d0aewsdhaqh:thread:1778110574.653649
Related PRs checked that do not fix this
I checked these because they are Slack/session-routing adjacent:
Proposed fix
Minimal safe direction:
- In
resolveSlackOutboundSessionRoute, treat bare D... IDs as native Slack IM channel IDs, not normal channels.
- Resolve
D... -> U... using conversations.info(channel=D...) when possible. resolveSlackChannelType already calls conversations.info, but currently discards channel.user; it may need to return richer info for IM channels.
- When
channel.is_im === true && channel.user:
peer = { kind: "direct", id: channel.user }
chatType = "direct"
from = `slack:${channel.user}`
to = `user:${channel.user}`
// retain native channel id in delivery context if needed
- If Slack API lookup fails or does not return
channel.user, still do not persist D... as channel:D.... Safer fallback options:
- classify as direct with peer id
D... and preserve nativeChannelId, or
- use
conversations.members(D...) and choose the non-bot member when available, or
- return null and skip mirror session creation rather than creating a known-wrong channel session.
- Add alias/migration handling for existing
agent:*:slack:channel:d... session entries so old delivery mirrors can be found/merged with canonical agent:*:slack:direct:u... sessions once the mapping is known.
Suggested tests
Add focused tests around Slack outbound route reconstruction and delivery mirrors:
- Bare
D... outbound target with conversations.info returning { is_im: true, user: "U..." } produces agent:<agent>:slack:direct:<u...>.
- Same case with
threadId preserves the thread suffix on the canonical direct session.
- Bare
D... with lookup failure does not produce agent:<agent>:slack:channel:<d...>.
channel:D... is either canonicalized to direct via lookup or rejected/handled as a native IM channel, but not silently stored as a normal channel session.
- Existing
G... MPIM behavior remains group.
- Inbound DM route and outbound delivery-mirror route for the same physical Slack DM thread produce the same session key under
session.dmScope: "per-channel-peer".
Assessment
Confidence: high that this is a real bug in Slack outbound route/session canonicalization and not a local config issue.
The local config value session.dmScope: "per-channel-peer" is not the cause. It exposes the bug because correct direct-message routing should key by Slack user ID, while the bad outbound mirror path bypasses direct-message classification entirely and stores the same conversation as a channel keyed by Slack's IM channel ID.
Summary
A Slack DM thread can split into two OpenClaw session buckets when an outbound
message.send/delivery-mirror path is given Slack's native IM channel ID (D...) instead of the peer user ID (U...). Inbound Slack DM routing canonicalizes the same physical conversation as a direct user session, but outbound route reconstruction in the Slack plugin treats a bareD...target as a normal channel target.Observed result for the same visible Slack DM thread and same Slack
thread_ts:This caused the assistant to lose prior Slack thread context even though the Slack UI still showed the same DM thread.
Why this appears to be a bug
The docs and inbound code both indicate Slack DMs should route as
direct, notchannel:docs/channels/slack.md: “DMs route asdirect; channels aschannel; MPIMs asgroup.”docs/channels/channel-routing.md: direct messages are controlled bysession.dmScope; channels/groups are isolated as channel/group sessions.D...channels as DMs, even if Slack sends a contradictory or missingchannel_type.The split is not explained by
session.dmScope. In the observed config,session.dmScopewas set to"per-channel-peer", which should produce user-scoped Slack DM keys such as:The problem occurs before
dmScopecan do the right thing: the outbound path classifies the peer aschannelinstead ofdirect.Historical evidence from one affected install
Installed version originally investigated:
The same issue still appears present in the published npm package for
2026.5.7.Observed session files for the same physical Slack DM thread timestamp:
A reset backup transcript showed the exact outbound tool call that created the delivery-mirror session:
Relevant record around
2026-05-08T02:24:48.802Z:{ "name": "message.send", "arguments": { "action": "send", "channel": "slack", "target": "D0AEWSDHAQH", "threadId": "1778110574.653649" } }The bad delivery-mirror transcript starts immediately after that, around
2026-05-08T02:24:49.195Z, with an OpenClaw delivery-mirror assistant record around2026-05-08T02:24:49.196Z.That timing strongly points to this sequence:
No gateway restart/reload/reconnect was observed around that creation window in the local gateway logs. The route flip was tied to the outbound delivery-mirror creation, not to restart recovery.
Slack API validation from the affected workspace
Using the configured Slack bot token, the workspace could map the
D...IM channel to the peer user:So at least with the recommended/minimal Slack scopes in this install, OpenClaw had enough permission to resolve
D... -> U....Code path analysis, 2026.5.7 npm package
I inspected the actual published npm tarball, not just GitHub release notes:
The built package still has the problematic outbound classification:
The target parser makes this worse for bare IDs:
So a bare
D0AEWSDHAQHbecomeskind: "channel",id: "D0AEWSDHAQH". Because the type-check block only runs forG...,D...is never checked withconversations.infoand remains a channel route.Readable source shows the same logic:
Delivery-mirror creation path
The path that creates the delivery-mirror session is:
Delivery mirrors themselves are intentional:
appendAssistantMessageToSessionTranscript(...)writesprovider: "openclaw", model: "delivery-mirror", and replay code treats those as transcript-only records. The issue is not that delivery mirrors exist; the issue is that their route can be non-canonical for Slack DMs.Inbound path behaves differently/correctly
The inbound Slack path already has explicit
D...handling:Inbound routing then uses the Slack user as the direct peer:
For the same Slack DM, inbound therefore generates a
slack:direct:<U...>session, while outbound delivery reconstruction can generateslack:channel:<D...>.Reproduction / validation steps for a fresh session
A fresh Codex/OpenClaw maintainer can validate this without my local logs:
extensions/slack/src/channel.tsinresolveSlackOutboundSessionRoute.params.targetwithdefaultKind: "channel".G...IDs are passed toresolveSlackChannelType.D...IDs therefore become channel peers.session.dmScope: "per-channel-peer", evaluate outbound route reconstruction for:Expected canonical result for a Slack IM channel whose peer is
U09G2DJ0275:Current result from the route logic:
Related PRs checked that do not fix this
I checked these because they are Slack/session-routing adjacent:
requireMention: falsechannels. It explicitly does not change DMs/MPIMs or outbound routing.Proposed fix
Minimal safe direction:
resolveSlackOutboundSessionRoute, treat bareD...IDs as native Slack IM channel IDs, not normal channels.D... -> U...usingconversations.info(channel=D...)when possible.resolveSlackChannelTypealready callsconversations.info, but currently discardschannel.user; it may need to return richer info for IM channels.channel.is_im === true && channel.user:channel.user, still do not persistD...aschannel:D.... Safer fallback options:D...and preservenativeChannelId, orconversations.members(D...)and choose the non-bot member when available, oragent:*:slack:channel:d...session entries so old delivery mirrors can be found/merged with canonicalagent:*:slack:direct:u...sessions once the mapping is known.Suggested tests
Add focused tests around Slack outbound route reconstruction and delivery mirrors:
D...outbound target withconversations.inforeturning{ is_im: true, user: "U..." }producesagent:<agent>:slack:direct:<u...>.threadIdpreserves the thread suffix on the canonical direct session.D...with lookup failure does not produceagent:<agent>:slack:channel:<d...>.channel:D...is either canonicalized to direct via lookup or rejected/handled as a native IM channel, but not silently stored as a normal channel session.G...MPIM behavior remains group.session.dmScope: "per-channel-peer".Assessment
Confidence: high that this is a real bug in Slack outbound route/session canonicalization and not a local config issue.
The local config value
session.dmScope: "per-channel-peer"is not the cause. It exposes the bug because correct direct-message routing should key by Slack user ID, while the bad outbound mirror path bypasses direct-message classification entirely and stores the same conversation as a channel keyed by Slack's IM channel ID.