Get started with the Vercel AI SDK
Build a chat app where every subscribed client shows the agent's response as it streams, and a client that reloads picks up where it left off. The same Ably channel can also carry the agent's status and artifacts.
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 database, 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 Pub/Sub server and device SDKs, the Vercel AI SDK, and Next.js, then add the scripts that run the app:
npm install @ably/ai-transport @ably/pubsub-server @ably/pubsub-device ai@^7 @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 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. The rule also persists the channel's messages, so a client that reloads can replay them.
Create the agent's HTTP endpoint
Create app/api/chat/route.js. The POST handler is the agent. It wraps the conversation's channel in a vercelAdaptor, which publishes the AI SDK's chunks to the channel. It publishes the user's message to the channel with send, records it in your database with the serial send returns, calls the model through the Vercel AI SDK, and pipes the model's output to the channel. The requestId header goes on the user's message and 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
// Agent code. This runs on your own infrastructure, so it holds the API key.
import { anthropic } from '@ai-sdk/anthropic';
import { convertToModelMessages, streamText, toUIMessageStream } from 'ai';
import { createRealtimeClient, ErrorInfo } from '@ably/pubsub-server';
import { ErrorCode } from '@ably/ai-transport';
import { vercelAdaptor } from '@ably/ai-transport/vercel';
import { loadConversation, recordMessages } from '../../store';
const ably = createRealtimeClient({ key: process.env.ABLY_API_KEY });
export async function POST(req) {
const { channelName, message } = await req.json();
const headers = { requestId: message.id };
const responseId = crypto.randomUUID();
const adaptor = vercelAdaptor(ably.channels.get(channelName));
// Publish the user's message to the channel, so every subscribed client shows it.
const { serial: promptSerial } = await adaptor.send({ type: 'user-message', message }, { headers });
recordMessages(channelName, [message], promptSerial);
const { messages } = loadConversation(channelName);
try {
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: await convertToModelMessages(messages),
abortSignal: req.signal,
});
let response;
const { serial } = await adaptor.pipe(
toUIMessageStream({
stream: result.stream,
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) {
// Best effort: publishing the abort chunk can fail for the same reason the pipe did.
await adaptor.send({ type: 'abort' }, { headers }).catch((sendError) => console.error('abort publish failed', sendError));
if (error instanceof ErrorInfo && error.code === ErrorCode.OperationCancelled) 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));
}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. The component reads the stored conversation from the agent's HTTP endpoint, gets the channel with its rewind param set to the stored serial, and subscribes a vercelDecoder to the channel. Ably delivers the messages that arrived after the serial, in channel order, then live messages. With no stored serial, the agent has not published to the channel yet, so the component attaches without a rewind.
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. The agent publishes the message to the channel, and the copy the channel delivers replaces the one the component added.
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
'use client';
// Client code. The browser fetches a token, so it never holds an API key.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { vercelDecoder } from '@ably/ai-transport/vercel';
import { usePubSubClient } from '@ably/pubsub-device/react';
import { createMerge } from './merge';
export function Chat({ channelName }) {
const client = usePubSubClient();
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
// 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 (event === undefined) return;
if (event.type === 'user-message') upsert(event.message);
else merge(event, message.extras?.headers?.requestId);
},
[merge, upsert],
);
useEffect(() => {
let channel;
let cancelled = false;
const decoder = vercelDecoder();
decoder.onEvent(apply);
(async () => {
const res = await fetch(`/api/chat?channel=${encodeURIComponent(channelName)}`);
const stored = await res.json();
if (cancelled) return;
setMessages(stored.messages);
// Rewind applies when the channel attaches, so set it before subscribing.
channel = client.channels.get(channelName, stored.serial ? { params: { rewind: stored.serial } } : undefined);
await channel.subscribe(decoder.receive);
})();
return () => {
cancelled = true;
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 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">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 the same time 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 or fails.
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 message.
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 with createClient, and wraps the chat in PubSubClientProvider:
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
'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 (
<PubSubClientProvider client={client}>
<Chat channelName={channelName} />
</PubSubClientProvider>
);
}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 prompt and the response appear in both tabs. 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's HTTP endpoint.
- 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 publishes the message to the channel with
send, records it, calls the model, and pipes the output to the channel with the samerequestId. Every subscribed client shows the message and 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 the channel rewinds to the serial your database holds. Conversation 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.
- Vercel AI SDK API reference:
vercelAdaptorandvercelDecoder.