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:
- Tokens stream from the Responses API to every connected client in realtime.
- A stop button cancels the run in flight, and every tab sees it close.
- A second tab shows the same stream, because every subscribed client receives the same messages.
- Reloading reads the conversation back from your own store.
The conversation never leaves your database. AI Transport carries the live traffic and publishes it inside runs and steps; you build your own interface, and 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:
npm install @ably/ai-transport ably openai next react react-domAI 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 JWT to the browser, signed with the user's client id and the channel capabilities they need, as described in Set up authentication.
The client below fetches from it 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 each response by appending tokens to a single channel message. That needs the Message annotations, updates, deletes, and appends rule (mutableMessages) on the namespace your conversations live on. Enable it once per Ably app through the dashboard, Control API, or CLI.
Create the agent route
Create app/api/chat/route.ts. This file shows the agent side of the application. The code creates the agent transport, connects to the channel, locates the input event that was sent on the invocation, loads the previous conversation messages from your database, and queries the OpenAI Responses API. Once the application has the response, the response chunks are piped directly into the run and onto the Ably channel. The run also carries a reason when it ends, which indicates success or failure of your application code.
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
// Agent-side. This runs on your own infrastructure, so it holds the key.
import { after } from 'next/server';
import OpenAI from 'openai';
import * as Ably from 'ably';
import { createAgentTransport } from '@ably/ai-transport';
import { ResponsesCodec } from '@ably/ai-transport/openai';
import { appendItems, loadConversation } from '../../store';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
const openai = new OpenAI();
export async function POST(req) {
const { conversationId, eventId } = await req.json();
const transport = createAgentTransport({
channel: ably.channels.get(conversationId),
codec: ResponsesCodec,
clientId: 'agent',
});
await transport.connect();
after(async () => {
try {
// locateInput scans channel history for the input carrying this event id.
// It is the one history read the SDK does for you.
const trigger = await transport.locateInput(eventId);
const prompt = trigger?.inputs.find((input) => input.kind === 'message');
// Passing inputCodecMessageId lets a cancel keyed on the input find this
// run, including one that arrived before the run opened.
const run = transport.openRun({ inputCodecMessageId: trigger?.meta.codecMessageId });
const input = [...loadConversation(conversationId), ...prompt.payload.items];
appendItems(conversationId, prompt.payload.items);
const stream = await openai.responses.create(
{ model: 'gpt-5.5', input, stream: true },
{ signal: run.abortSignal },
);
const { reason } = await run.pipe(stream);
await run.end({ reason });
if (reason === 'complete') {
const final = await stream.finalResponse();
appendItems(conversationId, final.output);
}
} finally {
transport.close();
}
});
return Response.json({ ok: true });
}Create the chat component
Create app/chat.tsx. The client publishes the user's input to the transport, and then invokes the agent via HTTP with the eventId that was returned by the transport. This eventId allows the agent to find the exact message that it's meant to start processing.
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
'use client';
// Client-side. The browser fetches a token; never put an API key here.
import { useEffect, useRef, useState } from 'react';
import * as Ably from 'ably';
import { createClientTransport } from '@ably/ai-transport';
import { ResponsesCodec } from '@ably/ai-transport/openai';
import { createFold } from './fold';
export function Chat({ conversationId, seed }) {
const [messages, setMessages] = useState(seed);
const [input, setInput] = useState('');
const [activeRunId, setActiveRunId] = useState(undefined);
const transportRef = useRef(null);
useEffect(() => {
const ably = new Ably.Realtime({ authUrl: '/api/auth/token' });
const fold = createFold();
const transport = createClientTransport({
channel: ably.channels.get(conversationId),
codec: ResponsesCodec,
});
transportRef.current = transport;
transport.subscribe((event) => {
if (event.kind === 'run-lifecycle') {
setActiveRunId(event.event.type === 'start' ? event.event.runId : undefined);
return;
}
fold.apply(event);
setMessages([...seed, ...fold.render()]);
});
void transport.connect();
return () => {
transport.close();
ably.close();
};
}, [conversationId]);
const send = async (text) => {
const sent = await transportRef.current.publishInput({
kind: 'message',
payload: {
role: 'user',
items: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text }] }],
},
});
// The SDK publishes; waking the agent is yours.
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ conversationId, eventId: sent.eventId }),
});
};
return (
<div>
{messages.map((m, i) => (
<div key={i}><strong>{m.role}:</strong> {m.text}</div>
))}
<form onSubmit={(e) => { e.preventDefault(); void send(input); setInput(''); }}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
{activeRunId ? (
<button type="button" onClick={() => transportRef.current.cancel(activeRunId)}>Stop</button>
) : (
<button type="submit">Send</button>
)}
</form>
</div>
);
}publishInput emits a local echo to your subscribe handler, so the user's own message appears without waiting for the round trip.
Merge the model output into conversation messages
Create app/fold.ts.
Most model output comes in chunks, parts, or events. These events are streamed directly over the Ably channel, and delta events like text-streaming are appended together into a single message. You merge these events into whatever your UI renders. AI Transport doesn't do this for you. See durable sessions for the APIs that will merge events into conversation messages.
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
// Client-side. Fold the transport's event stream into a renderable list.
export function createFold() {
const messages = new Map(); // codecMessageId -> { role, text, stepId, stepStartSerial }
const canonical = new Map(); // stepId -> the newest attempt's start serial
function apply(event) {
if (event.kind !== 'message') return;
const { codecMessageId, stepId, stepStartSerial, role } = event.meta;
if (!codecMessageId) return;
// A retry publishes under the same step id with a higher start serial.
// Record the newest, so render drops the attempt it replaced.
if (stepId && stepStartSerial && (canonical.get(stepId) ?? '') < stepStartSerial) {
canonical.set(stepId, stepStartSerial);
}
const entry = messages.get(codecMessageId) ?? { role, text: '', stepId, stepStartSerial };
for (const input of event.inputs) {
if (input.kind === 'message') entry.text = textOf(input.payload.items);
}
for (const output of event.outputs) {
if (output.type === 'response.output_text.delta') entry.text += output.delta;
}
messages.set(codecMessageId, entry);
}
function render() {
return [...messages.values()].filter(
(m) => !m.stepId || canonical.get(m.stepId) === m.stepStartSerial,
);
}
return { apply, render };
}
const textOf = (items) =>
items
.filter((item) => item.type === 'message')
.flatMap((item) => item.content)
.filter((part) => part.type === 'input_text' || part.type === 'output_text')
.map((part) => part.text)
.join('');Integrate with your database
Create app/store.ts. This Map stands in for whatever database your application already uses. You own the conversation at this level, so the store is part of the code here rather than hidden behind a helper.
The Responses API takes its history as an input array of items, so store items rather than a shape of your own and the next turn needs no conversion:
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(conversationId) {
return conversations.get(conversationId) ?? [];
}
export function appendItems(conversationId, items) {
const existing = conversations.get(conversationId) ?? [];
conversations.set(conversationId, [...existing, ...items]);
}Wire it together
Create app/page.tsx. The page reads the stored conversation and passes it to the component as the seed, which is hydration you write yourself rather than something AI Transport provides:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Chat } from './chat';
import { loadConversation } from './store';
export default function Page() {
const conversationId = 'conversations:demo';
const seed = loadConversation(conversationId)
.filter((item) => item.type === 'message')
.map((item) => ({
role: item.role,
text: item.content.filter((p) => p.text !== undefined).map((p) => p.text).join(''),
}));
return <Chat conversationId={conversationId} seed={seed} />;
}Run the app
Start the dev server:
npm run devOpen http://localhost:3000 in two tabs and send a message from one. Tokens stream into both. Press Stop mid-answer and both tabs see the run close. Reload and the conversation comes back from the store rather than the channel.
What happens when you send a message
publishInputpublishes the user's message on the channel and returns itscodecMessageIdandeventId, plus arunIdpromise. Your subscribe handler sees a local echo immediately.- Your POST wakes the agent. AI Transport sends no HTTP of its own; the endpoint is yours.
- The agent calls
locateInput(eventId)to find that message in channel history, opens a run against it, and pipes the Responses stream. Every subscribed client reads it. run.end({ reason })closes the run, and your route writes the finished output items to your store.
Understand the architecture
The OpenAI SDK owns the model call and the typed event stream. AI Transport carries that stream over one Ably channel, with the run and step lifecycle on top. Streaming covers who implements what, row by row, and OpenAI Responses covers what the codec does and does not encode.
Explore next
- Cancellation: how a cancel finds its run, and what the agent owes on the way out.
- Interruption and steering: send into a run that is already streaming.
- History and replay: page the channel backwards, and join it to your own store.
- OpenAI Responses: the codec's scope and the server-side tool loop.
- Durable sessions: what replaces the merge function and the store.