Multi-device and fan-out
Every client attached 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.
Fan-out is how one agent run reaches every device. The conversation is an Ably channel rather than a client-to-server HTTP connection, so every device attached to it receives every message: user prompts, agent responses, and control signals.
A user can start on a laptop, open the same conversation on a phone, and read the rest of the answer there. A second tab, or a colleague you share the conversation with, works the same way.
Fan-out is on by default. Two clients that use the same channel name are on the same conversation:
1
2
3
4
5
6
7
8
9
10
11
// Client-side, on the laptop and on the phone alike.
const transport = createClientTransport({
channel: ably.channels.get(conversationId),
codec,
clientId: 'alice-laptop', // 'alice-phone' on the other device
});
await transport.connect();
transport.subscribe((event) => {
if (event.kind === 'message') fold.apply(event);
});How it works
Every client attached to the channel receives every publish on it. A user message, an agent's output, a cancel, or a steering message all reach every one of them, in the same order, because Ably does the routing.
Nothing distinguishes the client that started a run from the ones watching it. A message event carries meta.clientId for the publisher and meta.runId for the run it belongs to, and both are the same on every device. What differs is only what a given client holds locally: the one that published owns the runId promise publishInput returned, and the others learn the run id from the run-lifecycle start event instead.
Tell your own runs from other clients'
Two fields carry ownership, and they answer different questions:
| Field | Where it is | What it tells you |
|---|---|---|
meta.clientId | On every message event | Which client published this particular message. |
clientId on a run-lifecycle event | On run-lifecycle | Which agent published the run, from the clientId you pass to createAgentTransport. Constant for the run's life. |
The run-lifecycle clientId is the agent's own id, so keep the runId promise publishInput returned to decide what this device offers a Stop button for:
1
2
3
4
5
6
7
8
9
// Client-side.
const myRuns = new Set();
transport.subscribe((event) => {
if (event.kind !== 'run-lifecycle') return;
const { type, runId, clientId } = event.event;
if (type === 'start' && clientId === myClientId) myRuns.add(runId);
if (type === 'end') myRuns.delete(runId);
});Track live runs across clients
The transport does not keep track of live runs for you, so what any device knows about live runs is what it has observed on the lifecycle stream since it attached. Two clients can legitimately disagree: one attached before a run started and saw its start, the other attached after and did not.
That matters most on a device joining mid-run. It sees the in-flight message accumulating but never saw the run start, so it has no run id to cancel. Page history on attach to recover the starts it missed, or accept that a fresh device offers no Stop until the next run begins.
Handle late joiners
A device attaching after the conversation started reads what came before with history, then merges the live stream on top:
1
2
3
4
// Client-side, on attach.
await transport.connect();
const { events } = await transport.history({ limit: 50 });
for (const event of events) fold.apply(event);If a response is streaming as the device attaches, it reads the accumulated content of that message rather than a replay of every token, and the codec's lifecycle tracker synthesises the stream-start events it never saw, so the merge on the client sees a well-formed stream.
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-side, in your token endpoint.
const token = jwt.sign({
'x-ably-clientId': 'user-123',
// ...
}, keySecret);Pass the same value to createClientTransport as clientId, which sets it on the inputs this client publishes. The same value decides whether the agent's onCancel accepts a cancel, and which device a message came from. Set up authentication covers issuing it.
Edge cases and unhappy paths
- Two devices sharing one
clientIdare indistinguishable on the wire. A cancel filter keyed on it cancels runs from both. Give each device its ownclientIdwhen ownership matters. - A late joiner without channel history capability sees the live stream but not the conversation that came before. Capability scoping is part of authentication.
- A client that loses connectivity mid-stream resumes on its own. The agent never learns, and the other devices are unaffected.
- Two devices publishing at the same time produce two separate concurrent runs on one channel, kept apart by their run ids.
- A client with an anonymous connection publishes no
clientId, someta.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 attached device, so subscribing is all the sync your code does. What each device does need is the same merge, because aggregating model output chunks into conversation messages is application-side at this level.
How many clients attach 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?
Yes, and often they will. Every device receives the same events, but what it renders is whatever its own merge produced, so a device that attached later has a shorter list until it pages history. With durable sessions the divergence becomes deliberate instead: each view holds its own branch selection over one shared tree.
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.
- Cancellation: cancel from any device.
- History and replay: late joiners load the full conversation.
- Database hydration: seed a device from your own store and reconcile it with the live session.