Get started with useChat
Build a chat app with the Vercel AI SDK's useChat hook, where each response streams over an Ably channel, so a tab that reloads during a response receives the rest of it.
What you build
A Next.js chat app where:
- The model's response streams into
useChat's messages over an Ably channel. - Reloading the page during a response shows the stored conversation, then the response so far, then the rest of it live.
- A tab you open during a response shows the response so far, then the rest of it live.
- The Stop button stops the agent, and ends the response in the tab.
The conversation lives in your database. AI Transport's chat transport takes the place of useChat's default HTTP transport.
Prerequisites
- Node.js 22 or later.
- An Ably account with an API key.
- An API key for Anthropic, or for any other model provider the Vercel AI SDK supports.
Install dependencies
Install the AI Transport SDK, the Ably Pub/Sub SDKs, the Vercel AI SDK with its React hooks, 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/react @ai-sdk/anthropic next react react-dom
npm pkg set scripts.dev="next dev" scripts.build="next build"The agent's HTTP endpoint creates its Ably client with @ably/pubsub-server, and the browser creates its client with @ably/pubsub-device. AI Transport does not depend on Next.js. This guide uses it as an example.
Add your keys
Create .env.local with your Ably API key and your model provider's key, which the server-side code reads:
ABLY_API_KEY=your-ably-api-key
ANTHROPIC_API_KEY=your-anthropic-api-keySet up authentication
Create app/api/auth/token/route.js, an auth endpoint that returns an Ably token to the browser. The chat transport needs subscribe and history to read the response from the channel, and the Stop button needs publish to publish its cancel message:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Server code. Only the server holds the API key.
import { createHttpClient } from '@ably/pubsub-server';
// Replace with your app's auth: validate the request (session cookie, bearer
// token) and return the authenticated user's id.
async function authenticateUser(req) {
return 'user-abc';
}
const ably = createHttpClient({ key: process.env.ABLY_API_KEY });
export async function GET(req) {
const userId = await authenticateUser(req);
const tokenRequest = await ably.auth.createTokenRequest({
clientId: userId,
capability: { 'conversations:*': ['subscribe', 'history', 'publish'] },
});
return Response.json(tokenRequest);
}The browser's client fetches its token with authUrl: '/api/auth/token'.
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. The rule also persists the channel's messages, so a tab that reloads can replay a response.
This guide uses the channel conversations:demo, so enable the rule for conversations:*, once per Ably app, through the dashboard, Control API, or CLI.
Store the conversation
Create app/store.js. It stands in for your database, and holds each conversation's messages and whether a response is in progress:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Agent code. Replace these functions with queries to your database.
const conversations = new Map();
const running = new Set();
export function loadMessages(chatId) {
return conversations.get(chatId) ?? [];
}
export function saveMessages(chatId, messages) {
conversations.set(chatId, messages);
}
// Returns false if the conversation already has a response in progress.
export function startTurn(chatId) {
if (running.has(chatId)) return false;
running.add(chatId);
return true;
}
export function endTurn(chatId) {
running.delete(chatId);
}Create the agent's HTTP endpoint
Create app/api/chat/route.js. The POST handler saves the conversation, and runs the agent with Next.js after(), so the agent keeps running if the user reloads the page. The handler rejects a new message with a 409 while the conversation's previous response is still in progress, because the chat transport cannot differentiate two responses on one channel. The agent publishes the user's message to the channel for other clients, pipes the model's output to the channel with vercelAdaptor and stops when a cancel message arrives on the 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
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
// Agent code. This runs on your own infrastructure, so it holds the API key.
import { createRealtimeClient } from '@ably/pubsub-server';
import { after } from 'next/server';
import { anthropic } from '@ai-sdk/anthropic';
import { convertToModelMessages, streamText, toUIMessageStream } from 'ai';
import { vercelAdaptor } from '@ably/ai-transport/vercel';
import { endTurn, loadMessages, saveMessages, startTurn } from '../../store';
export async function POST(req) {
// chatId is useChat's id, which is the conversation's channel name.
const { chatId, trigger, messages } = await req.json();
if (!startTurn(chatId)) return new Response('A response is already streaming', { status: 409 });
saveMessages(chatId, messages);
// Call endTurn however the agent ends, even if the agent cannot start.
after(() => respond(chatId, trigger, messages).finally(() => endTurn(chatId)));
return new Response(null, { status: 202 });
}
async function respond(chatId, trigger, messages) {
// A client for this response only. Closing the client when the response ends also removes the cancel subscription.
const ably = createRealtimeClient({ key: process.env.ABLY_API_KEY });
const channel = ably.channels.get(chatId);
const agent = vercelAdaptor(channel);
const controller = new AbortController();
try {
// Subscribing attaches the channel. Wait until the channel is attached before publishing anything,
// so a cancel message sent after the response starts reaches this agent.
await channel.subscribe('cancel', () => controller.abort());
// Publish a new prompt for other clients. A regenerated response reuses a prompt already on
// the channel, and a tool result arrives as 'submit-message' with the assistant's message last.
const prompt = messages.at(-1);
if (trigger === 'submit-message' && prompt.role === 'user') {
await agent.send({ type: 'user-message', message: prompt });
}
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: await convertToModelMessages(messages),
// Abort the model call only, so a stopped response still ends with an abort chunk.
abortSignal: controller.signal,
});
await agent.pipe(
toUIMessageStream({
stream: result.stream,
originalMessages: messages,
// Give each response a new id, which useChat and the store share.
generateMessageId: () => crypto.randomUUID(),
onEnd: ({ messages: conversation }) => saveMessages(chatId, conversation),
}),
);
} catch (error) {
// useChat waits until a chunk ends the response, so publish error.
console.error(error);
await agent.send({ type: 'error', errorText: 'The agent failed to respond.' });
} finally {
ably.close();
}
}
export function GET(req) {
const chatId = new URL(req.url).searchParams.get('chat');
return Response.json({ messages: loadMessages(chatId) });
}Create the chat component
Create app/chat.js. The component gets the conversation's channel from the Ably client, creates the chat transport on the channel and passes the transport to useChat with the stored messages and resume: true. The Stop button appears only once the response is streaming, so the agent has already subscribed to cancel messages when the user presses it. The Stop button publishes a cancel message, and then stops the response in this tab:
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
'use client';
// Client code. The browser fetches a token, so it never holds an API key.
import { useEffect, useMemo, useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { createChatTransport } from '@ably/ai-transport/vercel';
export function Chat({ client, chatId, initialMessages }) {
const channel = client.channels.get(chatId);
const transport = useMemo(() => createChatTransport({ channel }), [channel]);
const { messages, sendMessage, status, stop, error } = useChat({
id: chatId,
messages: initialMessages,
transport,
resume: true,
});
// useChat does not close its transport. Closing the transport leaves the agent running.
useEffect(() => () => transport.close(), [transport]);
const [input, setInput] = useState('');
// Disable sending while this tab's response is in progress.
const busy = status === 'submitted' || status === 'streaming';
const send = (e) => {
e.preventDefault();
const text = input.trim();
if (!text || busy) return;
setInput('');
void sendMessage({ text });
};
const stopResponse = () => {
void channel.publish('cancel', {});
void stop();
};
return (
<div>
{messages.map((m) => (
<p key={m.id}>
<strong>{m.role}:</strong> {m.parts.map((p) => (p.type === 'text' ? p.text : '')).join('')}
</p>
))}
{error && <p>{error.message}</p>}
<form onSubmit={send}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
{status === 'streaming' ? (
<button type="button" onClick={stopResponse}>Stop</button>
) : (
<button type="submit" disabled={busy}>Send</button>
)}
</form>
</div>
);
}Wire it together
Create app/page.js. The page creates the browser's Ably client, loads the stored conversation and shows the chat once both are ready, because useChat reads messages only when it starts:
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
'use client';
// Client code.
import { useEffect, useState } from 'react';
import { createClient } from '@ably/pubsub-device';
import { Chat } from './chat';
const chatId = 'conversations:demo';
export default function Page() {
const [client, setClient] = useState();
const [initialMessages, setInitialMessages] = useState();
useEffect(() => {
const realtime = createClient({ authUrl: '/api/auth/token' });
setClient(realtime);
return () => realtime.close();
}, []);
useEffect(() => {
fetch(`/api/chat?chat=${encodeURIComponent(chatId)}`)
.then((res) => res.json())
.then(({ messages }) => setInitialMessages(messages));
}, []);
if (!client || !initialMessages) return null;
return <Chat client={client} chatId={chatId} initialMessages={initialMessages} />;
}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 and send a message. While the response streams, reload the page, open the app in a second tab or press Stop.
After the reload, the tab shows the response so far, and then the rest of it live. The second tab shows the response too. Pressing Stop stops the agent and ends the response in both tabs.
What happens when you send a message
useChatadds the user's message and calls the chat transport, which POSTs the conversation to the agent's HTTP endpoint.- The endpoint saves the conversation and returns a 202. The agent then runs, and publishes the user's message to the channel.
- The agent pipes the model's output to the channel, and the chat transport passes each chunk to
useChat, which builds the response. - The response ends with
finish,abort, orerror.useChatreturns toreadyafterfinishorabort, and toerroraftererror. The agent saves the conversation when the response ends, unless the pipe failed and thecatchblock published theerror.
Understand the architecture
useChat holds the messages and the status. The chat transport POSTs the conversation to your agent's HTTP endpoint, and passes useChat the chunks it reads from the channel. For what useChat does not cover, such as a turn started on another device, your code can use the channel directly.
Explore next
- Multi-device and fan-out: how every connected client receives the same messages.
- Vercel AI SDK UI: what the chat transport adds to
useChat, and what your code can build on the channel. - Chat transport API reference: every option, and how the transport behaves.
- Get started with the Vercel AI SDK: a chat app whose client code merges each response itself.
- Token streaming: how a response is appended to one message.
- Reconnection and recovery: what happens when a client loses its connection.