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 20 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.

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

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 {
      await run.start();
      await run.loadConversation();

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

      const pipeResult = await run.pipe(result.toUIMessageStream());
      const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
      if (outcome === 'suspend') {
        await run.suspend();
      } else {
        await run.end(outcome);
      }
    } catch (err) {
      await run.end('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 uses Vercel's useChat hook with createChatTransport from @ably/ai-transport/vercel. The Vercel wrapper owns the agent-invocation POST; useChat calls into it as if it were the default HTTP transport.

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

'use client';

import { useMemo, useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { useClientSession } from '@ably/ai-transport/react';
import { createChatTransport } from '@ably/ai-transport/vercel';

export function Chat({ chatId }) {
  const [input, setInput] = useState('');
  const { session } = useClientSession();

  const chatTransport = useMemo(() => createChatTransport(session), [session]);

  const { messages, sendMessage, status, stop } = useChat({
    id: chatId,
    transport: chatTransport,
  });

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

  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. ClientSessionProvider constructs the underlying ClientSession bound to the channel.

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

31

32

'use client';

import { useEffect, useState } from 'react';
import * as Ably from 'ably';
import { AblyProvider } from 'ably/react';
import { ClientSessionProvider } from '@ably/ai-transport/react';
import { UIMessageCodec } from '@ably/ai-transport/vercel';
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() {
  const chatId = 'conversations:my-chat-session';
  return (
    <Providers>
      <ClientSessionProvider channelName={chatId} codec={UIMessageCodec}>
        <Chat chatId={chatId} />
      </ClientSessionProvider>
    </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 an ActiveRun carrying the synchronous inputCodecMessageId. The ChatTransport then calls activeRun.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 for the trigger input event on the channel (rewind plus 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