# 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: - Tokens stream from the model 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 connected 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](https://ably.com/docs/ai-transport/streaming/runs-and-steps.md); you build your own interface, and 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 client, the Vercel AI SDK, and Next.js: ### Shell ``` npm install @ably/ai-transport ably ai @ai-sdk/anthropic next react react-dom ``` 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 JWT to the browser, signed with the user's client id and the channel capabilities they need, as described in [Set up authentication](https://ably.com/docs/ai-transport/setup/authentication.md). 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](https://ably.com/docs/ai-transport/setup/channel-rules.md). ## 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 model through the Vercel AI SDK. 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. ### Javascript ``` // Agent-side. This runs on your own infrastructure, so it holds the key. import { after } from 'next/server'; import { streamText, convertToModelMessages } from 'ai'; import { anthropic } from '@ai-sdk/anthropic'; import * as Ably from 'ably'; import { createAgentTransport } from '@ably/ai-transport'; import { createUIMessageCodec } from '@ably/ai-transport/vercel'; import { appendMessage, loadConversation } from '../../store'; const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY }); const codec = createUIMessageCodec(); export async function POST(req) { const { conversationId, eventId } = await req.json(); const transport = createAgentTransport({ channel: ably.channels.get(conversationId), codec, 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 conversation = [...loadConversation(conversationId), prompt.payload]; appendMessage(conversationId, prompt.payload); const result = streamText({ model: anthropic('claude-sonnet-4-20250514'), messages: await convertToModelMessages(conversation), abortSignal: run.abortSignal, }); const { reason } = await run.pipe(result.toUIMessageStream()); await run.end({ reason }); if (reason === 'complete') { appendMessage(conversationId, { id: crypto.randomUUID(), role: 'assistant', parts: [{ type: 'text', text: await result.text }] }); } } 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. ### Javascript ``` '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 { createUIMessageCodec } from '@ably/ai-transport/vercel'; import { createFold } from './fold'; const codec = createUIMessageCodec(); 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, }); 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: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] }, }); // The SDK publishes; waking the agent is yours. await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ conversationId, eventId: sent.eventId }), }); }; return (
{messages.map((m, i) => (
{m.role}: {m.text}
))}
{ e.preventDefault(); void send(input); setInput(''); }}> setInput(e.target.value)} /> {activeRunId ? ( ) : ( )}
); } ```
`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](https://ably.com/docs/ai-transport/durable-sessions.md) for the APIs that will merge events into conversation messages. ### Javascript ``` // 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); } for (const output of event.outputs) { if (output.type === '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 = (message) => message.parts.filter((p) => p.type === 'text').map((p) => p.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: ### Javascript ``` // 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 appendMessage(conversationId, message) { const existing = conversations.get(conversationId) ?? []; conversations.set(conversationId, [...existing, message]); } ``` ## Wire it together Create `app/page.tsx`. The page reads the stored conversation and passes it to the component as the seed: ### Javascript ``` import { Chat } from './chat'; import { loadConversation } from './store'; export default function Page() { const conversationId = 'conversations:demo'; const seed = loadConversation(conversationId).map((m) => ({ role: m.role, text: m.parts.filter((p) => p.type === 'text').map((p) => p.text).join(''), })); return ; } ``` ## Run the app Start the dev server: ### Shell ``` npm run dev ``` Open `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 1. `publishInput` publishes the user's message on the channel and returns its `codecMessageId` and `eventId`, plus a `runId` promise. Your subscribe handler sees a local echo immediately. 2. Your POST wakes the agent. AI Transport sends no HTTP of its own; the endpoint is yours. 3. The agent calls `locateInput(eventId)` to find that message in channel history, opens a run against it, and pipes the model's output. Every connected client reads the stream. 4. `run.end({ reason })` closes the run, and your route writes the finished message to your store. ## Understand the architecture The Vercel AI SDK owns the model call and the event stream. AI Transport carries that stream over one Ably channel, with the run and step lifecycle on top. [Streaming](https://ably.com/docs/ai-transport/streaming.md) covers who implements what, row by row. ## Explore next - [Cancellation](https://ably.com/docs/ai-transport/streaming/cancellation.md): how a cancel finds its run, and what the agent owes on the way out. - [Interruption and steering](https://ably.com/docs/ai-transport/streaming/interruption-and-steering.md): send into a run that is already streaming. - [History and replay](https://ably.com/docs/ai-transport/streaming/history.md): page the channel backwards, and join it to your own store. - [Runs and steps](https://ably.com/docs/ai-transport/streaming/runs-and-steps.md): the supersede rule the merge encodes. - [Durable sessions](https://ably.com/docs/ai-transport/durable-sessions.md): what replaces the merge and the store. ## Related Topics - [OpenAI](https://ably.com/docs/ai-transport/streaming/quickstart-openai.md): Build a streaming chat app with the OpenAI Responses API and Ably AI Transport streaming. Responses reach every connected client, a stop button cancels the run, and the conversation stays in your own store. - [Custom wire codec](https://ably.com/docs/ai-transport/streaming/quickstart-wire-codec.md): Write encode and decode for a provider Ably AI Transport does not ship a codec for. Declare the descriptor table with defineCodec, then use the same client and agent transports unchanged. ## 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.