Skip to content

feat: add support for say_stream utility#1462

Open
WilliamBergamin wants to merge 11 commits intomainfrom
say_stream
Open

feat: add support for say_stream utility#1462
WilliamBergamin wants to merge 11 commits intomainfrom
say_stream

Conversation

@WilliamBergamin
Copy link
Contributor

@WilliamBergamin WilliamBergamin commented Mar 17, 2026

Summary

This PR aims to introduce a new kwarg say_stream, it allows developers the ability to easily use a WebClient.chat_stream object initialized with logical default values.

say_stream is WebClient.chat_stream initialized with

  • channel_id: from the event payload
  • thread_ts: thread_ts from the event payload or falls back to the ts value if it is available
  • recipient_team_id: the team_id from the event received or the enterprise_id if the app is installed on the org
  • recipient_user_id: the user_id from the event received

say_stream is available on app.event and app.message listeners, if Bolt fails to extract channel_id or thread_ts then say_stream will be None

Testing

  1. clone this branch
  2. build the project with scripts/build_pypi_package.sh
  3. Import the package in a Bolt project
  4. Play around with say_stream
Sample app.py
import os
import logging
from time import sleep

from slack_bolt import App, BoltContext, SayStream
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient

logging.basicConfig(level=logging.DEBUG)

app = App(token=os.environ.get("SLACK_BOT_TOKEN"))

@app.event("app_mention")
def handle_app_mention(client: WebClient, say_stream: SayStream, context: BoltContext):
  stream = say_stream(buffer_size=100)

  client.assistant_threads_setStatus(
      channel_id=context.channel_id,
      thread_ts=say_stream.thread_ts,
      status="Thinking...",
      loading_messages=[
          "Waking up from my mass-production nap...",
          "Putting on my thinking cap...",
          "Consulting the ancient Stack Overflow...",
          "Bribing the servers with virtual cookies...",
          "Loading witty response... please hold...",
      ],
  )

  stream.append(markdown_text="*Someone rang the bat signal!* :bat:\n\n")
  sleep(5)
  stream.append(markdown_text="Oh wait, it's just an @mention. Even better!\n\n")
  stream.append(markdown_text="> Fun fact: I was mass-produced in a mass-production factory, but I like to think I'm *one of a kind*.\n\n")
  sleep(1)
  stream.append(markdown_text="Anyway, here's my *totally professional* take:\n\n")
  stream.append(markdown_text="1. `say_stream` is basically magic — words appear like I'm *actually typing* :sparkles:\n")
  sleep(1)
  stream.append(markdown_text="2. Streaming means you don't have to stare at a blank screen wondering if I ghosted you\n")
  sleep(1)
  stream.append(markdown_text="3. Bolt for Python makes building Slack apps easier than microwaving leftovers\n\n")
  sleep(0.5)
  stream.append(markdown_text="_*mic drop*_ :microphone:")
  stream.stop()

@app.message("")
def handle_ask_bot(client: WebClient, say_stream: SayStream, context: BoltContext):
  stream = say_stream(buffer_size=100)

  client.assistant_threads_setStatus(
      channel_id=context.channel_id,
      thread_ts=say_stream.thread_ts,
      status="Thinking...",
      loading_messages=[
          "Rummaging through my one brain cell...",
          "Asking my rubber duck for advice...",
          "Warming up the nonsense generator...",
          "Downloading more RAM (just kidding)...",
          "Practicing my typing... clack clack clack...",
      ],
  )

  stream.append(markdown_text="*Psst...* you just DMed a bot. Bold move. I respect that. :sunglasses:\n\n")
  sleep(5)
  stream.append(markdown_text="Let me consult my *vast knowledge database*...\n\n")
  stream.append(markdown_text="```\n[ scanning... ]\n[ found: 1 brain cell ]\n[ deploying it now ]\n```\n\n")
  sleep(1)
  stream.append(markdown_text="Okay here's the deal:\n\n")
  sleep(1)
  stream.append(markdown_text=":rocket: *Streaming responses* means you get to watch me think in real time — terrifying, I know\n")
  sleep(1)
  stream.append(markdown_text=":hammer_and_wrench: *Bolt for Python* is the secret sauce behind my dazzling personality\n")
  sleep(1)
  stream.append(markdown_text=":zap: *Socket Mode* keeps our conversation nice and private — no nosy webhooks here\n\n")
  sleep(0.5)
  stream.append(markdown_text="That's all I've got. Don't forget to tip your bot! :robot_face:")
  stream.stop()

if __name__ == "__main__":
  SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start()
manifest.json
{
    "_metadata": {
        "major_version": 1,
        "minor_version": 1
    },
    "display_information": {
        "name": "say_stream_experiment"
    },
    "features": {
        "app_home": {
            "home_tab_enabled": false,
            "messages_tab_enabled": true,
            "messages_tab_read_only_enabled": false
        },
        "bot_user": {
            "display_name": "say_stream_experiment",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "app_mentions:read",
                "chat:write",
                "im:read",
                "im:write",
                "channels:history",
                "im:history"
            ]
        }
    },
    "settings": {
        "event_subscriptions": {
            "bot_events": [
                "app_mention",
                "message.im"
            ]
        },
        "interactivity": {
            "is_enabled": true
        },
        "org_deploy_enabled": true,
        "socket_mode_enabled": true,
        "token_rotation_enabled": false
    }
}

Category

  • slack_bolt.App and/or its core components
  • slack_bolt.async_app.AsyncApp and/or its core components
  • Adapters in slack_bolt.adapter
  • Document pages under /docs
  • Others

Requirements

Please read the Contributing guidelines and Code of Conduct before creating this issue or pull request. By submitting, you are agreeing to those rules.

  • I've read and understood the Contributing Guidelines and have done my best effort to follow them.
  • I've read and agree to the Code of Conduct.
  • I've run ./scripts/install_all_and_run_tests.sh after making the changes.

@WilliamBergamin WilliamBergamin added this to the 1.27.1 milestone Mar 17, 2026
@WilliamBergamin WilliamBergamin self-assigned this Mar 17, 2026
@WilliamBergamin WilliamBergamin added enhancement New feature or request area:async area:sync semver:minor experiment Experimental feature documented with ExperimentalWarning and pydoc Experiment section labels Mar 17, 2026
@codecov
Copy link

codecov bot commented Mar 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.43%. Comparing base (785b813) to head (143e8e3).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1462      +/-   ##
==========================================
+ Coverage   91.35%   91.43%   +0.08%     
==========================================
  Files         229      232       +3     
  Lines        7262     7334      +72     
==========================================
+ Hits         6634     6706      +72     
  Misses        628      628              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@WilliamBergamin WilliamBergamin marked this pull request as ready for review March 17, 2026 22:45
@WilliamBergamin WilliamBergamin requested a review from a team as a code owner March 17, 2026 22:45
Copy link
Member

@mwbrooks mwbrooks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WilliamBergamin Thanks a bunch for this PR! It's super exciting.

❓ Can you please provide some code for us to manually test? I'm having trouble importing SayStream and I'm unsure if I'm importing the incorrect path or have the sample app configured incorrectly. What is the import path?

Copy link
Member

@zimeg zimeg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎙️ Leaving a few comments too but an example listener in description might be helpful for later CHANGELOG I agree!

Comment on lines +35 to +44
# TODO: in the future we might want to introduce a "proper" extract_ts utility
thread_ts = req.context.thread_ts or event.get("ts")
if req.context.channel_id and thread_ts:
req.context["say_stream"] = SayStream(
client=req.context.client,
channel_id=req.context.channel_id,
thread_ts=thread_ts,
team_id=req.context.team_id,
user_id=req.context.user_id,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 thought: Defaulting to different threading behavior for say and say_stream concerns me somewhat.

🔮 ramble: I understand thread_ts is required to stream chat at this time, but if "parent" messages can be streamed in the future we might want to revisit also say behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yess I think we can revisite the say behavior in the future, but changing it I think would result a breaking change 🤔

I can add a TODO or maybe create an issue for this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WilliamBergamin Thanks for keeping note of this too! 📫

I'm partial to waiting for related feedback since this might be an expected experience for most! I fear that knowing some of the implementation details biases me...

Comment on lines +183 to +184
assert say_stream is None
assert context.say_stream is None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌟 praise: This is a pleasant assertion to have for application code guards too I think!

Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
@WilliamBergamin
Copy link
Contributor Author

@mwbrooks check out this app I've been using for testing

app.py
import os
import logging
from time import sleep

from slack_bolt import App, BoltContext, SayStream
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient

logging.basicConfig(level=logging.DEBUG)

app = App(token=os.environ.get("SLACK_BOT_TOKEN"))

@app.event("app_mention")
def handle_app_mention(client: WebClient, say_stream: SayStream, context: BoltContext):
  stream = say_stream(buffer_size=100)

  client.assistant_threads_setStatus(
      channel_id=context.channel_id,
      thread_ts=say_stream.thread_ts,
      status="Thinking...",
      loading_messages=[
          "Waking up from my mass-production nap...",
          "Putting on my thinking cap...",
          "Consulting the ancient Stack Overflow...",
          "Bribing the servers with virtual cookies...",
          "Loading witty response... please hold...",
      ],
  )

  stream.append(markdown_text="*Someone rang the bat signal!* :bat:\n\n")
  sleep(5)
  stream.append(markdown_text="Oh wait, it's just an @mention. Even better!\n\n")
  stream.append(markdown_text="> Fun fact: I was mass-produced in a mass-production factory, but I like to think I'm *one of a kind*.\n\n")
  sleep(1)
  stream.append(markdown_text="Anyway, here's my *totally professional* take:\n\n")
  stream.append(markdown_text="1. `say_stream` is basically magic — words appear like I'm *actually typing* :sparkles:\n")
  sleep(1)
  stream.append(markdown_text="2. Streaming means you don't have to stare at a blank screen wondering if I ghosted you\n")
  sleep(1)
  stream.append(markdown_text="3. Bolt for Python makes building Slack apps easier than microwaving leftovers\n\n")
  sleep(0.5)
  stream.append(markdown_text="_*mic drop*_ :microphone:")
  stream.stop()

@app.message("")
def handle_ask_bot(client: WebClient, say_stream: SayStream, context: BoltContext):
  stream = say_stream(buffer_size=100)

  client.assistant_threads_setStatus(
      channel_id=context.channel_id,
      thread_ts=say_stream.thread_ts,
      status="Thinking...",
      loading_messages=[
          "Rummaging through my one brain cell...",
          "Asking my rubber duck for advice...",
          "Warming up the nonsense generator...",
          "Downloading more RAM (just kidding)...",
          "Practicing my typing... clack clack clack...",
      ],
  )

  stream.append(markdown_text="*Psst...* you just DMed a bot. Bold move. I respect that. :sunglasses:\n\n")
  sleep(5)
  stream.append(markdown_text="Let me consult my *vast knowledge database*...\n\n")
  stream.append(markdown_text="```\n[ scanning... ]\n[ found: 1 brain cell ]\n[ deploying it now ]\n```\n\n")
  sleep(1)
  stream.append(markdown_text="Okay here's the deal:\n\n")
  sleep(1)
  stream.append(markdown_text=":rocket: *Streaming responses* means you get to watch me think in real time — terrifying, I know\n")
  sleep(1)
  stream.append(markdown_text=":hammer_and_wrench: *Bolt for Python* is the secret sauce behind my dazzling personality\n")
  sleep(1)
  stream.append(markdown_text=":zap: *Socket Mode* keeps our conversation nice and private — no nosy webhooks here\n\n")
  sleep(0.5)
  stream.append(markdown_text="That's all I've got. Don't forget to tip your bot! :robot_face:")
  stream.stop()

if __name__ == "__main__":
  SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start()
manifest.json
{
    "_metadata": {
        "major_version": 1,
        "minor_version": 1
    },
    "display_information": {
        "name": "say_stream_experiment"
    },
    "features": {
        "app_home": {
            "home_tab_enabled": false,
            "messages_tab_enabled": true,
            "messages_tab_read_only_enabled": false
        },
        "bot_user": {
            "display_name": "say_stream_experiment",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "app_mentions:read",
                "chat:write",
                "im:read",
                "im:write",
                "channels:history",
                "im:history"
            ]
        }
    },
    "settings": {
        "event_subscriptions": {
            "bot_events": [
                "app_mention",
                "message.im"
            ]
        },
        "interactivity": {
            "is_enabled": true
        },
        "org_deploy_enabled": true,
        "socket_mode_enabled": true,
        "token_rotation_enabled": false
    }
}

Copy link
Member

@zimeg zimeg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WilliamBergamin Super awesome changes recent! I'm finding this streams to channel as expected 🐢 💨

I'm leaving a note on experimental documentation but nothing to block this from merging I think!

"say_stream is experimental and may change in future versions.",
category=ExperimentalWarning,
stacklevel=2,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 note: I might not find this in outputs at the moment - would this be something to include in docstring for say_stream too?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:async area:sync enhancement New feature or request experiment Experimental feature documented with ExperimentalWarning and pydoc Experiment section semver:minor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants