Stream replies
Streaming replies · stream LLM response to WhatsApp · stream OpenAI to Telegram · send ChatGPT reply to iMessage · LLM chat bubbles · typing indicator APITurn a model stream into complete chat bubbles while showing typing.
Messaging apps have no streaming text box, so a streamed model answer is best sent as a few natural bubbles, each sent as soon as it is complete, with the typing indicator on in between.
import Anthropic from "@anthropic-ai/sdk";import { FlowMessaging, contentText } from "@flow-engineer/messaging";
const flow = new FlowMessaging();const anthropic = new Anthropic();
for await (const event of flow.events.stream({ types: ["message.received"] })) { const stream = anthropic.messages.stream({ model: "claude-sonnet-4-5", max_tokens: 1024, messages: [{ role: "user", content: contentText(event.data.message.content) }], }); const sent = await event.conversation.reply(stream); // typing on, bubbles out, typing off console.log(`sent ${sent.length} bubbles`);}What reply() does
Section titled “What reply() does”conversation.reply(input) accepts a string, a kind object (basic, buttons, …), a list of them, or a stream. With a stream it:
- Shows the typing indicator, and keeps showing it (channels clear it after a few seconds).
- Reads text out of the stream as it arrives.
- Cuts a bubble at each natural break (below) and sends it while the model is still writing. Bubbles go out in order.
- Stops refreshing typing when the stream ends, even if it failed. Typing has no “off”: it clears itself when the next message goes or after a few seconds.
It reads these streams as they are, with no adapter:
| Source | Pass |
|---|---|
| OpenAI Chat Completions | openai.chat.completions.create({ ..., stream: true }) |
| OpenAI Responses | openai.responses.create({ ..., stream: true }) |
| Anthropic Messages | anthropic.messages.stream(...) or create({ ..., stream: true }) |
| Vercel AI SDK | the streamText(...) result |
| OpenAI Agents SDK | the streamed run(agent, input, { stream: true }) result |
| Claude Agent SDK | the query(...) iterator |
| LangChain | model.stream(...) or a runnable’s .stream(...) |
| Mastra | agent.stream(...) |
| Anything else | any AsyncIterable<string> or ReadableStream |
Each bubble is a basic message, sent exactly as written: no markdown is rendered or stripped. Ask the model for plain text, or style text yourself in a kind object with a channel block (telegram.parse_mode, imessage.styles).
await event.conversation.reply(stream, { idempotencyKey: event.id, // bubbles get event.id:0, event.id:1, ... so retrying the reply is safe split: true, // false: one message, cut only at the channel's length limit onBubble: (message, i) => console.log(i, message.id),});The bubble rule (for any language)
Section titled “The bubble rule (for any language)”Without the SDK, apply this rule to the model’s text and send each bubble as a basic message (POST /v0/conversations/{conversation_id}/basic with {"text": ...}), one after another, showing typing (POST .../typing with {}) before you start and every 4 seconds until you finish.
- A bubble ends at a paragraph break (a blank line), except after a lead-in line that ends with
:and between items of one list, unless the bubble is already past the channel’s soft length. - Past the soft length, a bubble ends at the next sentence end (
.,!,?or…followed by a space). - Never cut inside a fenced code block, unless the bubble would pass the channel’s hard limit; then cut at the last line break or space before it.
| Channel | Soft length | Hard limit |
|---|---|---|
| Telegram | 900 characters | 4096 |
| 700 characters | 4096 | |
| iMessage | 400 characters | 9999 |
| SMS | 300 characters | 670 (10 segments, even with an emoji) |
Use the event’s ID plus the bubble’s index as each send’s Idempotency-Key (evt_...:0, evt_...:1), so retrying a failed reply never sends a bubble twice.
Showing work while the agent thinks
Section titled “Showing work while the agent thinks”For tool calls, retrieval or anything slow before the first word:
const answer = await event.conversation.responding(() => agent.run(question)); // typing stays shownawait event.conversation.reply(answer);Messages in one conversation go out one at a time, in the order they were accepted, across every endpoint, webhook replies and the stream, so bubbles never arrive out of order even when you send them quickly.
Related
Section titled “Related”- Message kinds: typing, read receipts and text styles per channel.
- Agent frameworks: full examples per framework.
Thanks, we read every comment.