Skip to content

Slack: support streaming replies to top-level message.im using incoming message ts #503

@vstratful

Description

@vstratful

Summary

Slack top-level DMs can be handled with chat.onDirectMessage, but there does not appear to be a high-level Chat SDK path for streaming a response as a Slack assistant-style thread reply to the incoming DM message.

This is related to, but narrower than, #268 / #292. PR #292 intentionally preserves empty threadTs for top-level DMs so openDM() subscription matching continues to work, and it guards Slack API calls against empty threadTs. That makes sense.

The remaining gap is native Slack streaming from a fresh message.im event:

chat.onDirectMessage(async (thread, message) => {
  const result = streamText(/* ... */);
  await thread.post(result.fullStream);
});

For a top-level Slack DM, the thread ID is still slack:D...:. Slack chat.startStream requires a real thread_ts, and Slack's streaming docs say streamed messages should reply to a user request. For this case, the correct Slack thread_ts appears to be the incoming user message event.ts.

Current behavior

In @chat-adapter/slack@4.28.1, top-level DM events are represented with an empty thread timestamp:

const threadTs = isDM ? event.thread_ts || "" : event.thread_ts || event.ts;

Then thread.post(result.fullStream) eventually calls the Slack adapter's stream() with a thread ID that has no thread context. The current adapter correctly rejects this:

Slack streaming requires a valid thread context (non-empty threadTs)

Desired behavior

There should be a supported SDK-level way to stream a reply to the specific incoming top-level DM message, without app code needing to construct Slack adapter thread IDs manually.

Ideally this would allow the simple handler above to work, by using the current/incoming message context to stream against message.raw.ts for top-level message.im events while preserving the existing empty-thread identity for DM conversation/subscription matching.

Alternative API shapes would also work, for example:

  • message.reply(result.fullStream)
  • thread.replyTo(message, result.fullStream)
  • an adapter option passed internally from ThreadImpl.handleStream() such as replyToMessageTs
  • documented guidance if this is intentionally out of scope

Workaround

The workaround is to drop below the Chat SDK abstraction and call the Slack adapter directly:

import { fromFullStream } from "chat";

const raw = message.raw as {
  channel?: string;
  ts?: string;
  team?: string;
  team_id?: string;
};

await adapter.stream(`slack:${raw.channel}:${raw.ts}`, fromFullStream(result.fullStream), {
  recipientUserId: message.author.userId,
  recipientTeamId: raw.team_id ?? raw.team,
});

This works, but it depends on Slack adapter thread ID internals and bypasses the normal thread.post(stream) abstraction.

Why this matters

For Slack assistant-style apps, a very common user behavior is to DM the bot directly. The expected UX is that the user's top-level DM becomes the new chat thread and the bot streams a response in that thread. Today that seems possible only by using adapter internals.

Environment

  • chat: 4.28.1
  • @chat-adapter/slack: 4.28.1
  • Slack Events API: message.im
  • Slack Assistants API/native streaming enabled

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions