Get started with the Core SDK

Build a chat app using AI Transport's core React hooks directly. You get the conversation tree, branch navigation, and pagination without going through a framework wrapper.

What you build

A Next.js chat app where:

  • Tokens stream from the model into a durable session.
  • The client reads the conversation through the useView hook, with direct access to the conversation tree.
  • Branching, edit, regenerate, and pagination are first-class.
  • A Stop button cancels the in-progress Run.

To use AI Transport with Vercel's useChat for message management, see Get started with Vercel AI SDK.

Prerequisites

  • Node.js 22 or later.
  • An Ably account with an API key.
  • An Anthropic API key, or any other model provider you prefer.

Install dependencies

npm install @ably/ai-transport ably ai @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, 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

import { after } from 'next/server';
import { streamText, convertToModelMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import * as Ably from 'ably';
import { createAgentSession, Invocation } from '@ably/ai-transport';
import { UIMessageCodec } 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,
    codec: UIMessageCodec,
  });

  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 { reason } = await run.pipe(result.toUIMessageStream());
      await run.end({ reason });
    } 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 uses useView directly. It returns the visible messages, write methods, branch navigation, and pagination. Because the core SDK doesn't POST to the agent endpoint itself, the component does it after view.send resolves.

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

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

'use client';

import { useState } from 'react';
import { useClientSession, useView } from '@ably/ai-transport/react';
import { UIMessageCodec } from '@ably/ai-transport/vercel';

async function wakeAgent(run) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(run.toInvocation().toJSON()),
  });
  return res.json();
}

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

  const { session } = useClientSession();
  const view = useView({ limit: 30 });
  const { messages, runOf } = view;

  // `'active'`, `'suspended'`, and `'error'` keep the Stop button visible
  // so a stuck Run can still be cancelled; `'complete'` and `'cancelled'`
  // are terminal.
  const latestRun = runOf(messages.at(-1)?.codecMessageId ?? '');
  const isStreaming =
    latestRun !== undefined &&
    latestRun.status !== 'complete' &&
    latestRun.status !== 'cancelled';

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    const text = input;
    setInput('');
    const run = await view.send(UIMessageCodec.createUserMessage({
      id: crypto.randomUUID(),
      role: 'user',
      parts: [{ type: 'text', text }],
    }));
    await wakeAgent(run);
  };

  const stop = async () => {
    if (latestRun) await session.cancel(latestRun.runId);
  };

  return (
    <div>
      {view.hasOlder && (
        <button type="button" onClick={() => view.loadOlder()}>Load older messages</button>
      )}
      {messages.map(({ codecMessageId, message }) => (
        <div key={codecMessageId}>
          <strong>{message.role}:</strong>{' '}
          {message.parts.map((part, i) =>
            part.type === 'text' ? <span key={i}>{part.text}</span> : null,
          )}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <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 wires the channel and codec into AI Transport.

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' });
    setClient(ably);
    return () => ably.close();
  }, []);

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

export default function Page() {
  const channelName = 'conversations:my-chat-session';
  return (
    <Providers>
      <ClientSessionProvider channelName={channelName} codec={UIMessageCodec}>
        <Chat />
      </ClientSessionProvider>
    </Providers>
  );
}

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

What is happening

  1. The user types a message. view.send(...) mints inputEventId and the message's codecMessageId, publishes the user input on the channel with those ids stamped under extras.ai.transport, and returns a ClientRun carrying the inputCodecMessageId the client owns synchronously. The SDK does not POST to your agent endpoint itself.
  2. Your client code calls clientRun.toInvocation().toJSON() to get an InvocationData body ({ inputEventId, sessionName }), and POSTs it to your agent endpoint.
  3. The agent endpoint receives the POST, calls Invocation.fromJSON, creates an AgentSession, and starts a Run from the Invocation. session.createRun(invocation) mints a fresh runId (for a fresh run) and a fresh invocationId; the agent returns both on the response so callers can observe them. 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.
  4. The agent streams the LLM response through run.pipe(...) to the channel. Every output carries input-codec-message-id so the client can correlate by an id it owned at send time.
  5. Every client subscribed to the channel receives the streamed messages in real time. useView re-renders as the visible Run's projection updates.
  6. If a client disconnects mid-stream, Ably resumes the subscription from the last serial on reconnect; the SDK rehydrates the view automatically.

useView subscribes to the conversation tree and returns the visible messages along the currently selected branch. The hook exposes messages, hasOlder, loading, loadError, loadOlder, runOf, run, runs, branchSelection, send, regenerate, and edit. Switch siblings through the branchSelection handle's select. It is the reactive mirror of the ClientView interface.

Explore next