Get started with Vercel AI SDK

Build a working chat app with Vercel AI SDK and AI Transport. Tokens stream over a durable session, so the conversation survives reconnects and syncs across tabs.

What you build

A Next.js chat app where:

  • Tokens stream from the model into a durable session, not the HTTP response.
  • Closing a tab and reopening it resumes the in-progress response.
  • A second tab on the same session sees the same conversation in real time.
  • A stop button cancels the in-progress Run.

For direct access to the conversation tree, branching, and pagination, see Get started with the Core SDK.

Prerequisites

  • Node.js 22 or later.
  • An Ably account with an API key.
  • An Anthropic API key, or any other model provider supported by Vercel AI SDK.

Install dependencies

npm install @ably/ai-transport ably ai @ai-sdk/react @ai-sdk/anthropic next react react-dom

Set up authentication

Create an auth endpoint at /api/auth/token that returns an Ably JWT to the client. The endpoint validates the user and signs a token with their client ID and the channel capabilities they need. See Set up authentication for the full setup.

The client below uses authUrl: '/api/auth/token' to fetch tokens from this endpoint.

Configure the channel rule

AI Transport streams each response by appending tokens to a single channel message. That requires the Message annotations, updates, deletes, and appends channel rule (mutableMessages) on the namespace your conversations live on.

In your Ably dashboard, enable Message annotations, updates, deletes, and appends on the conversations namespace. See Configure the channel rule for the dashboard, Control API, and CLI steps.

Create the agent route

Create app/api/chat/route.ts. The agent receives an Invocation, creates an AgentSession pre-bound to UIMessageCodec, starts a Run, hydrates the conversation, pipes the LLM stream, and ends the Run.

JavaScript

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

import { after } from 'next/server';
import { streamText, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import * as Ably from 'ably';
import { Invocation } from '@ably/ai-transport';
import { createAgentSession, vercelRunOutcome } from '@ably/ai-transport/vercel';

const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });

export async function POST(req) {
  const invocation = Invocation.fromJSON(await req.json());

  const session = createAgentSession({
    client: ably,
    channelName: invocation.sessionName,
  });

  await session.connect();

  const run = session.createRun(invocation, { signal: req.signal });

  after(async () => {
    try {
      // Rebuild the conversation from run.view before run.start(): draining pages
      // in this run's triggering input (otherwise run.start() awaits it live).
      while (run.view.hasOlder()) {
        await run.view.loadOlder();
      }
      const conversation = run.view.getMessages().map(({ message }) => message);

      await run.start();

      const result = streamText({
        model: anthropic('claude-sonnet-4-20250514'),
        system: 'You are a helpful assistant.',
        messages: await convertToModelMessages(conversation),
        abortSignal: run.abortSignal,
      });

      const pipeResult = await run.pipe(result.toUIMessageStream());
      const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
      if (outcome.reason === 'suspend') {
        await run.suspend();
      } else {
        await run.end(outcome);
      }
    } catch (err) {
      await run.end({ reason: 'error' });
      throw err;
    } finally {
      session.close();
    }
  });

  return Response.json({ runId: run.runId, invocationId: run.invocationId });
}

Create the chat component

Create app/chat.tsx. The component reads the chat transport from the nearest ChatTransportProvider with useChatTransport and passes it to Vercel's useChat hook. The transport owns the agent-invocation POST; useChat calls into it as if it were the default HTTP transport. useMessageSync feeds channel updates back into useChat, so other clients see messages they did not publish.

JavaScript

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';

import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { useChatTransport, useMessageSync } from '@ably/ai-transport/vercel/react';

export function Chat() {
  const [input, setInput] = useState('');
  const { session, chatTransport, chatTransportError } = useChatTransport();

  const { messages, setMessages, sendMessage, status } = useChat({
    transport: chatTransport,
  });

  useMessageSync({ setMessages });

  if (chatTransportError) {
    return <div>Failed to connect: {chatTransportError.message}</div>;
  }

  const isStreaming = status === 'submitted' || status === 'streaming';

  const stop = () => {
    const activeRun = session.view.runs().find((run) => run.status === 'active');
    if (activeRun) void session.cancel(activeRun.runId);
  };

  return (
    <div>
      {messages.map((msg) => (
        <div key={msg.id}>
          <strong>{msg.role}:</strong>{' '}
          {msg.parts.map((part, i) =>
            part.type === 'text' ? <span key={i}>{part.text}</span> : null,
          )}
        </div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim()) return;
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type a message..." />
        {isStreaming ? (
          <button type="button" onClick={stop}>Stop</button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </div>
  );
}

Wire it together

Create app/page.tsx. Providers sets up an authenticated Ably client. ChatTransportProvider constructs the underlying ClientSession bound to the channel and the ChatTransport over it. It POSTs invocations to /api/chat by default, matching the agent route above; set api to point elsewhere.

Update channelName to match a namespace with the AIT channel rules configured.

JavaScript

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

'use client';

import { useEffect, useState } from 'react';
import * as Ably from 'ably';
import { AblyProvider } from 'ably/react';
import { ChatTransportProvider } from '@ably/ai-transport/vercel/react';
import { Chat } from './chat';

function Providers({ children }) {
  const [client, setClient] = useState(null);

  useEffect(() => {
    const ably = new Ably.Realtime({ authUrl: '/api/auth/token', clientId: 'user-abc' });
    setClient(ably);
    return () => ably.close();
  }, []);

  if (!client) return null;
  return <AblyProvider client={client}>{children}</AblyProvider>;
}

export default function Page() {
  return (
    <Providers>
      <ChatTransportProvider channelName="conversations:my-chat-session">
        <Chat />
      </ChatTransportProvider>
    </Providers>
  );
}

Run npm run dev and open http://localhost:3000. Open a second tab to the same URL; both tabs share the same durable session.

What is happening

  1. sendMessage({ text }) calls the ChatTransport's sendMessages. Under the hood, the AI Transport client SDK mints inputEventId and the message's codecMessageId, stamps them on the channel publish under extras.ai.transport, and returns a ClientRun carrying the synchronous inputCodecMessageId. The ChatTransport then calls clientRun.toInvocation().toJSON() and POSTs the resulting InvocationData body ({ inputEventId, sessionName }) to your agent endpoint.
  2. The agent route receives the POST, calls Invocation.fromJSON, creates an AgentSession, and starts a Run. session.createRun(invocation) mints runId (for a fresh run) and invocationId (one per HTTP request); the agent stamps both on every event it publishes and returns them on the response. run.start() waits until the trigger input event has been observed on the channel, whether paged in from history (as the drain above does) or arriving live.
  3. The agent streams the LLM response through run.pipe() to the channel. Outputs carry input-codec-message-id so the client can correlate by an id it owned at send time.
  4. Every client subscribed to the channel decodes the streamed messages in real time. useChat re-renders as UIMessage parts accumulate.
  5. If a client disconnects mid-stream, Ably resumes the subscription from the last serial on reconnect; the session rehydrates without losing tokens.

Understand the architecture

Vercel AI SDK handles model orchestration and UI. AI Transport handles the durable session between agent and devices. See Vercel AI SDK UI for the client-side integration and Vercel AI SDK Core for the server-side integration.

Explore next