Get started with OpenAI

Build a working chat app with the OpenAI Responses API and AI Transport. The ResponsesCodec streams model output over a durable session, so the conversation survives reconnects and syncs across tabs.

What you build

A Next.js chat app where:

  • Output from OpenAI's Responses API streams into a durable session that outlives 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 realtime.
  • A stop button cancels the in-progress Run.

The ResponsesCodec maps the raw Responses event stream onto an Ably channel. You read the conversation with the core useView hook, which gives you branching, edit, regenerate, and pagination directly.

Prerequisites

  • Node.js 22 or later.
  • An Ably account with an API key.
  • An OpenAI API key.

Install dependencies

Install the AI Transport SDK, the Ably client, the OpenAI SDK, and Next.js:

npm install @ably/ai-transport ably openai next react react-dom

AI Transport does not depend on Next.js. This guide uses it because the agent route has to keep streaming after the HTTP response returns, which Next.js provides through after(), and any server that can do the same works unchanged.

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 agent session bound to the ResponsesCodec, starts a run, rebuilds the conversation, opens a Responses stream, pipes it, 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

57

58

59

60

61

62

63

import { after } from 'next/server';
import OpenAI from 'openai';
import * as Ably from 'ably';
import { createAgentSession, Invocation } from '@ably/ai-transport';
import { ResponsesCodec, toResponsesInput } from '@ably/ai-transport/openai';

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

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

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

  await session.connect();

  // No identity is pinned: this run is not retried, so a generated run id and
  // invocation id are correct.
  const run = session.createRun(invocation, {}, { signal: req.signal });

  // Load the full conversation from history before starting the run.
  // run.start() needs to have seen this run's triggering input; draining it
  // from history means run.start() resolves at once instead of waiting for
  // that input to arrive live on the channel.
  while (run.view.hasOlder()) {
    await run.view.loadOlder();
  }

  // Start before returning the response. A continuation re-keys run.runId from
  // the triggering input's headers, so the id is only final once start resolves.
  await run.start();

  // Flatten the conversation into the Responses `input` array. Each stored
  // message already holds valid model input, so no conversion is needed.
  const input = toResponsesInput(run.view.getMessages().map(({ message }) => message));

  after(async () => {
    try {
      const stream = await openai.responses.create(
        { model: 'gpt-5.5', input, stream: true },
        { signal: run.abortSignal },
      );

      // The Responses stream is an async iterable, and each raw event is valid
      // codec output, so pipe it straight through. run.pipe watches
      // run.abortSignal, so a cancel ends the run with no extra handling here.
      const { reason } = await run.pipe(stream);

      await run.end({ reason });
    } catch (err) {
      await run.end({ reason: 'error' });
      throw err;
    } finally {
      await session.end();
    }
  });

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

This route handles a single model turn. To configure server-side tools and run the agentic loop (model turn, run tools, continue), see OpenAI Responses.

Create the chat component

Create app/chat.tsx. The component reads the conversation with useView and sends a user turn with ResponsesCodec.createUserMessage. Because the core SDK never sends HTTP itself, the component POSTs to the agent endpoint after view.send resolves.

An OpenAI message holds a list of items, so rendering flattens each message's text content parts:

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

73

'use client';

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

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

// Flatten an OpenAI message's items into rendered text.
function messageText(message) {
  let text = '';
  for (const item of message.items) {
    if (item.type !== 'message') continue;
    for (const part of item.content) {
      if (part.type === 'output_text' || part.type === 'input_text') text += part.text;
    }
  }
  return text;
}

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

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

  // The Stop button shows only while the latest message's Run is streaming.
  const latestRun = runOf(messages.at(-1)?.codecMessageId ?? '');
  const isStreaming = latestRun?.status === 'active';

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    const text = input;
    setInput('');
    const run = await view.send(
      ResponsesCodec.createUserMessage({
        role: 'user',
        items: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text }] }],
      }),
    );
    await wakeAgent(run);
  };

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

  return (
    <div>
      {messages.map(({ codecMessageId, message }) => (
        <div key={codecMessageId}>
          <strong>{message.role}:</strong> {messageText(message)}
        </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 binds the channel and the ResponsesCodec into AI Transport. ResponsesCodec is a ready-made codec, so you pass it directly with no factory call.

Update channelName to match a namespace with the AIT channel rule 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 { ResponsesCodec } from '@ably/ai-transport/openai';
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={ResponsesCodec}>
        <Chat />
      </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 happens when you send a message

  1. The user types a message. view.send(...) publishes the message on the channel and returns a ClientRun. The SDK does not POST to your agent endpoint itself.
  2. Your client code calls clientRun.toInvocation().toJSON() and POSTs the resulting InvocationData to your agent endpoint. The body identifies the session and the input event the agent should respond to.
  3. The agent endpoint creates an AgentSession bound to the ResponsesCodec and starts a Run. run.start() waits until it has seen the triggering input on the channel, whether loaded from history or arriving live.
  4. The agent reads the full conversation from run.view and calls toResponsesInput to build the Responses input array. Each stored message is already valid Responses input.
  5. The agent opens a streaming Responses API call and pipes the event stream through run.pipe(), which encodes each Responses event onto the channel.
  6. Every client subscribed to the channel decodes the streamed events in realtime. useView re-renders as the visible Run accumulates OpenAI items.
  7. If a client disconnects mid-stream, Ably resumes the subscription from the last serial on reconnect; the SDK rehydrates the view without losing tokens.

Understand the architecture

The OpenAI SDK handles the model call and the typed event stream. AI Transport handles the durable session between agent and devices, encoding the Responses stream onto an Ably channel through the ResponsesCodec. See OpenAI Responses for how the codec works and how to run server-side tools.

Explore next