---
title: Streaming
description: Stream real-time text responses from AI models and other async sources to chat platforms.
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/api/markdown
  - /docs/concurrency
  - /docs/ai
---

# Streaming



Chat SDK accepts any `AsyncIterable<string>` as a message, so you can stream AI responses and other incremental content to chat platforms as it's generated. Streams from the AI SDK and TanStack AI are detected automatically. On platforms with native or structured streaming support, you can also stream `StreamChunk` objects for rich content such as 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: "xai/grok-4.5",
  instructions: "You are a helpful assistant.",
});

bot.onNewMention(async (thread, message) => {
  const result = await agent.stream({
    prompt: message.text,
    abortSignal: thread.signal,
  });
  await thread.post(result.fullStream);
});
```

Passing `thread.signal` lets platform cancellation stop model generation. In
Slack Agent messaging, clicking the native stop button aborts this signal even
when the stop webhook is handled by another serverless instance using the same
state adapter.

### Why `fullStream` over `textStream`?

When AI SDK agents make tool calls between text steps, `textStream` concatenates all text without separators, producing `"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);
```

For a human-in-the-loop turn, wrap the stream in `StreamingPlan` and leave the
Slack Agent Session suspended after the final chunk:

```typescript
await thread.post(
  new StreamingPlan(result.fullStream, {
    sessionStatus: "suspended",
  })
);
```

## TanStack AI integration

The stream returned by `chat()` from `@tanstack/ai` can be passed straight to `thread.post()`:

```typescript title="lib/bot.ts" lineNumbers
import { chat } from "@tanstack/ai";
import { vercelGatewayText } from "@tanstack/ai-vercel-gateway";

bot.onNewMention(async (thread, message) => {
  const abortController = new AbortController();
  thread.signal.addEventListener("abort", () => abortController.abort(), {
    once: true,
  });

  const stream = chat({
    adapter: vercelGatewayText("anthropic/claude-opus-5"),
    messages: [{ role: "user", content: message.text }],
    abortController,
  });
  await thread.post(stream);
});
```

TanStack AI streams follow the AG-UI protocol. Chat SDK extracts the text from `TEXT_MESSAGE_CONTENT` deltas and treats each `TEXT_MESSAGE_END` as a paragraph break, so when the tool loop runs several model turns their text is separated by `\n\n` (the same behavior as `finish-step` for the AI SDK). Tool call, reasoning, run, and step events are skipped.

This is for text responses. A structured-output run (`chat({ ..., outputSchema, stream: true })`) streams its JSON as `TEXT_MESSAGE_CONTENT` deltas, so posting that stream would show raw JSON in the channel. Format the result yourself and post the string instead.

TanStack's `chat()` takes an `AbortController` rather than a signal. Forwarding `thread.signal` to it, as above, lets platform cancellation stop model generation.

To feed prior thread history into `chat()` and give it Chat SDK tools, use `toTanStackMessages` and `createTanStackTools` from `chat/ai/tanstack`, covered in [TanStack AI](/docs/ai/tanstack-ai).

## 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` to update the message as chunks arrive                                                                                                                                        |
| Telegram    | Post + Edit                           | Posts a message then edits it as chunks arrive (throttled to \~1 edit/s). Set `nativeStreaming: true` to use `sendRichMessageDraft` previews in private chats instead, persisted with `sendRichMessage` |
| 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                                                                                                                                                          |
| 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                                                                                               |
| Notion      | Post + Edit                           | Posts a comment then edits it as chunks arrive (throttled for \~3 req/s)                                                                                                                                |
| 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...",
});
```

On Teams, a custom placeholder becomes an informative status during native direct-message streaming, while group chats edit the placeholder into the final answer. Leave the option unset to preserve native DM and buffered group-chat behavior, or set it to `null` to disable the status.

## Markdown healing

During streaming, chunks often arrive mid-word or mid-syntax. For example, `**bold` can arrive before the closing `**`. The SDK heals incomplete markdown in intermediate renders using [remend](https://www.npmjs.com/package/remend), so messages 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 streamed content contains GFM tables, as LLM output often does, the SDK buffers potential table headers until a separator line (`|---|---|`) confirms them. This stops tables from briefly flashing as raw pipe-delimited text before the table structure is complete. Table buffering needs no configuration.

## 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, such as action buttons that appear 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, and each 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. For parallel work, pass `autoCompletePrevious: false` to `addTask()` so earlier tasks stay in progress; then pass `{ id }` to `updateTask()` to target a specific task when updates run out of order:

```typescript
const fetchTask = await plan.addTask({ title: "Fetch data" });
const transformTask = await plan.addTask({
  title: "Transform",
  autoCompletePrevious: false,
});

// 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?, autoCompletePrevious? })` | Append a new task. When `autoCompletePrevious` is true (default), existing in-progress tasks are marked complete first; pass `false` for parallel workflows |
| `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. Use it to reuse 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.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)