# 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](https://ably.com/docs/channels.md), and you build your own interface. The one below is an example. ## Prerequisites - Node.js 22 or later. - An [Ably account](https://ably.com/sign-up) 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: ### Shell ``` 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](https://ably.com/docs/ai-transport/setup/authentication.md). 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](https://ably.com/docs/ai-transport/setup/channel-rules.md). The rule also persists the channel's messages, so a client that reloads can replay them. > **Important** > > Without the rule, the first append fails with error `93002` and the response does not stream. The channel name you use must match the rule's expression. ## 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`](https://ably.com/docs/ai-transport/api/javascript/vercel.md#vercel-adaptor), 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. ### Javascript ``` // 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`](https://ably.com/docs/ai-transport/api/javascript/vercel.md#vercel-decoder) 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](https://ably.com/docs/ai-transport/conversation.md#prompt). The agent publishes the message to the channel, and the copy the channel delivers replaces the one the component added. ### Javascript ``` '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 (
{messages.map((m) => (

{m.role}: {m.parts.map((p) => (p.type === 'text' ? p.text : '')).join('')}

))}
setInput(e.target.value)} />
); } ```
## 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. ### Javascript ``` // 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. ### Javascript ``` // 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`: ### Javascript ``` '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 ( ); } ``` Create `app/layout.js`. Next.js needs a root layout to build the app: ### Javascript ``` export default function RootLayout({ children }) { return ( {children} ); } ``` ## Run the app Start the dev server: ### Shell ``` npm run dev ``` Open `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 1. The client adds the user's message to its list, and POSTs the message to the agent's HTTP endpoint. 2. The POST wakes the agent, and the agent [reads the prompt from the request body](https://ably.com/docs/ai-transport/conversation.md#prompt). AI Transport does not send HTTP requests of its own. 3. The agent publishes the message to the channel with `send`, records it, calls the model, and pipes the output to the channel with the same `requestId`. Every subscribed client shows the message and merges the chunks into the response. 4. 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](https://ably.com/docs/ai-transport/conversation.md) covers who implements what. ## Explore next - [History and replay](https://ably.com/docs/ai-transport/conversation/history.md): replay the channel from a stored serial, and read history backwards. - [Reconnection and recovery](https://ably.com/docs/ai-transport/conversation/reconnection-and-recovery.md): what happens when a client loses its connection. - [Multi-device and fan-out](https://ably.com/docs/ai-transport/conversation/multi-device.md): how every connected client receives the same messages. - [Token streaming](https://ably.com/docs/ai-transport/conversation/token-streaming.md): how a response is appended to one message. - [Vercel AI SDK API reference](https://ably.com/docs/ai-transport/api/javascript/vercel.md): `vercelAdaptor` and `vercelDecoder`. ## Related Topics - [OpenAI responses](https://ably.com/docs/ai-transport/get-started/openai.md): Build a streaming chat app with the OpenAI Responses API and Ably AI Transport. Responses reach every connected client, and a client that reloads reads the conversation from your database, then replays what arrived on the channel after it. - [Custom events](https://ably.com/docs/ai-transport/get-started/custom-events.md): Build a Next.js chat app with your custom events. Define your event format with defineCodec, stream it from the agent with an adaptor, and decode it in every subscribed client. ## Documentation Index To discover additional Ably documentation: 1. Fetch [llms.txt](https://ably.com/llms.txt) for the canonical list of available pages. 2. Identify relevant URLs from that index. 3. Fetch target pages as needed. Avoid using assumed or outdated documentation paths.