Robutler

Multi-party RTC, peer mesh over host.collab

This guide is for widget authors building multi-party real-time audio/video (e.g. a small group call, a shared mic widget). The portal does not host an SFU; widgets own their RTCPeerConnections and use host.collab awareness as the signaling bus.

When to use this pattern vs. host.live

Use casePrimitive
One producer, many consumers (broadcast)host.live
Few peers, every peer sees every peer (mesh)host.collab + RTCPeerConnection (this guide)
Large group, every peer sees every peerNot v1, needs an SFU you bring yourself

Rule of thumb: mesh topology scales to roughly 6 peers. Past that, the per-peer fanout cost (each peer encodes once per other peer) saturates uplinks; bring an SFU.

Architecture overview

+--------+   awareness   +--------+   awareness   +--------+
| Peer A | <-----------> | Portal | <-----------> | Peer B |
+--------+  (webrtc.*)   +--------+  (webrtc.*)   +--------+
    \                                                /
     \--------------- RTCPeerConnection -------------/
                  (audio/video tracks, direct)
  • The portal's Hocuspocus pod relays Yjs awareness updates on a reserved webrtc.* namespace. Use it to exchange SDP offers, answers, and ICE candidates.
  • Once SDP/ICE is exchanged, media flows peer to peer over the RTCPeerConnection. The portal does not see the media.
  • TURN credentials come from room.localUser.turn (short-TTL, lt-cred-mech). Re-join the room to rotate.

Step-by-step

1. Declare permissions

Multi-party RTC widgets MUST declare device intent in their HTML:

<meta name="robutler:widget" content="allowMic allowCamera" />

Without this, the browser will refuse getUserMedia inside the widget iframe.

2. Join the room

await host.ready();

const { token, wsUrl, roomId, turn } = await host.collab.getToken(
  'item',
  workspaceItemId,
);

// Open a Hocuspocus connection with your Yjs provider of choice.
// `provider.awareness` is your signaling bus.

3. Exchange SDP/ICE via awareness

Use the reserved webrtc.* namespace on awareness. Each peer publishes its offers / answers / ICE candidates keyed by destination clientId.

provider.awareness.setLocalStateField('webrtc.offer', {
  to: remoteClientId,
  sdp: offer.sdp,
});

provider.awareness.on('update', () => {
  for (const [clientId, state] of provider.awareness.getStates()) {
    if (state['webrtc.answer']?.to === provider.awareness.clientID) {
      pc.setRemoteDescription({ type: 'answer', sdp: state['webrtc.answer'].sdp });
    }
  }
});

4. Configure RTCPeerConnection with portal TURN

const pc = new RTCPeerConnection({
  iceServers: turn ? [{
    urls: turn.urls,
    username: turn.username,
    credential: turn.credential,
  }] : [],
});

5. Mesh topology

For each new peer that joins, open a fresh RTCPeerConnection. Each peer maintains (N - 1) connections. This is what keeps the pattern bounded to small groups.

Reserved awareness namespaces

The Hocuspocus pod drops widget-origin updates that touch host-owned namespaces. Stay inside webrtc.* for signaling:

  • presence.*, canvas cursors / follow-me (host chrome)
  • comment.*, thread typing indicators (host chrome)
  • webrtc.*, multi-party RTC signaling (yours)
  • user, top-level identity field

TURN credential refresh

turn credentials are short-TTL. To rotate, call host.collab.getToken(...) again and re-establish the room connection with the fresh token; the new room.localUser.turn block replaces the old one. Renegotiate ICE on existing peer connections if you want to swap relay servers without dropping the call.

Capacity guidance

  • Mesh: ≤6 peers comfortably; past that, uplink saturates.
  • Larger groups: bring an SFU (Janus, mediasoup, LiveKit, etc.). The portal does not provide one in v1. You still use host.collab for signaling, the difference is that peers send their tracks to the SFU rather than to each other.

Minimal working example (two peers, audio only)

<!doctype html>
<meta name="robutler:widget" content="allowMic" />
<script type="module">
  await host.ready();
  const { token, wsUrl, roomId, turn } = await host.collab.getToken(
    'item', host.context.itemId,
  );

  const provider = new HocuspocusProvider({ url: wsUrl, name: roomId, token });
  const pc = new RTCPeerConnection({
    iceServers: turn ? [{ urls: turn.urls, username: turn.username, credential: turn.credential }] : [],
  });

  const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
  for (const track of mic.getTracks()) pc.addTrack(track, mic);

  pc.onicecandidate = (e) => {
    if (e.candidate) {
      provider.awareness.setLocalStateField('webrtc.ice', { candidate: e.candidate });
    }
  };

  pc.ontrack = (e) => {
    const audio = new Audio();
    audio.srcObject = e.streams[0];
    audio.play();
  };

  // Caller: create + publish offer.
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  provider.awareness.setLocalStateField('webrtc.offer', { sdp: offer.sdp });

  // Callee: watch for offer, respond with answer.
  provider.awareness.on('update', async () => {
    for (const [, state] of provider.awareness.getStates()) {
      if (state['webrtc.offer'] && !pc.currentRemoteDescription) {
        await pc.setRemoteDescription({ type: 'offer', sdp: state['webrtc.offer'].sdp });
        const answer = await pc.createAnswer();
        await pc.setLocalDescription(answer);
        provider.awareness.setLocalStateField('webrtc.answer', { sdp: answer.sdp });
      }
      if (state['webrtc.answer'] && !pc.currentRemoteDescription) {
        await pc.setRemoteDescription({ type: 'answer', sdp: state['webrtc.answer'].sdp });
      }
      if (state['webrtc.ice']?.candidate) {
        try { await pc.addIceCandidate(state['webrtc.ice'].candidate); } catch {}
      }
    }
  });
</script>

On this page