Get started with the Vercel AI SDK
Build a chat app with the Vercel AI SDK and stream agent output to multiple connected clients in realtime.
What you build
A Next.js chat app where:
- The model's response 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 Anthropic API key, or any other model provider the Vercel AI SDK supports.
Install dependencies
Install the AI Transport SDK, the Ably client, the Vercel AI SDK, and Next.js, then add the scripts that run the app:
npm install @ably/ai-transport ably ai @ai-sdk/anthropic 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 records the user's message in your store, calls the model through the Vercel AI SDK, and pipes the model's output to the channel. The requestId header goes on every message of the response, so a client can tell which prompt each response answers. When the pipe resolves, the route stores the response with the serial of its last message.
If the client closes the request, req.signal cancels the pipe. The pipe waits for the appends it has sent, and repairs any that failed, before it rejects. The route then sends an abort chunk so every client knows the response has ended.
The GET handler returns the stored conversation and its 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
54
55
56
57
58
59
// Agent code. This runs on your own infrastructure, so it holds the API key.
import * as Ably from 'ably';
import { anthropic } from '@ai-sdk/anthropic';
import { convertToModelMessages, streamText, toUIMessageStream } from 'ai';
import { createTransport, ErrorCode } from '@ably/ai-transport';
import { vercel } from '@ably/ai-transport/vercel';
import { loadConversation, recordMessages } from '../../store';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
export async function POST(req) {
const { channelName, message } = await req.json();
recordMessages(channelName, [message]);
const { messages } = loadConversation(channelName);
const headers = { requestId: message.id };
const responseId = crypto.randomUUID();
const transport = createTransport({ client: ably, channelName, codec: vercel });
// Optionally: publish the user's input to the channel
await transport.send({ type: 'user-message', message }, { headers });
try {
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: await convertToModelMessages(messages),
abortSignal: req.signal,
});
let response;
const { serial } = await transport.pipe(
toUIMessageStream({
stream: result.fullStream,
originalMessages: messages,
generateMessageId: () => responseId,
onEnd: ({ responseMessage }) => {
response = responseMessage;
},
}),
{ signal: req.signal, headers },
);
if (response && serial) recordMessages(channelName, [response], serial);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof Ably.ErrorInfo && error.code === ErrorCode.OperationCancelled) {
await transport.send({ type: 'abort' }, { headers });
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));
}generateMessageId gives each response its own message id on its start chunk. Without it, the client merges every response into the same message.
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 message to its list, then wakes the agent with a POST that carries the message. 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
73
74
75
76
77
78
79
80
81
82
83
'use client';
// Client code. The browser fetches a token, so it never holds an API key.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fromSerial, untilEvent } from '@ably/ai-transport';
import { useTransport } from '@ably/ai-transport/react';
import { createMerge } 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 [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const lastSeen = useRef(undefined);
// Put a message in the list by id, so each fuller state of a response replaces the last one.
const upsert = useCallback((message) => {
setMessages((prev) =>
prev.some((m) => m.id === message.id) ? prev.map((m) => (m.id === message.id ? message : m)) : [...prev, message],
);
}, []);
const merge = useMemo(() => createMerge(upsert), [upsert]);
const apply = useCallback(
({ event, message }) => {
if (lastSeen.current === undefined || message.serial > lastSeen.current) lastSeen.current = message.serial;
if (event !== undefined) merge(event, message.extras?.headers?.requestId);
},
[merge],
);
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;
setMessages(stored.messages);
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 message = { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] };
upsert(message);
await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ channelName, message }) });
};
return (
<div>
{messages.map((m) => (
<p key={m.id}>
<strong>{m.role}:</strong> {m.parts.map((p) => (p.type === 'text' ? p.text : '')).join('')}
</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 AI SDK's chunks, and each client merges them into messages with the AI SDK's own readUIMessageStream. The merge opens one stream per response when its start chunk arrives, keyed by the requestId header, so two responses in flight at once each go into their own message. It closes the stream on finish, or on the abort chunk the agent sends when its pipe is cancelled.
The merge reserves the response's place in the list as soon as start arrives. readUIMessageStream produces its first state asynchronously, and without the reservation the user's next message could appear ahead of the response it follows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Client code.
import { readUIMessageStream } from 'ai';
export function createMerge(onMessage) {
const responses = new Map();
return (event, requestId) => {
if (event.type === 'start') {
if (event.messageId) onMessage({ id: event.messageId, role: 'assistant', parts: [] });
const stream = new ReadableStream({ start: (controller) => responses.set(requestId, controller) });
(async () => {
for await (const message of readUIMessageStream({ stream })) onMessage(message);
})();
}
responses.get(requestId)?.enqueue(event);
if (event.type === 'finish' || event.type === 'abort') {
responses.get(requestId)?.close();
responses.delete(requestId);
}
};
}Integrate with your database
Create app/store.js. This Map stands in for the database your application already uses. Each conversation holds its messages and the serial of the last stored response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Stand-in for your own database. Replace both functions with real queries.
const conversations = new Map();
export function loadConversation(channelName) {
return conversations.get(channelName) ?? { messages: [] };
}
export function recordMessages(channelName, messages, serial) {
const current = conversations.get(channelName) ?? { messages: [] };
const merged = [...current.messages];
for (const message of messages) {
const at = merged.findIndex((m) => m.id === message.id);
if (at === -1) merged.push(message);
else merged[at] = message;
}
conversations.set(channelName, { messages: merged, 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 { vercel } from '@ably/ai-transport/vercel';
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={vercel}>
<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 message to its list, and POSTs the message 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 records the message, calls the model, and pipes the output to the channel with the same
requestId. Every subscribed client merges the chunks into the response. - When the pipe resolves, the agent stores the response with the serial of its last message. A client that loads the page later replays the channel from that serial.
Understand the architecture
The Vercel AI SDK owns the model call and the chunk 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.
- Multi-device and fan-out: how every connected client receives the same messages.
- Token streaming: how a response is appended to one message.
- Transport API reference: every method on the transport.