diff --git a/.changeset/slick-points-arrive.md b/.changeset/slick-points-arrive.md
new file mode 100644
index 000000000..8eb4bd547
--- /dev/null
+++ b/.changeset/slick-points-arrive.md
@@ -0,0 +1,5 @@
+---
+"@chat-adapter/slack": minor
+---
+
+add threadDirectMessages option to reply in a thread when a user DMs the bot, which also enables native streaming in DMs
diff --git a/apps/docs/content/adapters/official/slack.mdx b/apps/docs/content/adapters/official/slack.mdx
index c96ca72f3..3e546d72a 100644
--- a/apps/docs/content/adapters/official/slack.mdx
+++ b/apps/docs/content/adapters/official/slack.mdx
@@ -140,6 +140,12 @@ bot.onNewMention(async (thread, message) => {
description:
"Enable the Agent messaging experience (agent_view manifest mode).",
},
+ threadDirectMessages: {
+ type: "boolean",
+ default: "false",
+ description:
+ "Reply in a thread when a user DMs the bot, so each top-level DM starts its own conversation. Implied by agentView.",
+ },
suggestedPrompts: {
type: "SlackSuggestedPrompts",
description:
@@ -276,6 +282,26 @@ The `@chat-adapter/slack/webhook`, `@chat-adapter/slack/format`, `@chat-adapter/
## Advanced
+### Threaded DMs
+
+By default a DM with your bot is one flat conversation: every message and every reply lands at the top level. Set `threadDirectMessages` to reply in a thread instead, so each top-level DM starts its own conversation:
+
+```typescript
+const slack = createSlackAdapter({ threadDirectMessages: true });
+```
+
+Each message the user sends at the DM top level becomes a thread root and the bot replies inside it. Users get one scoped conversation per topic instead of an unbounded transcript, and follow-ups stay attached to the message they belong to.
+
+It also gives DMs the `thread_ts` that Slack's [native streaming API](#native-streaming) requires, so streamed replies render natively in DMs rather than falling back to post-and-edit.
+
+
+Because bot replies are threaded under each user message, channel-level history (`channel.messages`, `conversations.history`) only returns the user's side of a DM conversation. If you build AI conversation history for DMs, use [transcripts](/docs/conversation-history) (which record both roles across thread IDs) instead of channel history, otherwise the model never sees its own previous replies.
+
+
+Thread IDs change from `slack:{channelId}:` to `slack:{channelId}:{ts}`, so turning this on for a running bot starts fresh threads and state stored against the old conversation-scoped ID stays there. Subscriptions are bridged: when the conversation-scoped thread returned by `openDM()` is subscribed, top-level DM messages still route to it, so `onSubscribedMessage` and proactive flows keep working.
+
+[`agentView`](#agent-messaging-experience) already threads DMs, so agents do not need to set this.
+
### Agents
Everything for building an AI agent on Slack: the Agent messaging experience (`agent_view`), the Assistants API (suggested prompts, status, titles), native streaming, and feedback buttons.
@@ -294,11 +320,7 @@ With `agentView: true`:
- `onAppContextChanged` reports the user's active view (see [Handling active-view context](/docs/handling-events#handling-active-view-context-agent-messaging)).
- `getAppContext(message)` returns the folded active-view context on a DM message.
- `setSuggestedPrompts(channelId, undefined, prompts)` may omit the thread reference — prompts sit at the top of the agent conversation. A `suggestedPrompts` config entry is applied automatically on every Messages-tab open.
-- DM messages are threaded per Slack's model (each user message is a thread root). Threads returned by `openDM()` keep working: when the conversation-scoped thread is subscribed, incoming top-level DM messages route to it, so `onSubscribedMessage` and per-thread state behave as before.
-
-
-Because bot replies are threaded under each user message, channel-level history (`channel.messages`, `conversations.history`) only returns the user's side of a DM conversation. If you build AI conversation history for DMs, use [transcripts](/docs/conversation-history) (which record both roles across thread IDs) instead of channel history — otherwise the model never sees its own previous replies.
-
+- DM messages are threaded per Slack's model (each user message is a thread root). See [Threaded DMs](#threaded-dms) for how thread IDs, subscriptions, and conversation history behave.
Add the event subscription and scope to your manifest:
diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts
index 007753cde..0b262c596 100644
--- a/packages/adapter-slack/src/index.test.ts
+++ b/packages/adapter-slack/src/index.test.ts
@@ -4963,6 +4963,121 @@ describe("agent_view DM threading", () => {
});
});
+// ============================================================================
+// threadDirectMessages Tests
+// ============================================================================
+
+describe("threadDirectMessages", () => {
+ const secret = "test-secret";
+
+ function dmBody(threadTs?: string) {
+ return JSON.stringify({
+ type: "event_callback",
+ event: {
+ type: "message",
+ channel: "D1",
+ channel_type: "im",
+ user: "U1",
+ text: "hi",
+ ts: "1771.99",
+ event_ts: "1771.99",
+ ...(threadTs ? { thread_ts: threadTs } : {}),
+ },
+ });
+ }
+
+ async function routeDM(
+ adapter: SlackAdapter,
+ mockChat: ReturnType,
+ body: string
+ ) {
+ await adapter.initialize(mockChat);
+ const tasks: Promise[] = [];
+ await adapter.handleWebhook(createWebhookRequest(body, secret), {
+ waitUntil: (p) => {
+ tasks.push(p);
+ },
+ });
+ await Promise.all(tasks);
+ return vi.mocked(mockChat.processMessage).mock.calls[0]?.[1];
+ }
+
+ it("threads a top-level DM message under its own ts", async () => {
+ const adapter = createSlackAdapter({
+ threadDirectMessages: true,
+ signingSecret: secret,
+ botUserId: "U_BOT",
+ logger: mockLogger,
+ });
+ const mockChat = createMockChatInstance({ state: createMockState() });
+
+ expect(await routeDM(adapter, mockChat, dmBody())).toBe("slack:D1:1771.99");
+ });
+
+ it("keeps DM replies on the parent thread_ts", async () => {
+ const adapter = createSlackAdapter({
+ threadDirectMessages: true,
+ signingSecret: secret,
+ botUserId: "U_BOT",
+ logger: mockLogger,
+ });
+ const mockChat = createMockChatInstance({ state: createMockState() });
+
+ expect(await routeDM(adapter, mockChat, dmBody("1771.11"))).toBe(
+ "slack:D1:1771.11"
+ );
+ });
+
+ it("routes to the conversation-scoped thread when it is subscribed (openDM flow)", async () => {
+ const adapter = createSlackAdapter({
+ threadDirectMessages: true,
+ signingSecret: secret,
+ botUserId: "U_BOT",
+ logger: mockLogger,
+ });
+ const state = createMockState();
+ await state.subscribe("slack:D1:");
+ const mockChat = createMockChatInstance({ state });
+
+ expect(await routeDM(adapter, mockChat, dmBody())).toBe("slack:D1:");
+ });
+
+ it("keeps DMs conversation-scoped when unset", async () => {
+ const adapter = createSlackAdapter({
+ signingSecret: secret,
+ botUserId: "U_BOT",
+ logger: mockLogger,
+ });
+ const mockChat = createMockChatInstance({ state: createMockState() });
+
+ expect(await routeDM(adapter, mockChat, dmBody())).toBe("slack:D1:");
+ });
+
+ it("leaves channel threading unchanged", async () => {
+ const adapter = createSlackAdapter({
+ threadDirectMessages: true,
+ signingSecret: secret,
+ botUserId: "U_BOT",
+ logger: mockLogger,
+ });
+ const mockChat = createMockChatInstance({ state: createMockState() });
+ const body = JSON.stringify({
+ type: "event_callback",
+ event: {
+ type: "message",
+ channel: "C1",
+ channel_type: "channel",
+ user: "U1",
+ text: "hi",
+ ts: "1771.99",
+ event_ts: "1771.99",
+ },
+ });
+
+ expect(await routeDM(adapter, mockChat, body)).toBe("slack:C1:1771.99");
+ });
+});
+
// ============================================================================
// Typing Indicator Tests
// ============================================================================
diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts
index af5bcdefd..39e5396a0 100644
--- a/packages/adapter-slack/src/index.ts
+++ b/packages/adapter-slack/src/index.ts
@@ -631,6 +631,8 @@ export class SlackAdapter implements Adapter {
// Socket mode support
protected readonly appToken: string | undefined;
protected readonly agentView: boolean;
+ /** True when top-level DMs are routed to their own thread root. */
+ protected readonly dmThreading: boolean;
protected readonly suggestedPrompts?: SlackSuggestedPrompts;
protected readonly loadingMessages?: string[];
/** Normalized feedbackButtons config (`true` becomes `{}`). */
@@ -811,6 +813,7 @@ export class SlackAdapter implements Adapter {
this.appToken = config.appToken;
this.agentView = config.agentView ?? false;
+ this.dmThreading = this.agentView || (config.threadDirectMessages ?? false);
this.suggestedPrompts = config.suggestedPrompts;
this.loadingMessages = config.loadingMessages;
this.nativeStreaming = config.nativeStreaming ?? true;
@@ -2611,16 +2614,17 @@ export class SlackAdapter implements Adapter {
return;
}
- // For DMs under assistant_view (legacy): top-level messages use empty threadTs
- // (matches openDM subscriptions); thread replies use thread_ts for per-conversation
- // isolation.
- // Under agent_view the Messages-tab conversation is threaded per Slack's model —
- // each user message is a thread root — so reply in-thread using `thread_ts ?? ts`
- // (except when the conversation-scoped openDM ID is subscribed; see bridge below).
+ // For DMs by default (and under assistant_view, legacy): top-level messages use
+ // empty threadTs (matches openDM subscriptions); thread replies use thread_ts for
+ // per-conversation isolation.
+ // Under agent_view or threadDirectMessages the DM conversation is threaded per
+ // Slack's model, each user message being a thread root, so reply in-thread using
+ // `thread_ts ?? ts` (except when the conversation-scoped openDM ID is subscribed;
+ // see bridge below).
// For channels: always use thread_ts or ts for per-thread IDs.
const isDM = event.channel_type === "im";
const threadTs =
- isDM && !this.agentView
+ isDM && !this.dmThreading
? event.thread_ts || ""
: event.thread_ts || event.ts;
const threadId = this.encodeThreadId({
@@ -2646,12 +2650,12 @@ export class SlackAdapter implements Adapter {
return msg;
};
- // Under agent_view each top-level DM message is its own thread root, which
- // would silently bypass subscriptions created on the conversation-scoped
+ // With DM threading on, each top-level DM message is its own thread root,
+ // which would silently bypass subscriptions created on the conversation-scoped
// thread ID that openDM() returns (slack:{D…}:). Bridge: when that
// conversation-scoped ID is subscribed, route the message to it so
// onSubscribedMessage and per-thread state keep working for proactive flows.
- if (this.agentView && isDM && !event.thread_ts) {
+ if (this.dmThreading && isDM && !event.thread_ts) {
const chat = this.chat;
const conversationThreadId = this.encodeThreadId({
channel: event.channel,
@@ -2665,7 +2669,7 @@ export class SlackAdapter implements Adapter {
}
} catch (error) {
this.logger.warn(
- "agent_view DM subscription check failed; using per-message thread",
+ "DM subscription check failed; using per-message thread",
{ error: String(error), threadId }
);
}
@@ -5832,6 +5836,7 @@ export function createSlackAdapter(config?: SlackAdapterConfig): SlackAdapter {
logger: config?.logger ?? new ConsoleLogger("info").child("slack"),
nativeStreaming: config?.nativeStreaming,
suggestedPrompts: config?.suggestedPrompts,
+ threadDirectMessages: config?.threadDirectMessages,
socketForwardingSecret:
config?.socketForwardingSecret ??
process.env.SLACK_SOCKET_FORWARDING_SECRET,
diff --git a/packages/adapter-slack/src/types.ts b/packages/adapter-slack/src/types.ts
index ea69f60df..ee4e8c3da 100644
--- a/packages/adapter-slack/src/types.ts
+++ b/packages/adapter-slack/src/types.ts
@@ -178,6 +178,19 @@ export interface SlackAdapterConfig {
* active-view entities) and returns prompts per thread.
*/
suggestedPrompts?: SlackSuggestedPrompts;
+ /**
+ * Reply in a thread when a user DMs the bot, so each top-level DM starts its
+ * own conversation instead of one flat, unbounded transcript. Also gives DMs
+ * the `thread_ts` that Slack's native streaming API requires.
+ *
+ * Thread IDs become `slack:{D…}:{ts}` rather than `slack:{D…}:`, so enabling
+ * this on a running bot starts fresh threads: existing per-thread state stays
+ * on the old ID. Subscriptions created by `openDM()` keep working: a
+ * subscribed conversation-scoped thread still receives every DM.
+ *
+ * Implied by `agentView`. Defaults to false.
+ */
+ threadDirectMessages?: boolean;
/** Override bot username (optional) */
userName?: string;
/**