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.
Each device creates a transport for the same channel and subscribes:
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:
| Field | What it tells you |
|---|---|
message.clientId | Which client published this message. |
message.extras.headers | The 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:
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:
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:
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:
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
clientIdare indistinguishable on the channel. Give each device its ownclientId, such as<userId>:<deviceId>, when you need to tell them apart. - A late joiner without the
historycapability 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
requestIdheader, so each device can keep the responses apart. - A client with an anonymous connection publishes no
clientId, somessage.clientIdon its messages isundefined. 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.
Related features
- Reconnection and recovery: each device reconnects independently.
- History and replay: late joiners replay the conversation from your store.