Multi-device and fan-out

Every client subscribed to the conversation gets the same messages, because Ably delivers every publish to all of them. A user starts on a laptop, picks up a phone, and the response carries on where it was.

A user can start a conversation on a laptop and carry on from a phone. Every client subscribed to the conversation's Ably channel receives every message on it, in the same order, so each device shows the same response as it streams.

Diagram showing a user switching from laptop to phone mid-response, with the response continuing uninterrupted on the new device

Each device creates a transport for the same channel and subscribes:

OpenAI
Vercel AI SDK
JavaScript

1

2

3

4

5

6

7

8

9

10

11

// Client code, on the laptop and on the phone alike.
import * as Ably from 'ably';
import { createTransport } from '@ably/ai-transport';
import { openai } from '@ably/ai-transport/openai';

const ably = new Ably.Realtime({ authUrl: '/auth' });
const transport = createTransport({ client: ably, channelName: 'conversations:42', codec: openai });

transport.subscribe(({ event }) => {
  if (event !== undefined) merge(event);
});

How it works

Every client subscribed to the channel receives every message published to it. A user's message from one device, and the agent's response to it, reach all of them in the same order, because Ably does the routing.

A device receives its own publishes too. The transport's echoMessages option defaults to true, so a message a client sends comes back to it as an ordinary delivery, and every device shows the user's message from the same source. Set echoMessages: false to render what you sent from the event itself, and match it up with later messages on the serial that send() returns.

Tell your own messages from other clients'

Each delivery carries the Ably message it came from. Two fields tell you where it came from:

FieldWhat it tells you
message.clientIdWhich client published this message.
message.extras.headersThe headers of the send() or pipe() call that published it, such as a requestId that ties a response to the prompt it answers.

The agent can get a prompt from the HTTP request that wakes it, or from the channel. A prompt in the HTTP request only reaches the agent, but a prompt published to the channel reaches every subscribed device in realtime. The samples below publish each prompt with send(), so the user's other devices receive the prompt in realtime, and the HTTP request only wakes the agent.

A device can keep the ids of the prompts it sent, and match a response to them by its requestId header:

OpenAI
Vercel AI SDK
JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

// Client code.
const myRequests = new Set();

async function sendPrompt(items) {
  const requestId = crypto.randomUUID();
  myRequests.add(requestId);
  await transport.send({ type: 'input', items }, { headers: { requestId } });
  // Wake the agent. It reads the prompt from the channel.
  await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ channelName: 'conversations:42', requestId }) });
}

transport.subscribe(({ event, message }) => {
  const requestId = message.extras?.headers?.requestId;
  if (event?.type === 'response.output_item.added') showResponse(requestId, { mine: myRequests.has(requestId) });
});

Publish the prompt from the agent

The client can also keep the prompt in the HTTP request, and the agent publishes it to the channel before it pipes the response. Other devices then receive the prompt from the channel, and the agent reads it from the request body. The published prompt carries the agent's clientId, so the device that sent it matches it by its requestId header:

OpenAI
Vercel AI SDK
JavaScript

1

2

3

4

5

6

7

8

9

10

11

// Agent code.
const { channelName, items, requestId } = await req.json();
const transport = createTransport({ client: ably, channelName, codec: openai });
const headers = { requestId };

await transport.send({ type: 'input', items }, { headers });
const stream = await new OpenAI().responses.create(
  { model: 'gpt-5.5', input: [...(await loadConversation(channelName)), ...items], stream: true },
  { signal: req.signal },
);
await transport.pipe(stream, { signal: req.signal, headers });

Handle late joiners

A device that subscribes after the conversation started has not seen what came before. It loads the conversation from your store and subscribes with a replay from the stored serial, so it receives the rest of the conversation in order, then live messages:

JavaScript

1

2

3

4

5

6

7

// Client code, on a device that joins later.
import { fromSerial } from '@ably/ai-transport';

const stored = await loadConversation('conversations:42');
render(stored.messages);

transport.subscribe(apply, { history: { replay: fromSerial(stored.serial) } });

A device that does not hold a serial can replay back to an event it recognises with untilEvent, as history and replay describes. If a response is streaming as the device joins, the device receives the response's text so far in one delivery, then the remaining appends live.

Identify the client

Each client carries a clientId that identifies it on every message it publishes. Issue it from your token endpoint rather than setting it on the client, so Ably verifies it and it cannot be spoofed:

JavaScript

1

2

3

4

5

// Agent code, in your token endpoint.
const token = jwt.sign({
  'x-ably-clientId': 'user-123',
  // ...
}, keySecret);

The clientId appears as message.clientId on every message the client publishes. Set up authentication covers issuing it.

Edge cases and unhappy paths

  • Two devices sharing one clientId are indistinguishable on the channel. Give each device its own clientId, such as <userId>:<deviceId>, when you need to tell them apart.
  • A late joiner without the history capability receives live messages but cannot replay the conversation that came before. Capability scoping is part of authentication.
  • A client that loses its connection resumes after a brief drop, and can subscribe again with a replay after a longer gap. The agent does not learn about it, and the other devices are unaffected.
  • Two devices that send at the same time produce two responses on one channel. Tag each prompt with its own requestId header, so each device can keep the responses apart.
  • A client with an anonymous connection publishes no clientId, so message.clientId on its messages is undefined. Decide what your interface does with that before it happens in production.

FAQ

Do I need to write any sync code?

No routing code. Ably delivers every publish to every subscribed device, so subscribing is all the sync your code does. Each device does need the same merge, because your application merges the model's output into conversation messages.

How many clients can subscribe to one conversation?

There is no fixed limit. Every client subscribes to the same channel, so usage scales with the number of subscribers, up to the connection and message rate limits in effect.

Can two devices show different things?

Each device renders what its own merge produced. Two devices that subscribed from the same point show the same conversation. A device that subscribed later without a replay shows only the messages after it subscribed.

What stops a stranger from joining the conversation?

Channel capabilities. Issue tokens that scope subscribe and publish to the specific channel name for authenticated users.

Does presence work across devices?

Yes. Each device enters presence with its own clientId, following the agent presence patterns.