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
useViewhook, 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-domSet 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.
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.
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.
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
- The user types a message.
view.send(...)mintsinputEventIdand the message'scodecMessageId, publishes the user input on the channel with those ids stamped underextras.ai.transport, and returns anActiveRuncarrying theinputCodecMessageIdthe client owns synchronously. The SDK does not POST to your agent endpoint itself. - Your client code calls
activeRun.toInvocation().toJSON()to get anInvocationDatabody ({ inputEventId, sessionName }), and POSTs it to your agent endpoint. - The agent endpoint receives the POST, calls
Invocation.fromJSON, creates anAgentSession, and starts aRunfrom the Invocation.session.createRun(invocation)mints a freshrunId(for a fresh run) and a freshinvocationId; 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). - The agent streams the LLM response through
run.pipe(...)to the channel. Every output carriesinput-codec-message-idso the client can correlate by an id it owned at send time. - Every client subscribed to the channel receives the streamed messages in real time.
useViewre-renders as the visible Run's projection updates. - 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
- Branching, edit, and regenerate: fork the conversation and navigate alternative branches with
view.regenerate,view.edit, andview.selectSibling. - History and replay:
useViewloads history on mount via thelimitoption. - Cancellation: the Stop button pattern and the agent-side
onCancelauthorisation hook. - Concepts: Runs: the SDK primitive each turn becomes.
- React API reference: providers and the per-hook pages.