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

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

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 {
      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 { reason } = await run.pipe(result.toUIMessageStream());
      await run.end(reason);
    } 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 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 an ActiveRun carrying the inputCodecMessageId the client owns synchronously. The SDK does not POST to your agent endpoint itself.
  2. Your client code calls activeRun.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 for the trigger input event on the channel (rewind + 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, selectSibling, send, regenerate, and edit. It is the reactive mirror of the View interface.

Explore next