Get started with OpenAI
Build a chat app on the OpenAI Responses API and stream agent output to multiple connected clients in realtime.
What you build
A Next.js chat app where:
- The Responses API's output streams to every connected client in realtime.
- A second tab shows the same conversation, because every subscribed client receives the same messages.
- Reloading the page reads the conversation from your own store, then replays what arrived on the channel after it, so a response still streaming carries on where it is.
The conversation lives in your own database. AI Transport carries the live traffic over an Ably channel, and you build your own interface. The one below is an example.
Prerequisites
- Node.js 22 or later.
- An Ably account with an API key.
- An OpenAI API key.
Install dependencies
Install the AI Transport SDK, the Ably client, the OpenAI SDK, and Next.js, then add the scripts that run the app:
npm install @ably/ai-transport ably openai 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. The client needs the publish, subscribe and history capabilities on the conversation channels.
The client below fetches its token with authUrl: '/api/auth/token'. The agent route 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. The rule also persists the channel's messages, so a client that reloads can replay them.
Create the agent route
Create app/api/chat/route.js. The POST handler is the agent. It loads the stored conversation, calls the Responses API with the stored items and the new input, and pipes the stream to the channel. 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 input and the output items together, 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 nothing for that turn.
The GET handler returns the stored items and their serial to the page.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Agent code. This runs on your own infrastructure, so it holds the API key.
import * as Ably from 'ably';
import OpenAI from 'openai';
import { createTransport } from '@ably/ai-transport';
import { openai } from '@ably/ai-transport/openai';
import { loadConversation, recordItems } from '../../store';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
const client = new OpenAI();
// Pass each event to the pipe, and keep the finished output items for your store.
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 transport = createTransport({ client: ably, channelName, codec: openai });
// Optionally: publish the user's input to the channel
await transport.send({ type: 'input', items });
try {
const stream = await client.responses.create(
{ model: 'gpt-5.5', input: [...history, ...items], stream: true },
{ signal: req.signal },
);
let output = [];
const { serial } = await transport.pipe(keepOutput(stream, (finished) => (output = finished)), {
signal: req.signal,
});
recordItems(channelName, [...items, ...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;
} finally {
await transport.close();
}
}
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. When the transport is ready, the component reads the stored conversation from the agent route and subscribes from its serial with fromSerial. The transport 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 component replays the whole channel with untilEvent, using a predicate that never matches.
Each delivery moves lastSeen forward. After a discontinuity, the component subscribes again from lastSeen, so it receives the messages it missed.
To send, the component adds the user's input items to its entries, then wakes the agent with a POST that carries the items. The agent reads the prompt from the request body.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
'use client';
// Client code. The browser fetches a token, so it never holds an API key.
import { useCallback, useEffect, useRef, useState } from 'react';
import { fromSerial, untilEvent } from '@ably/ai-transport';
import { useTransport } from '@ably/ai-transport/react';
import { mergeEvent, toEntries } from './merge';
// With no stored serial, nothing is stored yet, so replay the whole channel.
const allHistory = untilEvent(() => false);
export function Chat({ channelName }) {
const { transport } = useTransport();
const [entries, setEntries] = useState([]);
const [input, setInput] = useState('');
const lastSeen = useRef(undefined);
const apply = useCallback(({ event, message }) => {
if (lastSeen.current === undefined || message.serial > lastSeen.current) lastSeen.current = message.serial;
if (event !== undefined) setEntries((prev) => mergeEvent(prev, event));
}, []);
useEffect(() => {
if (!transport) return;
let unsubscribe;
let cancelled = false;
const subscribeFrom = (serial) => {
unsubscribe?.();
unsubscribe = transport.subscribe(apply, { history: { replay: serial ? fromSerial(serial) : allHistory } });
};
(async () => {
const res = await fetch(`/api/chat?channel=${encodeURIComponent(channelName)}`);
const stored = await res.json();
if (cancelled) return;
setEntries(toEntries(stored.items));
lastSeen.current = stored.serial;
subscribeFrom(stored.serial);
})();
const off = transport.on('discontinuity', () => subscribeFrom(lastSeen.current));
return () => {
cancelled = true;
off();
unsubscribe?.();
};
}, [transport, channelName, apply]);
const send = async (e) => {
e.preventDefault();
const text = input.trim();
if (!text || !transport) return;
setInput('');
const items = [{ role: 'user', content: text }];
setEntries((prev) => [...prev, ...toEntries(items)]);
await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ channelName, items }) });
};
return (
<div>
{entries.map((entry) => (
<p key={entry.id}>
<strong>{entry.role}:</strong> {entry.text}
</p>
))}
<form onSubmit={send}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" disabled={!transport}>Send</button>
</form>
</div>
);
}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 it renders. 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 user's input when they send it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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 !== '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 response.
1
2
3
4
5
6
7
8
9
10
11
// 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, and wraps the chat in AblyProvider and TransportProvider, which creates the transport for the conversation's channel:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
'use client';
// Client code.
import { useEffect, useState } from 'react';
import * as Ably from 'ably';
import { AblyProvider } from 'ably/react';
import { TransportProvider } from '@ably/ai-transport/react';
import { openai } from '@ably/ai-transport/openai';
import { Chat } from './chat';
const channelName = 'conversations:demo';
export default function Page() {
const [client, setClient] = useState();
useEffect(() => {
const realtime = new Ably.Realtime({ authUrl: '/api/auth/token' });
setClient(realtime);
return () => realtime.close();
}, []);
if (!client) return null;
return (
<AblyProvider client={client}>
<TransportProvider channelName={channelName} codec={openai}>
<Chat channelName={channelName} />
</TransportProvider>
</AblyProvider>
);
}Create app/layout.js. Next.js needs a root layout to build the app:
1
2
3
4
5
6
7
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Run the app
Start the dev server:
npm run devOpen http://localhost:3000 in two tabs and send a message from one. The response streams into both. The prompt travels in the HTTP request that wakes the agent, so it is not on the channel, and the other tab does not receive it in realtime. Multi-device and fan-out shows how to deliver the prompt to every device. 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
- The client adds the user's input items to its entries, and POSTs the items to the agent route.
- The POST wakes the agent, and the agent reads the prompt from the request body. AI Transport does not send HTTP requests of its own.
- 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.
- When the pipe resolves, the agent stores the input and 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 replays the channel from the serial your store holds. Streaming covers who implements what.
Explore next
- History and replay: replay the channel from a stored serial, and read history backwards.
- Reconnection and recovery: what happens when a client loses its connection.
- OpenAI Responses: how the codec carries the Responses API's events.
- OpenAI codec API reference: the events the codec encodes and decodes.
- Transport API reference: every method on the transport.