Skip to content

[Feature]: Native Slack streaming via chat.startStream / appendStream / stopStream APIs #4391

@spartakb

Description

@spartakb

Summary

Add native real-time token streaming for Slack channels using Slack's chat.startStream, chat.appendStream, and chat.stopStream APIs. This replaces the current block-streaming approach (periodic message edits) with true streaming — tokens appear character-by-character as the LLM generates them, matching the experience users get in ChatGPT, Claude.ai, etc.

Motivation

Clawdbot's current Slack delivery has two modes:

  1. No streaming — entire response posted at once after generation completes
  2. Block streaming — response chunked and delivered via periodic message edits (chat.update)

Block streaming is a significant improvement, but it's still noticeably "chunky" — text appears in bursts rather than flowing smoothly. Slack now offers native streaming APIs that deliver a much better UX.

Working Implementation

I've built and tested a complete implementation that's been running in production. Here's how it works:

Architecture

Two files:

  1. dist/slack/chat-stream.js (NEW) — Streaming wrapper with throttling, error recovery, and graceful fallback
  2. dist/slack/monitor/message-handler/dispatch.js (MODIFIED) — Wires streaming into the existing reply pipeline

Key API Details

Slack's streaming API has some poorly documented requirements discovered during implementation:

// Start a stream — creates a placeholder message that updates in real-time
const response = await client.chat.startStream({
    channel,
    thread_ts: threadTs,           // REQUIRED — streaming only works in threads
    recipient_team_id: teamId,     // REQUIRED — poorly documented
    recipient_user_id: userId,     // REQUIRED — poorly documented  
    markdown_text: firstChunk,
});
const streamTs = response.ts;  // message timestamp for subsequent calls

// Append more text (delta-based — send only new characters)
await client.chat.appendStream({
    channel,
    ts: streamTs,
    markdown_text: deltaText,
});

// Finalize — locks the message with final content
await client.chat.stopStream({
    channel,
    ts: streamTs,
    markdown_text: finalFullText,  // optional: replace entire content
});

Integration with dispatch.js

The stream hooks into the existing blockStreaming pipeline:

// In dispatch.js — stream creation
const canStreamNative = account.config.nativeStreaming === true ||
    (account.config.nativeStreaming !== false && account.config.blockStreaming === true);

const chatStream = canStreamNative
    ? createSlackChatStream({
        client: ctx.app.client,
        channel: message.channel,
        threadTs: initialThreadTs,
        teamId: ctx.teamId || message.team,
        userId: message.user,
        token: ctx.botToken,
    })
    : undefined;

// Partial reply handler — feeds LLM tokens to the stream
onPartialReply: chatStream
    ? (payload) => { if (payload.text) chatStream.update(payload.text); }
    : undefined,

// Delivery handler — stream IS the delivery, skip chat.postMessage
deliver: async (payload, info) => {
    if (chatStream?.isStarted() && !chatStream.hasFailed()) {
        const response = await chatStream.stop(payload.text);
        if (response) {
            replyPlan.markSent();
            if (!hasMedia) return;  // text delivered via stream
        }
    }
    // Fallback to normal delivery
    await deliverReplies({ ... });
}

Design Decisions

  1. Thread-only: Slack streaming only works as threaded replies (thread_ts required). Falls back to normal delivery for top-level messages.
  2. Throttled: 300ms minimum between API calls to avoid rate limits. Uses a timer-based throttle similar to telegram/draft-stream.js.
  3. Delta-based: Only sends new characters (not full text each time), matching Slack's append model.
  4. Graceful fallback: If streaming fails at any point, hasFailed() returns true and the delivery path falls through to normal chat.postMessage.
  5. No duplicate messages: When stream succeeds, the deliver callback returns early — the streamed message IS the final message, no separate chat.postMessage needed.
  6. Block streaming disabled during stream: disableBlockStreaming: true prevents the block chunking pipeline from also editing the message.

Config

Activates automatically when blockStreaming: true (existing config). Could also support a dedicated nativeStreaming: true flag.

Requirements

  • Slack bot token with appropriate scopes
  • replyToMode: "all" or messages in existing threads (streaming requires thread_ts)
  • blockStreaming: true in channel config (or a new nativeStreaming flag)

Notes

  • recipient_team_id and recipient_user_id are required by the Slack API but not well documented — took trial and error to discover
  • The stream wrapper pattern mirrors telegram/draft-stream.js for consistency
  • This has been running in production on a real workspace with no issues
  • Happy to contribute a PR if maintainers are interested

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    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