Skip to main content
ctx.stream(source, opts) sends a reply that is still being generated (LLM tokens, a long report) while it is produced, with the best strategy the channel supports: native drafts on Telegram, live edits on Discord, and sentence-sized messages with “typing…” on WhatsApp. The same handler works on every channel.
The source can be any AsyncIterable of strings or bytes (an async generator, a Node Readable), a web ReadableStream, or a plain Iterable of strings. Byte chunks are decoded as UTF-8.

Strategies per channel

The provider declares capabilities.streaming ('draft' | 'edit' | 'none') and ctx.stream() picks the strategy from it:
  • Drafts are only attempted in private chats. If the transport refuses a draft at runtime, the stream degrades to edits (when the channel can edit) or to chunks.
  • strategy forces a choice; an unsupported one degrades draft → edit → chunk. 'chunk' and 'buffer' are always available.
  • No message ever exceeds maxLength (default capabilities.maxTextLength, else 4096): long text is split at natural boundaries (paragraphs, lines, sentences, words) and continues in a new message. On channels that count bytes (capabilities.maxTextBytes, Instagram) no message exceeds that many UTF-8 bytes either.

Options

ctx.stream() resolves with a StreamResult: text, messages, strategy (the one actually used, after any fallback), cancelled and stoppedByUser. It rejects with the source’s error (after flushing what was produced) or with the first send error.

Cancellation

A stream stops when signal aborts or, on Telegram drafts with canStop: true, when the user presses the stop button (stoppedByUser: true). Reading stops and the source’s return() is called, so an AI request is aborted too. bot.stop() also cancels every running ctx.stream() (after the drain, if any), but sends nothing more: no final flush, edit or deletion, whatever onCancel says.
  • onCancel: 'flush' (default) sends the text produced so far.
  • onCancel: 'discard' sends nothing more: a draft simply expires and, with the edit strategy, the message being edited is deleted (when the channel can delete; otherwise only the cursor is removed). Messages already completed before the cancel stay.
The Telegram long-poll asks for stopped_message_generation updates, which carry the stop button presses.

Rate limits

Live updates are throttled and never run concurrently. When the transport answers with a RateLimitedError, live updates pause for its retryAfterMs. The final edit (which removes the cursor and shows the full text) waits out the back-off and retries a rate limit up to three times.

Hooks and the message log

Everything ctx.stream() outputs goes through the before('send') hooks, so a hook that redacts, translates or aborts replies also covers streamed text. The payload carries a stream marker:
A hook that aborts a draft or an edit skips that update. The message log keeps the final edited text of each message.

With @botmesh/ai

The AI autoresponder streams its answer with the stream option:
  • stream takes true or the StreamOptions above (without signal and sendOptions), plus enabled: false to turn it off while keeping the options. Default: off (one message at the end).
  • Typing follows humanizeTyping unless you set typing yourself.
  • A refusal replies refusalMessage and is not stored; an empty answer replies errorMessage, as does a stream that fails.
  • With onCancel: 'discard', a cancelled answer is also kept out of the AI context, since the user never saw it in full.
  • With tools configured, turns are not streamed: the tool loop needs the whole answer, so the autoresponder falls back to one message.
In your own handlers, ctx.tools.ai.streamReplyInChat() is the streaming twin of replyInChat(): it reads and stores the chat history, applies memory and RAG and yields the answer piece by piece.
Stopping the stream aborts the provider request; the partial answer is stored unless you pass keepPartial: false (pair it with onCancel: 'discard'). The plugin’s timeoutMs applies as an idle timeout: no chunk for that long throws AITimeoutError. With @botmesh/voice and autoReplyWithVoice, streamed output always stays text.

Custom sinks

ctx.stream() is streamText() bound to the current chat. For other targets (a web widget, a test) use it directly with a StreamTarget:
resolveStreamStrategy(target, requested) tells you which strategy a target would get, and findSplitPoint(text, max) where text would be cut. Both come from @botmesh/core/internal, with no semver guarantee.

Testing

MockProvider simulates each mode with its streaming option:
streaming: 'edit' also exposes editMessage (recorded in edits) and deleteMessage (deleted); streaming: 'draft' adds sendDraft (recorded in drafts). maxTextLength and maxTextBytes simulate the length limits, and supports enables or hides each seam individually.

Next steps