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.
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 declarescapabilities.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.
strategyforces a choice; an unsupported one degradesdraft→edit→chunk.'chunk'and'buffer'are always available.- No message ever exceeds
maxLength(defaultcapabilities.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 whensignal 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.
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 aRateLimitedError, 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
Everythingctx.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:
With @botmesh/ai
The AI autoresponder streams its answer with the stream option:
streamtakestrueor theStreamOptionsabove (withoutsignalandsendOptions), plusenabled: falseto turn it off while keeping the options. Default: off (one message at the end).- Typing follows
humanizeTypingunless you settypingyourself. - A refusal replies
refusalMessageand is not stored; an empty answer replieserrorMessage, 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
toolsconfigured, turns are not streamed: the tool loop needs the whole answer, so the autoresponder falls back to one message.
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.
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
- AI plugin — the autoresponder and
ctx.tools.ai - Messages & media — edits, typing and per-transport support
- Hooks — the
sendpipeline