### Shell
```
npm install @ably/ai-transport @ably/pubsub-server @ably/pubsub-device openai@^6 next react react-dom
npm pkg set scripts.dev="next dev" scripts.build="next build"
```
AI Transport does not depend on Next.js. This guide uses it as an example.
## Set up authentication
Create an auth endpoint at `/api/auth/token` that returns an Ably token to the browser, with the client id and the channel capabilities the user needs, as described in [Set up authentication](https://ably.com/docs/ai-transport/setup/authentication.md). The client needs the `subscribe` and `history` capabilities on the conversation channels.
The client below fetches its token with `authUrl: '/api/auth/token'`. The agent's HTTP endpoint runs on your own infrastructure, so it uses the API key directly.
## Configure the channel rule
AI Transport streams a response's text by appending each delta to a channel message. That needs the **Message annotations, updates, deletes, and appends** rule (`mutableMessages: true` in the Control API) on the channels your conversations use. Enable it once per Ably app through the [dashboard, Control API, or CLI](https://ably.com/docs/ai-transport/setup/channel-rules.md). The rule also persists the channel's messages, so a client that reloads can replay them.
> **Important**
>
> Without the rule, the first append fails with error `93002` and the response does not stream. The channel name you use must match the rule's expression.
## Create the agent's HTTP endpoint
Create `app/api/chat/route.js`. The `POST` handler is the agent. It loads the stored conversation, publishes the new input to the channel with `send`, calls the Responses API with the stored items and the new input, and pipes the stream to the channel. The agent stores the input items with the serial that `send` returns, so a client that loads while the response streams replays the whole response. A small generator passes each event to the pipe and keeps the finished output items from the `response.completed` event. When the pipe resolves, the route stores the output items, with the serial of the last message the pipe published.
If the client closes the request, `req.signal` cancels both the model call and the pipe, and the route stores no output for that turn.
The `GET` handler returns the stored items and their serial to the page.
### Javascript
```
// Agent code. This runs on your own infrastructure, so it holds the API key.
import OpenAI from 'openai';
import { openaiAdaptor } from '@ably/ai-transport/openai';
import { createRealtimeClient } from '@ably/pubsub-server';
import { loadConversation, recordItems } from '../../store';
const ably = createRealtimeClient({ key: process.env.ABLY_API_KEY });
const client = new OpenAI();
// Pass each event to the pipe, and keep the finished output items for your database.
async function* keepOutput(stream, onOutput) {
for await (const event of stream) {
if (event.type === 'response.completed') onOutput(event.response.output);
yield event;
}
}
export async function POST(req) {
const { channelName, items } = await req.json();
const { items: history } = loadConversation(channelName);
const adaptor = openaiAdaptor(ably.channels.get(channelName));
// Publish the user's input to the channel, and store it with its serial.
const { serial: inputSerial } = await adaptor.send({ type: 'input', items });
recordItems(channelName, items, inputSerial);
try {
const stream = await client.responses.create(
{ model: 'gpt-5.5', input: [...history, ...items], stream: true },
{ signal: req.signal },
);
let output = [];
const { serial } = await adaptor.pipe(keepOutput(stream, (finished) => (output = finished)), {
signal: req.signal,
});
recordItems(channelName, output, serial);
return new Response(null, { status: 204 });
} catch (error) {
// The client closed the request, so the model call or the pipe was cancelled.
if (req.signal.aborted) return new Response(null, { status: 204 });
throw error;
}
}
export function GET(req) {
const channelName = new URL(req.url).searchParams.get('channel');
return Response.json(loadConversation(channelName));
}
```
The codec sends the text that streamed as deltas once, so the `response.completed` event on the channel carries an empty `output`. The agent reads the output from the stream before the codec encodes it, so the items it stores keep their text.
## Create the chat component
Create `app/chat.js`. The component reads the stored conversation from the agent's HTTP endpoint, and gets the channel with a `rewind` to its serial. It subscribes an `openaiDecoder()` to the channel. When the channel attaches, Ably replays the messages that arrived after the serial, in channel order, then delivers live messages. With no stored serial, nothing is stored yet, so the channel attaches without a rewind.
To send, the component wakes the agent with a POST that carries the user's input items. The agent [reads the prompt from the request body](https://ably.com/docs/ai-transport/conversation.md#prompt), and publishes it to the channel, so every subscribed client receives it.
### Javascript
```
'use client';
// Client code. The browser fetches a token, so it never holds an API key.
import { useCallback, useEffect, useState } from 'react';
import { openaiDecoder } from '@ably/ai-transport/openai';
import { usePubSubClient } from '@ably/pubsub-device/react';
import { mergeEvent, toEntries } from './merge';
export function Chat({ channelName }) {
const client = usePubSubClient();
const [entries, setEntries] = useState([]);
const [input, setInput] = useState('');
const apply = useCallback(({ event }) => {
if (event !== undefined) setEntries((prev) => mergeEvent(prev, event));
}, []);
useEffect(() => {
let channel;
let decoder;
let cancelled = false;
(async () => {
const res = await fetch(`/api/chat?channel=${encodeURIComponent(channelName)}`);
const stored = await res.json();
if (cancelled) return;
setEntries(toEntries(stored.items));
// Replay the messages after the stored serial. With no stored serial, nothing is stored yet.
channel = client.channels.get(channelName, stored.serial ? { params: { rewind: stored.serial } } : undefined);
decoder = openaiDecoder();
decoder.onEvent(apply);
await channel.subscribe(decoder.receive);
})();
return () => {
cancelled = true;
if (!decoder) return;
channel.unsubscribe(decoder.receive);
// Detach, so the channel rewinds again when the effect runs again.
channel.detach().catch(() => {});
};
}, [client, channelName, apply]);
const send = async (e) => {
e.preventDefault();
const text = input.trim();
if (!text) return;
setInput('');
const items = [{ role: 'user', content: text }];
await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ channelName, items }) });
};
return (
{entries.map((entry) => (
{entry.role}: {entry.text}
))}
);
}
```
## Merge the model output into conversation messages
Create `app/merge.js`. The agent publishes the Responses API's own stream events, and each client merges them into the entries its UI shows. Each `response.output_text.delta` appends its text to the entry for its output item, keyed by `item_id`, so a response builds up one entry as its deltas arrive. `toEntries` turns items into the same entries, for the stored items when the page loads and for the input items of each `input` event.
### Javascript
```
// Client code.
const textOf = (content) =>
typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('');
export function toEntries(items) {
return items
.filter((item) => item.role === 'user' || item.role === 'assistant')
.map((item) => ({ id: item.id ?? crypto.randomUUID(), role: item.role, text: textOf(item.content) }));
}
export function mergeEvent(entries, event) {
if (event.type === 'input') return [...entries, ...toEntries(event.items)];
if (event.type !== 'response.output_text.delta') return entries;
if (!entries.some((entry) => entry.id === event.item_id)) {
return [...entries, { id: event.item_id, role: 'assistant', text: event.delta }];
}
return entries.map((entry) => (entry.id === event.item_id ? { ...entry, text: entry.text + event.delta } : entry));
}
```
## Integrate with your database
Create `app/store.js`. This `Map` stands in for the database your application already uses. The Responses API takes its history as an `input` array of items, so the store keeps items, and the next turn passes them to the model unchanged. It also keeps the serial of the last stored input or response.
### Javascript
```
// Stand-in for your own database. Replace both functions with real queries.
const conversations = new Map();
export function loadConversation(channelName) {
return conversations.get(channelName) ?? { items: [] };
}
export function recordItems(channelName, items, serial) {
const current = loadConversation(channelName);
conversations.set(channelName, { items: [...current.items, ...items], serial: serial ?? current.serial });
}
```
## Wire it together
Create `app/page.js`. The page creates the Ably client in the browser with `createClient`, and wraps the chat in `PubSubClientProvider`:
### Javascript
```
'use client';
// Client code.
import { useEffect, useState } from 'react';
import { createClient } from '@ably/pubsub-device';
import { PubSubClientProvider } from '@ably/pubsub-device/react';
import { Chat } from './chat';
const channelName = 'conversations:demo';
export default function Page() {
const [client, setClient] = useState();
useEffect(() => {
const ably = createClient({ authUrl: '/api/auth/token' });
setClient(ably);
return () => ably.close();
}, []);
if (!client) return null;
return (
);
}
```
Create `app/layout.js`. Next.js needs a root layout to build the app:
### Javascript
```
export default function RootLayout({ children }) {
return (
{children}
);
}
```
## Run the app
Start the dev server:
### Shell
```
npm run dev
```
Open `http://localhost:3000` in two tabs and send a message from one. The response streams into both. The agent publishes the prompt to the channel, so both tabs show it. Reload a tab while a response is streaming, and the tab shows the stored conversation, then the response so far, then the rest of it live.
## What happens when you send a message
1. The client POSTs the user's input items to the agent's HTTP endpoint.
2. The POST wakes the agent, and the agent [reads the prompt from the request body](https://ably.com/docs/ai-transport/conversation.md#prompt). AI Transport does not send HTTP requests of its own. The agent publishes the prompt to the channel with `send`, and stores the input items with the serial that `send` returns.
3. The agent calls the Responses API with the stored items and the new input, and pipes the stream to the channel. Every subscribed client merges the deltas into the response.
4. When the pipe resolves, the agent stores the output items with the serial of the last message. A client that loads the page later replays the channel from that serial.
## Understand the architecture
The OpenAI SDK owns the model call and the event stream. AI Transport carries that stream over one Ably channel, and the channel replays from the serial your database holds. [Conversation](https://ably.com/docs/ai-transport/conversation.md) covers who implements what.
## Explore next
- [History and replay](https://ably.com/docs/ai-transport/conversation/history.md): replay the channel from a stored serial, and read history backwards.
- [Reconnection and recovery](https://ably.com/docs/ai-transport/conversation/reconnection-and-recovery.md): what happens when a client loses its connection.
- [OpenAI Responses](https://ably.com/docs/ai-transport/frameworks/openai.md): how the codec carries the Responses API's events.
- [OpenAI API reference](https://ably.com/docs/ai-transport/api/javascript/openai.md): `openaiAdaptor`, `openaiDecoder`, and the events the codec encodes and decodes.
- [Agent adaptor API reference](https://ably.com/docs/ai-transport/api/javascript/agent-adaptor.md): every method on the adaptor.
## Related Topics
- [Vercel AI SDK](https://ably.com/docs/ai-transport/get-started/vercel-ai-sdk.md): Build a streaming chat app with the Vercel AI SDK and Ably AI Transport. Responses reach every connected client, and a client that reloads reads the conversation from your database, then replays what arrived on the channel after it.
- [Custom events](https://ably.com/docs/ai-transport/get-started/custom-events.md): Build a Next.js chat app with your custom events. Define your event format with defineCodec, stream it from the agent with an adaptor, and decode it in every subscribed client.
## Documentation Index
To discover additional Ably documentation:
1. Fetch [llms.txt](https://ably.com/llms.txt) for the canonical list of available pages.
2. Identify relevant URLs from that index.
3. Fetch target pages as needed.
Avoid using assumed or outdated documentation paths.