UNPKG

chat

Version:

Unified chat abstraction for Slack, Teams, Google Chat, and Discord

260 lines (188 loc) 9.99 kB
--- title: Streaming description: Stream real-time text responses from AI models and other async sources to chat platforms. type: guide prerequisites: - /docs/usage --- Chat SDK accepts any `AsyncIterable<string>` as a message, enabling real-time streaming of AI responses and other incremental content to chat platforms. For platforms with native or structured streaming support, you can also stream `StreamChunk` objects for rich content like task progress cards and plan updates. ## AI SDK integration Pass an AI SDK `fullStream` or `textStream` directly to `thread.post()`: ```typescript title="lib/bot.ts" lineNumbers import { ToolLoopAgent } from "ai"; const agent = new ToolLoopAgent({ model: "anthropic/claude-4.5-sonnet", instructions: "You are a helpful assistant.", }); bot.onNewMention(async (thread, message) => { const result = await agent.stream({ prompt: message.text }); await thread.post(result.fullStream); }); ``` ### Why `fullStream` over `textStream`? When AI SDK agents make tool calls between text steps, `textStream` concatenates all text without separators — `"hello.how are you?"` instead of `"hello.\n\nhow are you?"`. The `fullStream` contains explicit `finish-step` events that Chat SDK uses to inject paragraph breaks between steps automatically. Both stream types are auto-detected: ```typescript // Recommended: fullStream preserves step boundaries await thread.post(result.fullStream); // Also works: textStream for single-step generation await thread.post(result.textStream); ``` ## Custom streams Any async iterable works: ```typescript title="lib/bot.ts" lineNumbers const stream = (async function* () { yield "Processing"; yield "..."; yield " done!"; })(); await thread.post(stream); ``` ## Platform behavior | Platform | Method | Description | |----------|--------|-------------| | Slack | Native streaming API | Uses Slack's `chatStream` for smooth, real-time updates | | Teams | Native (DMs) / Buffered (group chats) | Uses the Teams SDK's native `stream.emit()` for direct messages; accumulates chunks and posts one final message when no native streamer is active | | Google Chat | Post + Edit | Posts a message then edits it as chunks arrive | | Discord | Post + Edit | Posts a message then edits it as chunks arrive | | Telegram | Post + Edit | Posts a message then edits it as chunks arrive | | GitHub | Buffered | Accumulates chunks and posts one final comment | | Linear | Agent sessions / Post + Edit | Uses agent session activities in agent-session threads; falls back to post+edit comments in issue threads | | WhatsApp | Buffered | Accumulates chunks and sends one final message | | Messenger | Buffered | Accumulates chunks and sends one final message | The post+edit fallback throttles edits to avoid rate limits. Configure the update interval when creating your `Chat` instance: ```typescript title="lib/bot.ts" lineNumbers const bot = new Chat({ // ... streamingUpdateIntervalMs: 500, // Default: 500ms }); ``` ### Disabling the placeholder message By default, post+edit adapters send an initial `"..."` placeholder message before the first chunk arrives. You can disable this to wait for real content before posting: ```typescript title="lib/bot.ts" lineNumbers const bot = new Chat({ // ... fallbackStreamingPlaceholderText: null, }); ``` You can also customize the placeholder text: ```typescript title="lib/bot.ts" const bot = new Chat({ // ... fallbackStreamingPlaceholderText: "Thinking...", }); ``` ## Markdown healing During streaming, chunks often arrive mid-word or mid-syntax — for example, `**bold` before the closing `**` arrives. The SDK automatically heals incomplete markdown in intermediate renders using [remend](https://www.npmjs.com/package/remend), so messages always display with correct formatting while streaming. The final message uses the raw accumulated text without healing, so the original markdown is preserved. ## Table buffering When streaming content that contains GFM tables (e.g. from an LLM), the SDK automatically buffers potential table headers until a separator line (`|---|---|`) confirms them. This prevents tables from briefly flashing as raw pipe-delimited text before the table structure is complete. This happens transparently — no configuration needed. ## Structured streaming chunks For Slack native streams and Linear agent-session streams, you can yield `StreamChunk` objects alongside plain text for rich progress updates: ```typescript title="lib/bot.ts" lineNumbers import type { StreamChunk } from "chat"; const stream = (async function* () { yield { type: "markdown_text", text: "Searching..." } satisfies StreamChunk; yield { type: "task_update", id: "search-1", title: "Searching documents", details: "Querying internal docs and ranking the best matches", status: "in_progress", } satisfies StreamChunk; // ... do work ... yield { type: "task_update", id: "search-1", title: "Searching documents", details: "Ranked 3 relevant results", status: "complete", output: "Found 3 results", } satisfies StreamChunk; yield { type: "markdown_text", text: "Here are your results..." } satisfies StreamChunk; })(); await thread.post(stream); ``` ### Chunk types | Type | Fields | Description | |------|--------|-------------| | `markdown_text` | `text` | Streamed text content | | `task_update` | `id`, `title`, `status`, `details?`, `output?` | Tool/step progress updates (`pending`, `in_progress`, `complete`, `error`) with optional extra task context | | `plan_update` | `title` | Plan title updates on supported platforms | ### Streaming with options Wrap a stream in a `StreamingPlan` to pass platform-specific options through `thread.post()` without dropping down to `adapter.stream()` directly: ```typescript import { StreamingPlan } from "chat"; const planned = new StreamingPlan(stream, { groupTasks: "plan", // Slack: render task cards as a single grouped block endWith: [feedbackBlock], // Slack: Block Kit elements appended after stream stops updateIntervalMs: 750, // Post+edit cadence on supported adapters }); await thread.post(planned); ``` | Option | Platform | Description | |--------|----------|-------------| | `groupTasks` | Slack | `"timeline"` (default) renders task cards inline; `"plan"` groups them into one plan block | | `endWith` | Slack | Block Kit elements attached when the stream stops (e.g. retry / feedback buttons) | | `updateIntervalMs` | Post+edit adapters | Minimum interval between post+edit cycles in ms (default `500`) | Adapters without structured chunk support extract text from `markdown_text` chunks and ignore other types. Slack-only options are silently ignored on other platforms. ## Stop blocks (Slack only) Use `endWith` on `StreamingPlan` to attach Block Kit elements to the final message. This is useful for adding action buttons after a streamed response completes: ```typescript title="lib/bot.ts" lineNumbers import { StreamingPlan } from "chat"; const planned = new StreamingPlan(textStream, { endWith: [ { type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: "Retry" }, action_id: "retry", }], }, ], }); await thread.post(planned); ``` ## Plan API For step-by-step task progress that lives outside an LLM stream, post a `Plan` directly. `Plan` is a `PostableObject` you can mutate after posting — every mutation re-renders the block in place. ```typescript title="lib/bot.ts" lineNumbers import { Plan } from "chat"; const plan = new Plan({ initialMessage: "Researching options..." }); await thread.post(plan); const lookup = await plan.addTask({ title: "Look up customer record" }); // ...do work... await plan.updateTask("Found 3 matches"); await plan.addTask({ title: "Summarize findings" }); await plan.complete({ completeMessage: "Done!" }); ``` By default `updateTask()` mutates the most recent `in_progress` task. Pass `{ id }` to target a specific task — useful when work runs in parallel or out of order: ```typescript const fetchTask = await plan.addTask({ title: "Fetch data" }); const transformTask = await plan.addTask({ title: "Transform" }); // Update a specific task by id, even if it isn't the most recent in_progress one. await plan.updateTask({ id: fetchTask.id, output: "Got 42 rows" }); await plan.updateTask({ id: transformTask.id, status: "complete" }); ``` Adapters that don't support PostableObject editing (e.g. WhatsApp) render the plan as a fallback emoji-list message; the plan still posts, but mutations are no-ops. | Method | Description | |--------|-------------| | `addTask({ title, children? })` | Append a new task. The previous in-progress task is auto-completed | | `updateTask(input)` | Mutate the current (or `{ id }`-targeted) task's `output`, `status`, or `title` | | `complete({ completeMessage })` | Mark all in-progress tasks complete and update the plan title | | `reset({ initialMessage })` | Discard all tasks and start fresh with a new initial message — useful when re-using a plan handle for a new run | ## Streaming with conversation history Combine message history with streaming for multi-turn AI conversations. Use [`toAiMessages()`](/docs/ai/to-ai-messages) to convert chat messages into the `{ role, content }` format expected by AI SDKs: ```typescript title="lib/bot.ts" lineNumbers import { toAiMessages } from "chat/ai"; bot.onSubscribedMessage(async (thread, message) => { // Fetch recent messages for context const result = await thread.adapter.fetchMessages(thread.id, { limit: 20 }); const history = await toAiMessages(result.messages); const response = await agent.stream({ prompt: history }); await thread.post(response.fullStream); }); ``` See the [`toAiMessages` reference](/docs/ai/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling.