diff --git a/apps/docs/content/docs/api/index.mdx b/apps/docs/content/docs/api/index.mdx
index 9d898d16..df150b30 100644
--- a/apps/docs/content/docs/api/index.mdx
+++ b/apps/docs/content/docs/api/index.mdx
@@ -24,7 +24,7 @@ import { Chat, root, paragraph, text, Card, Button, emoji } from "chat";
| Export | Description |
|--------|-------------|
-| [`toAiMessages`](/docs/streaming#toaimessagesmessages-options) | Convert `Message[]` to AI SDK `{ role, content }[]` format |
+| [`toAiMessages`](/docs/api/to-ai-messages) | Convert `Message[]` to AI SDK `{ role, content }[]` format |
## Message formats
diff --git a/apps/docs/content/docs/api/message.mdx b/apps/docs/content/docs/api/message.mdx
index 6572cc66..54676a6c 100644
--- a/apps/docs/content/docs/api/message.mdx
+++ b/apps/docs/content/docs/api/message.mdx
@@ -186,7 +186,7 @@ Links found in incoming messages are extracted and exposed as `LinkPreview` obje
/>
-When using [`toAiMessages()`](/docs/streaming#toaimessagesmessages-options), link metadata is automatically appended to the message content. Embedded message links are labeled as `[Embedded message: ...]` so the AI model understands the context.
+When using [`toAiMessages()`](/docs/api/to-ai-messages), link metadata is automatically appended to the message content. Embedded message links are labeled as `[Embedded message: ...]` so the AI model understands the context.
### Platform support
diff --git a/apps/docs/content/docs/api/to-ai-messages.mdx b/apps/docs/content/docs/api/to-ai-messages.mdx
new file mode 100644
index 00000000..c2f2adf0
--- /dev/null
+++ b/apps/docs/content/docs/api/to-ai-messages.mdx
@@ -0,0 +1,190 @@
+---
+title: toAiMessages
+description: Convert Chat SDK messages to AI SDK conversation format.
+type: reference
+---
+
+Convert an array of `Message` objects into the `{ role, content }[]` format expected by AI SDKs. The output is structurally compatible with AI SDK's `ModelMessage[]`.
+
+```typescript
+import { toAiMessages } from "chat";
+```
+
+## Usage
+
+```typescript title="lib/bot.ts" lineNumbers
+import { toAiMessages } from "chat";
+
+bot.onSubscribedMessage(async (thread, message) => {
+ 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);
+});
+```
+
+## Signature
+
+```typescript
+function toAiMessages(
+ messages: Message[],
+ options?: ToAiMessagesOptions
+): Promise
+```
+
+### Parameters
+
+
+
+### Options
+
+ AiMessage | null | Promise',
+ },
+ onUnsupportedAttachment: {
+ description: 'Called when an attachment type is not supported (video, audio).',
+ type: '(attachment: Attachment, message: Message) => void',
+ default: 'console.warn',
+ },
+ }}
+/>
+
+### Returns
+
+`Promise` — an array of messages with `role` and `content` fields, directly assignable to AI SDK's `ModelMessage[]`.
+
+## Behavior
+
+- **Role mapping** — `author.isMe === true` maps to `"assistant"`, all others to `"user"`
+- **Filtering** — Messages with empty or whitespace-only text are removed
+- **Sorting** — Messages are sorted chronologically (oldest first) by `metadata.dateSent`
+- **Links** — Link metadata (URL, title, description, site name) is appended to message content. Embedded message links are labeled as `[Embedded message: ...]`
+- **Attachments** — Images and text files (JSON, XML, YAML, etc.) are included as multipart content using `fetchData()`. Video and audio attachments trigger `onUnsupportedAttachment`
+
+## Return types
+
+```typescript
+type AiMessage = AiUserMessage | AiAssistantMessage;
+
+interface AiUserMessage {
+ role: "user";
+ content: string | AiMessagePart[];
+}
+
+interface AiAssistantMessage {
+ role: "assistant";
+ content: string;
+}
+```
+
+User messages have multipart `content` when attachments are present:
+
+```typescript
+type AiMessagePart = AiTextPart | AiImagePart | AiFilePart;
+
+interface AiTextPart {
+ type: "text";
+ text: string;
+}
+
+interface AiImagePart {
+ type: "image";
+ image: DataContent | URL;
+ mediaType?: string;
+}
+
+interface AiFilePart {
+ type: "file";
+ data: DataContent | URL;
+ filename?: string;
+ mediaType: string;
+}
+```
+
+## Examples
+
+### Multi-user context
+
+Prefix each user message with their username so the AI model can distinguish speakers:
+
+```typescript
+const history = await toAiMessages(result.messages, { includeNames: true });
+// [{ role: "user", content: "[alice]: Hello" },
+// { role: "assistant", content: "Hi there!" },
+// { role: "user", content: "[bob]: Thanks" }]
+```
+
+### Transforming messages
+
+Replace raw user IDs with readable names:
+
+```typescript
+const history = await toAiMessages(result.messages, {
+ transformMessage: (aiMessage) => {
+ if (typeof aiMessage.content === "string") {
+ return {
+ ...aiMessage,
+ content: aiMessage.content.replace(/<@U123>/g, "@VercelBot"),
+ };
+ }
+ return aiMessage;
+ },
+});
+```
+
+### Filtering messages
+
+Skip messages from a specific user:
+
+```typescript
+const history = await toAiMessages(result.messages, {
+ transformMessage: (aiMessage, source) => {
+ if (source.author.userId === "U_NOISY_BOT") return null;
+ return aiMessage;
+ },
+});
+```
+
+### Handling unsupported attachments
+
+```typescript
+const history = await toAiMessages(result.messages, {
+ onUnsupportedAttachment: (attachment, message) => {
+ logger.warn(`Skipped ${attachment.type} attachment in message ${message.id}`);
+ },
+});
+```
+
+## Supported attachment types
+
+| Type | MIME types | Included as |
+|------|-----------|-------------|
+| `image` | Any image MIME type | `FilePart` with base64 data |
+| `file` | `text/*`, `application/json`, `application/xml`, `application/javascript`, `application/typescript`, `application/yaml`, `application/toml` | `FilePart` with base64 data |
+| `video` | Any | Skipped (triggers `onUnsupportedAttachment`) |
+| `audio` | Any | Skipped (triggers `onUnsupportedAttachment`) |
+| `file` | Other (e.g. `application/pdf`) | Silently skipped |
+
+
+Attachments require `fetchData()` to be available on the attachment object. Attachments without `fetchData()` are silently skipped.
+
diff --git a/apps/docs/content/docs/handling-events.mdx b/apps/docs/content/docs/handling-events.mdx
index 6497184e..a1988975 100644
--- a/apps/docs/content/docs/handling-events.mdx
+++ b/apps/docs/content/docs/handling-events.mdx
@@ -111,6 +111,8 @@ bot.onSubscribedMessage(async (thread, message) => {
});
```
+See [`toAiMessages`](/docs/api/to-ai-messages) for all options including multi-user name prefixing, message transforms, and attachment handling.
+
### Example: Unsubscribe on keyword
```typescript title="lib/bot.ts" lineNumbers
diff --git a/apps/docs/content/docs/posting-messages.mdx b/apps/docs/content/docs/posting-messages.mdx
index 507dad71..aeccc25e 100644
--- a/apps/docs/content/docs/posting-messages.mdx
+++ b/apps/docs/content/docs/posting-messages.mdx
@@ -151,6 +151,8 @@ await thread.post(result.fullStream);
Both `fullStream` and `textStream` are supported. Use `fullStream` with multi-step agents — it preserves paragraph breaks between steps. Any `AsyncIterable` also works for custom streams.
+For multi-turn conversations, use [`toAiMessages()`](/docs/api/to-ai-messages) to convert thread history into the `{ role, content }[]` format expected by AI SDKs.
+
See the [Streaming](/docs/streaming) page for details on platform behavior and configuration.
## Attachments and files
diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx
index 9377e5a8..d9105dc0 100644
--- a/apps/docs/content/docs/streaming.mdx
+++ b/apps/docs/content/docs/streaming.mdx
@@ -184,7 +184,7 @@ await thread.stream(textStream, {
## Streaming with conversation history
Combine message history with streaming for multi-turn AI conversations.
-Use `toAiMessages()` to convert chat messages into the `{ role, content }` format expected by AI SDKs:
+Use [`toAiMessages()`](/docs/api/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";
@@ -200,51 +200,4 @@ bot.onSubscribedMessage(async (thread, message) => {
});
```
-### `toAiMessages(messages, options?)`
-
-Converts an array of `Message` objects into AI SDK conversation format:
-
-- Maps `author.isMe` to `"assistant"` role, all others to `"user"`
-- Filters out empty messages
-- Sorts chronologically (oldest first)
-- Appends link metadata (URLs, titles, descriptions) when present
-- Labels embedded message links (e.g. shared Slack messages) as `[Embedded message: ...]`
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `includeNames` | `boolean` | `false` | Prefix user messages with `[username]: ` for multi-user context |
-| `transformMessage` | `(aiMessage, source) => AiMessage \| Promise \| null` | — | Transform or filter each message after default processing. Return `null` to skip. |
-| `onUnsupportedAttachment` | `(attachment, message) => void` | `console.warn` | Called when an attachment type is not supported |
-
-### Customizing messages with `transformMessage`
-
-Use `transformMessage` to modify, enrich, or filter messages after default processing:
-
-```typescript title="lib/bot.ts" lineNumbers
-import { toAiMessages } from "chat";
-
-const history = await toAiMessages(result.messages, {
- transformMessage: (aiMessage, source) => {
- // Replace bot user IDs with readable names
- if (typeof aiMessage.content === "string") {
- return {
- ...aiMessage,
- content: aiMessage.content.replace(/<@U123>/g, "@VercelBot"),
- };
- }
- return aiMessage;
- },
-});
-```
-
-Return `null` to skip a message entirely:
-
-```typescript
-const history = await toAiMessages(result.messages, {
- transformMessage: (aiMessage, source) => {
- // Skip messages from a specific user
- if (source.author.userId === "U_NOISY_BOT") return null;
- return aiMessage;
- },
-});
-```
+See the [`toAiMessages` API reference](/docs/api/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling.