chat
Version:
Unified chat abstraction for Slack, Teams, Google Chat, and Discord
260 lines (188 loc) • 9.99 kB
text/mdx
---
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.