diff --git a/src/components/Examples/ExamplesGrid.tsx b/src/components/Examples/ExamplesGrid.tsx
index c372f8ee84..1fde8d68dc 100644
--- a/src/components/Examples/ExamplesGrid.tsx
+++ b/src/components/Examples/ExamplesGrid.tsx
@@ -32,6 +32,8 @@ const ExamplesGrid = ({
return 'text-blue-600';
case 'liveObjects':
return 'text-green-600';
+ case 'aiTransport':
+ return 'text-cyan-500';
default:
return 'text-orange-700';
}
diff --git a/src/data/content/homepage.ts b/src/data/content/homepage.ts
index 4297274e9e..189063190b 100644
--- a/src/data/content/homepage.ts
+++ b/src/data/content/homepage.ts
@@ -38,6 +38,10 @@ export default {
name: 'liveSync',
link: '/docs/livesync',
},
+ {
+ name: 'aiTransport',
+ link: '/docs/ai-transport',
+ },
],
},
examples: {
diff --git a/src/data/examples/index.ts b/src/data/examples/index.ts
index ce75f4d31a..780f96c66b 100644
--- a/src/data/examples/index.ts
+++ b/src/data/examples/index.ts
@@ -287,6 +287,9 @@ export const products = {
spaces: {
label: 'Spaces',
},
+ aitransport: {
+ label: 'AI Transport',
+ },
};
const useCasesList = [
diff --git a/src/data/index.ts b/src/data/index.ts
index b7bd28c49a..c36331ceb1 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -1,4 +1,12 @@
-import { chatNavData, liveObjectsNavData, liveSyncNavData, platformNavData, pubsubNavData, spacesNavData } from './nav';
+import {
+ aiTransportNavData,
+ chatNavData,
+ liveObjectsNavData,
+ liveSyncNavData,
+ platformNavData,
+ pubsubNavData,
+ spacesNavData,
+} from './nav';
import { languageData } from './languages';
import { PageData, ProductData } from './types';
import homepageContentData from './content/homepage';
@@ -16,6 +24,10 @@ export const productData = {
nav: chatNavData,
languages: languageData.chat,
},
+ aiTransport: {
+ nav: aiTransportNavData,
+ languages: languageData.aiTransport,
+ },
spaces: {
nav: spacesNavData,
languages: languageData.spaces,
diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts
index 605d7eac9d..cd416f9c07 100644
--- a/src/data/languages/languageData.ts
+++ b/src/data/languages/languageData.ts
@@ -29,6 +29,9 @@ export default {
swift: '1.0',
kotlin: '1.0',
},
+ aiTransport: {
+ javascript: '2.11',
+ },
spaces: {
javascript: '0.4',
react: '0.4',
diff --git a/src/data/nav/aitransport.ts b/src/data/nav/aitransport.ts
new file mode 100644
index 0000000000..dd82007afa
--- /dev/null
+++ b/src/data/nav/aitransport.ts
@@ -0,0 +1,36 @@
+import { NavProduct } from './types';
+
+export default {
+ name: 'Ably AI Transport',
+ link: '/docs/ai-transport',
+ icon: {
+ closed: 'icon-gui-prod-ai-transport-outline',
+ open: 'icon-gui-prod-ai-transport-solid',
+ },
+ content: [
+ {
+ name: 'Introduction',
+ pages: [
+ {
+ name: 'About AI Transport',
+ link: '/docs/ai-transport',
+ index: true,
+ },
+ ],
+ },
+ {
+ name: 'Token streaming',
+ pages: [
+ {
+ name: 'Message per response',
+ link: '/docs/ai-transport/features/token-streaming/message-per-response',
+ },
+ {
+ name: 'Message per token',
+ link: '/docs/ai-transport/features/token-streaming/message-per-token',
+ },
+ ],
+ },
+ ],
+ api: [],
+} satisfies NavProduct;
diff --git a/src/data/nav/index.ts b/src/data/nav/index.ts
index aac3975f22..e5ed49fddb 100644
--- a/src/data/nav/index.ts
+++ b/src/data/nav/index.ts
@@ -1,8 +1,17 @@
import platformNavData from './platform';
import pubsubNavData from './pubsub';
import chatNavData from './chat';
+import aiTransportNavData from './aitransport';
import liveObjectsNavData from './liveobjects';
import spacesNavData from './spaces';
import liveSyncNavData from './livesync';
-export { platformNavData, pubsubNavData, chatNavData, liveObjectsNavData, spacesNavData, liveSyncNavData };
+export {
+ platformNavData,
+ pubsubNavData,
+ chatNavData,
+ aiTransportNavData,
+ liveObjectsNavData,
+ spacesNavData,
+ liveSyncNavData,
+};
diff --git a/src/data/types.ts b/src/data/types.ts
index 0884a04a8b..a9c2b2977b 100644
--- a/src/data/types.ts
+++ b/src/data/types.ts
@@ -3,7 +3,7 @@ import { LanguageData } from './languages/types';
import { NavProduct } from './nav/types';
const pageKeys = ['homepage'] as const;
-const productKeys = ['platform', 'pubsub', 'chat', 'spaces', 'liveObjects', 'liveSync'] as const;
+const productKeys = ['platform', 'pubsub', 'chat', 'aiTransport', 'spaces', 'liveObjects', 'liveSync'] as const;
export type ProductKey = (typeof productKeys)[number];
type PageKey = (typeof pageKeys)[number];
diff --git a/src/pages/docs/ai-transport/features/token-streaming/message-per-response.mdx b/src/pages/docs/ai-transport/features/token-streaming/message-per-response.mdx
new file mode 100644
index 0000000000..b91bf42ae1
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/token-streaming/message-per-response.mdx
@@ -0,0 +1,412 @@
+---
+title: Message per response
+meta_description: "Stream individual tokens from AI models into a single message over Ably."
+---
+
+Token streaming with message-per-response is a pattern where every token generated by your model is appended to a single Ably message. Each complete AI response then appears as one message in the channel history while delivering live tokens in realtime. This uses [Ably Pub/Sub](/docs/basics) for realtime communication between agents and clients.
+
+This pattern is useful for chat-style applications where you want each complete AI response stored as a single message in history, making it easy to retrieve and display multi-response conversation history. Each agent response becomes a single message that grows as tokens are appended, allowing clients joining mid-stream to catch up efficiently without processing thousands of individual tokens.
+
+## How it works
+
+1. **Initial message**: When an agent response begins, publish an initial message with `message.create` action to the Ably channel with an empty or the first token as content.
+2. **Token streaming**: Append subsequent tokens to the original message by publishing those tokens with the `message.append` action.
+3. **Live delivery**: Clients subscribed to the channel receive each appended token in realtime, allowing them to progressively render the response.
+4. **Compacted history**: The channel history contains only one message per agent response, which includes all tokens appended to it concatenated together.
+
+You do not need to mark the message or token stream as completed; the final message content will automatically include the full response constructed from all appended tokens.
+
+
+
+## Enable appends
+
+Message append functionality requires the "Message annotations, updates, and deletes" [channel rule](/docs/channels#rules) enabled for your channel or [namespace](/docs/channels#namespaces).
+
+
+
+To enable the channel rule:
+
+1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
+2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
+3. Choose "Add new rule".
+4. Enter a channel name or namespace pattern (e.g. `ai:*` for all channels starting with `ai:`).
+5. Select the "Message annotations, updates, and deletes" rule from the list.
+6. Click "Create channel rule".
+
+The examples on this page use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
+
+## Publishing tokens
+
+Publish tokens from a [Realtime](/docs/api/realtime-sdk) client, which maintains a persistent connection to the Ably service. This allows you to publish at very high message rates with the lowest possible latencies, while preserving guarantees around message delivery order. For more information, see [Realtime and REST](/docs/basics#realtime-and-rest).
+
+[Channels](/docs/channels) separate message traffic into different topics. For token streaming, each conversation or session typically has its own channel.
+
+Use the [`get()`](/docs/api/realtime-sdk/channels#get) method to create or retrieve a channel instance:
+
+
+```javascript
+const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
+```
+
+
+To start streaming an AI response, publish the initial message. The message is identified by a server-assigned identifier called a [`serial`](/docs/messages#properties). Use the `serial` to append each subsequent token to the message as it arrives from the AI model:
+
+
+```javascript
+// Publish initial message and capture the serial for appending tokens
+const { serials: [msgSerial] } = await channel.publish('response', { data: '' });
+
+// Example: stream returns events like { type: 'token', text: 'Hello' }
+for await (const event of stream) {
+ // Append each token as it arrives
+ if (event.type === 'token') {
+ channel.appendMessage(msgSerial, event.text);
+ }
+}
+```
+
+
+When publishing tokens, don't await the `channel.appendMessage()` call. Ably rolls up acknowledgments and debounces them for efficiency, which means awaiting each append would unnecessarily slow down your token stream. Messages are still published in the order that `appendMessage()` is called, so delivery order is not affected.
+
+
+```javascript
+// ✅ Do this - append without await for maximum throughput
+for await (const event of stream) {
+ if (event.type === 'token') {
+ channel.appendMessage(msgSerial, event.text);
+ }
+}
+
+// ❌ Don't do this - awaiting each append reduces throughput
+for await (const event of stream) {
+ if (event.type === 'token') {
+ await channel.appendMessage(msgSerial, event.text);
+ }
+}
+```
+
+
+
+
+This pattern allows publishing append operations for multiple concurrent model responses on the same channel. As long as you append to the correct message serial, tokens from different responses will not interfere with each other, and the final concatenated message for each response will contain only the tokens from that response.
+
+## Subscribing to token streams
+
+Subscribers receive different message actions depending on when they join and how they're retrieving messages. Each message has an `action` field that indicates how to process it, and a `serial` field that identifies which message the action relates to:
+
+- `message.create`: Indicates a new response has started (i.e. a new message was created). The message `data` contains the initial content (often empty or the first token). Store this as the beginning of a new response using `serial` as the identifier.
+- `message.append`: Contains a single token fragment to append. The message `data` contains only the new token, not the full concatenated response. Append this token to the existing response identified by `serial`.
+- `message.update`: Contains the complete response up to that point. The message `data` contains the full concatenated text so far. Replace the entire response content with this data for the message identified by `serial`. This action occurs when the channel needs to resynchronize the full message state, such as after a client [resumes](/docs/connect/states#resume) from a transient disconnection.
+
+
+```javascript
+const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
+
+// Track responses by message serial
+const responses = new Map();
+
+// Subscribe to live messages (implicitly attaches the channel)
+await channel.subscribe((message) => {
+ switch (message.action) {
+ case 'message.create':
+ // New response started
+ responses.set(message.serial, message.data);
+ break;
+ case 'message.append':
+ // Append token to existing response
+ const current = responses.get(message.serial) || '';
+ responses.set(message.serial, current + message.data);
+ break;
+ case 'message.update':
+ // Replace entire response content
+ responses.set(message.serial, message.data);
+ break;
+ }
+});
+```
+
+
+## Client hydration
+
+When clients connect or reconnect, such as after a page refresh, they often need to catch up on complete responses and individual tokens that were published while they were offline or before they joined.
+
+The message per response pattern enables efficient client state hydration without needing to process every individual token and supports seamlessly transitioning from historical responses to live tokens.
+
+
+
+### Using rewind for recent history
+
+The simplest approach is to use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past, and automatically receive all messages since that point. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses:
+
+
+```javascript
+// Use rewind to receive recent historical messages
+const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
+ params: { rewind: '2m' } // or rewind: '10' for message count
+});
+
+// Track responses by message serial
+const responses = new Map();
+
+// Subscribe to receive both recent historical and live messages,
+// which are delivered in order to the subscription
+await channel.subscribe((message) => {
+ switch (message.action) {
+ case 'message.create':
+ // New response started
+ responses.set(message.serial, message.data);
+ break;
+ case 'message.append':
+ // Append token to existing response
+ const current = responses.get(message.serial) || '';
+ responses.set(message.serial, current + message.data);
+ break;
+ case 'message.update':
+ // Replace entire response content
+ responses.set(message.serial, message.data);
+ break;
+ }
+});
+```
+
+
+Rewind supports two formats:
+
+- **Time-based**: Use a time interval like `'30s'` or `'2m'` to retrieve messages from that time period
+- **Count-based**: Use a number like `10` or `50` to retrieve the most recent N messages (maximum 100)
+
+
+
+### Using history for older messages
+
+Use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to paginate back through history to obtain historical responses, while preserving continuity with the delivery of live tokens:
+
+
+```javascript
+const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
+
+// Track responses by message serial
+const responses = new Map();
+
+// Subscribe to live messages (implicitly attaches the channel)
+await channel.subscribe((message) => {
+ switch (message.action) {
+ case 'message.create':
+ // New response started
+ responses.set(message.serial, message.data);
+ break;
+ case 'message.append':
+ // Append token to existing response
+ const current = responses.get(message.serial) || '';
+ responses.set(message.serial, current + message.data);
+ break;
+ case 'message.update':
+ // Replace entire response content
+ responses.set(message.serial, message.data);
+ break;
+ }
+});
+
+// Fetch history up until the point of attachment
+let page = await channel.history({ untilAttach: true });
+
+// Paginate backwards through history
+while (page) {
+ // Messages are newest-first
+ for (const message of page.items) {
+ // message.data contains the full concatenated text
+ responses.set(message.serial, message.data);
+ }
+
+ // Move to next page if available
+ page = page.hasNext() ? await page.next() : null;
+}
+```
+
+
+### Hydrating an in-progress response
+
+A common pattern is to persist complete model responses in your database while using Ably for streaming in-progress responses.
+
+The client loads completed responses from your database, then uses Ably to catch up on any response that was still in progress.
+
+You can hydrate in-progress responses using either the [rewind](#rewind) or [history](#history) pattern.
+
+#### Publishing with correlation metadata
+
+To correlate Ably messages with your database records, include the `responseId` in the message [extras](/docs/messages#properties) when publishing:
+
+
+```javascript
+// Publish initial message with responseId in extras
+const { serials: [msgSerial] } = await channel.publish({
+ name: 'response',
+ data: '',
+ extras: {
+ headers: {
+ responseId: 'resp_abc123' // Your database response ID
+ }
+ }
+});
+
+// Append tokens, including extras to preserve headers
+for await (const event of stream) {
+ if (event.type === 'token') {
+ channel.appendMessage(msgSerial, event.text, {
+ extras: {
+ headers: {
+ responseId: 'resp_abc123'
+ }
+ }
+ });
+ }
+}
+```
+
+
+
+
+#### Hydrate using rewind
+
+When hydrating, load completed responses from your database, then use rewind to catch up on any in-progress response. Check the `responseId` from message extras to skip responses already loaded from your database:
+
+
+```javascript
+// Load completed responses from your database
+// completedResponses is a Set of responseIds
+const completedResponses = await loadResponsesFromDatabase();
+
+// Use rewind to receive recent historical messages
+const channel = realtime.channels.get('ai:responses', {
+ params: { rewind: '2m' }
+});
+
+// Track in-progress responses by responseId
+const inProgressResponses = new Map();
+
+await channel.subscribe((message) => {
+ const responseId = message.extras?.headers?.responseId;
+
+ if (!responseId) {
+ console.warn('Message missing responseId');
+ return;
+ }
+
+ // Skip messages for responses already loaded from database
+ if (completedResponses.has(responseId)) {
+ return;
+ }
+
+ switch (message.action) {
+ case 'message.create':
+ // New response started
+ inProgressResponses.set(responseId, message.data);
+ break;
+ case 'message.append':
+ // Append token to existing response
+ const current = inProgressResponses.get(responseId) || '';
+ inProgressResponses.set(responseId, current + message.data);
+ break;
+ case 'message.update':
+ // Replace entire response content
+ inProgressResponses.set(responseId, message.data);
+ break;
+ }
+});
+```
+
+
+
+
+#### Hydrate using history
+
+Load completed responses from your database, then use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to catch up on any in-progress responses. Use the timestamp of the last completed response to start pagination from that point forward, ensuring continuity with live message delivery.
+
+
+```javascript
+// Load completed responses from database (sorted by timestamp, oldest first)
+const completedResponses = await loadResponsesFromDatabase();
+
+// Get the timestamp of the latest completed response
+const latestTimestamp = completedResponses.latest().timestamp;
+
+const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
+
+// Track in progress responses by ID
+const inProgressResponses = new Map();
+
+// Subscribe to live messages (implicitly attaches)
+await channel.subscribe((message) => {
+ const responseId = message.extras?.headers?.responseId;
+
+ if (!responseId) {
+ console.warn('Message missing responseId');
+ return;
+ }
+
+ // Skip messages for responses already loaded from database
+ if (completedResponses.has(responseId)) {
+ return;
+ }
+
+ switch (message.action) {
+ case 'message.create':
+ // New response started
+ inProgressResponses.set(responseId, message.data);
+ break;
+ case 'message.append':
+ // Append token to existing response
+ const current = inProgressResponses.get(responseId) || '';
+ inProgressResponses.set(responseId, current + message.data);
+ break;
+ case 'message.update':
+ // Replace entire response content
+ inProgressResponses.set(responseId, message.data);
+ break;
+ }
+});
+
+// Fetch history from the last completed response until attachment
+let page = await channel.history({
+ untilAttach: true,
+ start: latestTimestamp,
+ direction: 'forwards'
+});
+
+// Paginate through all missed messages
+while (page) {
+ for (const message of page.items) {
+ const responseId = message.extras?.headers?.responseId;
+
+ if (!responseId) {
+ console.warn('Message missing responseId');
+ continue;
+ }
+
+ // message.data contains the full concatenated text so far
+ inProgressResponses.set(responseId, message.data);
+ }
+
+ // Move to next page if available
+ page = page.hasNext() ? await page.next() : null;
+}
+```
+
+
+
diff --git a/src/pages/docs/ai-transport/features/token-streaming/message-per-token.mdx b/src/pages/docs/ai-transport/features/token-streaming/message-per-token.mdx
new file mode 100644
index 0000000000..71e7fdaf5a
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/token-streaming/message-per-token.mdx
@@ -0,0 +1,433 @@
+---
+title: Message per token
+meta_description: "Stream individual tokens from AI models as separate messages over Ably."
+---
+
+Token streaming with message-per-token is a pattern where every token generated by your model is published as its own Ably message. Each token then appears as one message in the channel history. This uses [Ably Pub/Sub](/docs/basics) for realtime communication between agents and clients.
+
+This pattern is useful when clients only care about the most recent part of a response and you are happy to treat the channel history as a short sliding window rather than a full conversation log. For example:
+
+- **Backend-stored responses**: The backend writes complete responses to a database and clients load those full responses from there, while Ably is used only to deliver live tokens for the current in-progress response.
+- **Live transcription, captioning, or translation**: A viewer who joins a live stream only needs the last few tokens for the current "frame" of subtitles, not the entire transcript so far.
+- **Code assistance in an editor**: Streamed tokens become part of the file on disk as they are accepted, so past tokens do not need to be replayed from Ably.
+- **Autocomplete**: A fresh response is streamed for each change a user makes to a document, with only the latest suggestion being relevant.
+
+## Publishing tokens
+
+Publish tokens from a [Realtime](/docs/api/realtime-sdk) client, which maintains a persistent connection to the Ably service. This allows you to publish at very high message rates with the lowest possible latencies, while preserving guarantees around message delivery order. For more information, see [Realtime and REST](/docs/basics#realtime-and-rest).
+
+[Channels](/docs/channels) separate message traffic into different topics. For token streaming, each conversation or session typically has its own channel.
+
+Use the [`get()`](/docs/api/realtime-sdk/channels#get) method to create or retrieve a channel instance:
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+```
+
+
+When publishing tokens, don't await the `channel.publish()` call. Ably rolls up acknowledgments and debounces them for efficiency, which means awaiting each publish would unnecessarily slow down your token stream. Messages are still published in the order that `publish()` is called, so delivery order is not affected.
+
+
+```javascript
+// ✅ Do this - publish without await for maximum throughput
+for await (const event of stream) {
+ if (event.type === 'token') {
+ channel.publish('token', event.text);
+ }
+}
+
+// ❌ Don't do this - awaiting each publish reduces throughput
+for await (const event of stream) {
+ if (event.type === 'token') {
+ await channel.publish('token', event.text);
+ }
+}
+```
+
+
+This approach maximizes throughput while maintaining ordering guarantees, allowing you to stream tokens as fast as your AI model generates them.
+
+## Streaming patterns
+
+Ably is a pub/sub messaging platform, so you can structure your messages however works best for your application. Below are common patterns for streaming tokens, each showing both agent-side publishing and client-side subscription. Choose the approach that fits your use case, or create your own variation.
+
+### Continuous token stream
+
+For simple streaming scenarios such as live transcription, where all tokens are part of a continuous stream, simply publish each token as a message.
+
+#### Publish tokens
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+
+// Example: stream returns events like { type: 'token', text: 'Hello' }
+for await (const event of stream) {
+ if (event.type === 'token') {
+ channel.publish('token', event.text);
+ }
+}
+```
+
+
+#### Subscribe to tokens
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+
+// Subscribe to token messages
+await channel.subscribe('token', (message) => {
+ const token = message.data;
+ console.log(token); // log each token as it arrives
+});
+```
+
+
+This pattern is simple and works well when you're displaying a single, continuous stream of tokens.
+
+### Token stream with multiple responses
+
+For applications with multiple responses, such as chat conversations, include a `responseId` in message [extras](/docs/messages#properties) to correlate tokens together that belong to the same response.
+
+#### Publish tokens
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+
+// Example: stream returns events like { type: 'token', text: 'Hello', responseId: 'resp_abc123' }
+for await (const event of stream) {
+ if (event.type === 'token') {
+ channel.publish({
+ name: 'token',
+ data: event.text,
+ extras: {
+ headers: {
+ responseId: event.responseId
+ }
+ }
+ });
+ }
+}
+```
+
+
+#### Subscribe to tokens
+
+Use the `responseId` header in message extras to correlate tokens. The `responseId` allows you to group tokens belonging to the same response and correctly handle token delivery for multiple responses, even when delivered concurrently.
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+
+// Track responses by ID
+const responses = new Map();
+
+await channel.subscribe('token', (message) => {
+ const token = message.data;
+ const responseId = message.extras?.headers?.responseId;
+
+ if (!responseId) {
+ console.warn('Token missing responseId');
+ return;
+ }
+
+ // Create an empty response
+ if (!responses.has(responseId)) {
+ responses.set(responseId, '');
+ }
+
+ // Append token to response
+ responses.set(responseId, responses.get(responseId) + token);
+});
+```
+
+
+### Token stream with explicit start/stop events
+
+In some cases, your AI model response stream may include explicit events to mark response boundaries. You can indicate the event type, such as a response start/stop event, using the Ably message name.
+
+#### Publish tokens
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+
+// Example: stream returns events like:
+// { type: 'message_start', responseId: 'resp_abc123' }
+// { type: 'message_delta', responseId: 'resp_abc123', text: 'Hello' }
+// { type: 'message_stop', responseId: 'resp_abc123' }
+
+for await (const event of stream) {
+ if (event.type === 'message_start') {
+ // Publish response start
+ channel.publish({
+ name: 'start',
+ extras: {
+ headers: {
+ responseId: event.responseId
+ }
+ }
+ });
+ } else if (event.type === 'message_delta') {
+ // Publish tokens
+ channel.publish({
+ name: 'token',
+ data: event.text,
+ extras: {
+ headers: {
+ responseId: event.responseId
+ }
+ }
+ });
+ } else if (event.type === 'message_stop') {
+ // Publish response stop
+ channel.publish({
+ name: 'stop',
+ extras: {
+ headers: {
+ responseId: event.responseId
+ }
+ }
+ });
+ }
+}
+```
+
+
+#### Subscribe to tokens
+
+Handle each event type to manage response lifecycle:
+
+
+```javascript
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
+
+const responses = new Map();
+
+// Handle response start
+await channel.subscribe('start', (message) => {
+ const responseId = message.extras?.headers?.responseId;
+ responses.set(responseId, '');
+});
+
+// Handle tokens
+await channel.subscribe('token', (message) => {
+ const responseId = message.extras?.headers?.responseId;
+ const token = message.data;
+
+ const currentText = responses.get(responseId) || '';
+ responses.set(responseId, currentText + token);
+});
+
+// Handle response stop
+await channel.subscribe('stop', (message) => {
+ const responseId = message.extras?.headers?.responseId;
+ const finalText = responses.get(responseId);
+ console.log('Response complete:', finalText);
+});
+```
+
+
+## Client hydration
+
+When clients connect or reconnect, such as after a page refresh, they often need to catch up on tokens that were published while they were offline or before they joined. Ably provides several approaches to hydrate client state depending on your application's requirements.
+
+
+
+
+
+### Using rewind for recent history
+
+The simplest approach is to use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past, and automatically receive all tokens since that point:
+
+
+```javascript
+// Use rewind to receive recent historical messages
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
+ params: { rewind: '2m' } // or rewind: 100 for message count
+});
+
+// Subscribe to receive both recent historical and live messages,
+// which are delivered in order to the subscription
+await channel.subscribe('token', (message) => {
+ const token = message.data;
+
+ // Process tokens from both recent history and live stream
+ console.log('Token received:', token);
+});
+```
+
+
+Rewind supports two formats:
+
+- **Time-based**: Use a time interval like `'30s'` or `'2m'` to retrieve messages from that time period
+- **Count-based**: Use a number like `50` or `100` to retrieve the most recent N messages (maximum 100)
+
+
+
+By default, rewind is limited to the last 2 minutes of messages. This is usually sufficient for scenarios where clients need only recent context, such as for continuous token streaming, or when the response stream from a given model request does not exceed 2 minutes. If you need more than 2 minutes of history, see [Using history for longer persistence](#history).
+
+### Using history for older messages
+
+For applications that need to retrieve tokens beyond the 2-minute rewind window, enable [persistence](/docs/storage-history/storage#all-message-persistence) on your channel. Use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to paginate back through history to obtain historical tokens, while preserving continuity with the delivery of live tokens:
+
+
+```javascript
+// Use a channel in a namespace called 'persisted', which has persistence enabled
+const channel = realtime.channels.get('persisted:{{RANDOM_CHANNEL_NAME}}');
+
+let response = '';
+
+// Subscribe to live messages (implicitly attaches the channel)
+await channel.subscribe('token', (message) => {
+ // Append the token to the end of the response
+ response += message.data;
+});
+
+// Fetch history up until the point of attachment
+let page = await channel.history({ untilAttach: true });
+
+// Paginate backwards through history
+while (page) {
+ // Messages are newest-first, so prepend them to response
+ for (const message of page.items) {
+ response = message.data + response;
+ }
+
+ // Move to next page if available
+ page = page.hasNext() ? await page.next() : null;
+}
+```
+
+
+### Hydrating an in-progress response
+
+A common pattern is to persist complete model responses in your database while using Ably for live token delivery of the in-progress response.
+
+The client loads completed responses from your database, then reaches back into Ably channel history until it encounters a token for a response it's already loaded.
+
+You can retrieve partial history using either the [rewind](#rewind) or [history](#history) pattern.
+
+#### Hydrate using rewind
+
+Load completed responses from your database, then use rewind to catch up on any in-progress responses, skipping any tokens that belong to a response that was already loaded:
+
+
+```javascript
+// Load completed responses from database
+const completedResponses = await loadResponsesFromDatabase();
+
+// Use rewind to receive recent historical messages
+const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
+ params: { rewind: '2m' }
+});
+
+// Track in progress responses by ID
+const inProgressResponses = new Map();
+
+// Subscribe to receive both recent historical and live messages,
+// which are delivered in order to the subscription
+await channel.subscribe('token', (message) => {
+ const token = message.data;
+ const responseId = message.extras?.headers?.responseId;
+
+ if (!responseId) {
+ console.warn('Token missing responseId');
+ return;
+ }
+
+ // Skip tokens for responses already hydrated from database
+ if (completedResponses.has(responseId)) {
+ return;
+ }
+
+ // Create an empty in-progress response
+ if (!inProgressResponses.has(responseId)) {
+ inProgressResponses.set(responseId, '');
+ }
+
+ // Append tokens for new responses
+ inProgressResponses.set(responseId, inProgressResponses.get(responseId) + token);
+});
+```
+
+
+#### Hydrate using history
+
+Load completed responses from your database, then paginate backwards through history to catch up on in-progress responses until you reach a token that belongs to a response you've already loaded:
+
+
+```javascript
+// Load completed responses from database
+const completedResponses = await loadResponsesFromDatabase();
+
+// Use a channel in a namespace called 'persisted', which has persistence enabled
+const channel = realtime.channels.get('persisted:{{RANDOM_CHANNEL_NAME}}');
+
+// Track in progress responses by ID
+const inProgressResponses = new Map();
+
+// Subscribe to live tokens (implicitly attaches)
+await channel.subscribe('token', (message) => {
+ const token = message.data;
+ const responseId = message.extras?.headers?.responseId;
+
+ if (!responseId) {
+ console.warn('Token missing responseId');
+ return;
+ }
+
+ // Skip tokens for responses already hydrated from database
+ if (completedResponses.has(responseId)) {
+ return;
+ }
+
+ // Create an empty in-progress response
+ if (!inProgressResponses.has(responseId)) {
+ inProgressResponses.set(responseId, '');
+ }
+
+ // Append live tokens for in-progress responses
+ inProgressResponses.set(responseId, inProgressResponses.get(responseId) + token);
+});
+
+// Paginate backwards through history until we encounter a hydrated response
+let page = await channel.history({ untilAttach: true });
+
+// Paginate backwards through history
+let done = false;
+while (page && !done) {
+ // Messages are newest-first, so prepend them to response
+ for (const message of page.items) {
+ const token = message.data;
+ const responseId = message.extras?.headers?.responseId;
+
+ // Stop when we reach a response already loaded from database
+ if (completedResponses.has(responseId)) {
+ done = true;
+ break;
+ }
+
+ // Create an empty in-progress response
+ if (!inProgressResponses.has(responseId)) {
+ inProgressResponses.set(responseId, '');
+ }
+
+ // Prepend historical tokens for in-progress responses
+ inProgressResponses.set(responseId, token + inProgressResponses.get(responseId));
+ }
+
+ // Move to next page if available
+ page = page.hasNext() ? await page.next() : null;
+}
+```
+
diff --git a/src/pages/docs/ai-transport/index.mdx b/src/pages/docs/ai-transport/index.mdx
new file mode 100644
index 0000000000..fb2f2b271e
--- /dev/null
+++ b/src/pages/docs/ai-transport/index.mdx
@@ -0,0 +1,6 @@
+---
+title: About AI Transport
+meta_description: "Learn more about Ably's AI Transport and the features that enable you to quickly build functionality into new and existing applications."
+redirect_from:
+ - /docs/products/ai-transport
+---