Multi-device sessions

Open in

Multi-device sessions in AI Transport work because the session is a shared Ably channel, not a private connection. Any device that subscribes to the channel sees every message - user prompts, agent responses, and control signals - in real time. Open a second tab, switch to your phone, or share a session with a colleague.

Diagram showing multi-device co-pilots architecture

How it works

All clients connected to the same Ably channel share the same durable session. When any participant publishes (a user message, an agent response, a cancel signal), every other participant receives it through their channel subscription.

The client transport distinguishes between "own" turns (started by this client) and "observer" turns (started by someone else). Both types are tracked, decoded, and added to the conversation tree. The UI updates for all clients, regardless of who initiated the action.

Minimal code - no special configuration needed:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

// Client A (laptop)
const transport = useClientTransport({
  channel: ably.channels.get('shared-session'),
  codec: UIMessageCodec,
  clientId: 'user-laptop',
})

// Client B (phone) - same channel name, different device
const transport = useClientTransport({
  channel: ably.channels.get('shared-session'),
  codec: UIMessageCodec,
  clientId: 'user-phone',
})

Both clients see the same conversation. When Client A sends a message, Client B sees it immediately.

Own turns vs observer turns

The transport distinguishes between turns initiated by the current client and turns from other participants:

  • Own turns - the client sent the HTTP POST that created this turn. It receives an ActiveTurn with a stream and cancel() method.
  • Observer turns - another client or agent created this turn. The observer sees the turn lifecycle events and streamed messages, but doesn't have a direct stream handle.

Both types appear in the conversation tree and UI. The difference is in how they're routed internally - own turns have a dedicated stream, observer turns are accumulated from channel messages.

Active turn tracking

Track which clients have active turns using useActiveTurns:

JavaScript

1

2

3

4

5

6

7

8

const activeTurns = useActiveTurns(transport)
// Map<clientId, Set<turnId>>

// Check if any client is streaming
const isAnyoneStreaming = activeTurns.size > 0

// Check if a specific client is streaming
const isAgentWorking = activeTurns.has('agent-1')

This works across all connected clients. If Client A starts a turn, Client B's useActiveTurns updates immediately.

Sync with useChat

When using Vercel's useChat, the useMessageSync hook pushes messages from other clients into useChat's state:

JavaScript

1

2

const { messages, setMessages } = useChat({ transport: chatTransport })
useMessageSync(transport, setMessages)

Without useMessageSync, useChat only sees messages from its own sends. The sync hook bridges the gap by feeding observer messages into the state.

Late joiners

A client that connects after the conversation has started loads the full history from the channel:

JavaScript

1

const { nodes, hasOlder, loadOlder } = useView(transport, { limit: 30 })

useView loads history on mount. If a response is currently streaming, the late joiner sees it in progress - the lifecycle tracker synthesizes missing events so the stream renders correctly.

Client identity

Each client has a clientId that identifies it across the session. Set the client ID through Ably token authentication to ensure it's verified and can't be spoofed:

JavaScript

1

2

3

4

5

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

The clientId is used throughout: turn ownership, cancel scoping ({ own: true } filters by the sender's client ID), and active turn tracking.