Skip to main content

RemotePeer

What the PeerJoined event and MeteredPeer's peer collection give you. One RemotePeer instance per remote peer currently in your joined channel.

You don't construct these — MeteredPeer creates them as peers join the channel and owns their lifecycle.

from metered_realtime import PeerJoined

@peer.on(PeerJoined)
def on_join(ev: PeerJoined) -> None:
remote = ev.peer # a RemotePeer
print(f"{remote.id} joined ({remote.state})")

What you can read

remote.id          # str — server-assigned id, or the peer's token `sub` claim
remote.metadata # dict[str, Any] | None — the peer's metadata from their token
remote.state # "idle" | "connecting" | "connected" | "reconnecting" | "closed"
remote.polite # bool — perfect-negotiation tie-breaker; you rarely need this
remote.pc # the underlying aiortc RTCPeerConnection — escape hatch, see below

RemotePeer also has a safe repr()RemotePeer(id=..., state=...) — with no metadata (may carry PII) and no pc (cycles), so it's fine to log.

metadata — what to put in it

metadata is the peer-level metadata asserted on the token used to mint the peer's connection. Typical fields: user_id, username, avatar_url, role. The server stamps it onto presence events and (optionally) onto messages.

@peer.on(PeerJoined)
def on_join(ev: PeerJoined) -> None:
remote = ev.peer
if remote.metadata:
print(f"{remote.metadata.get('username')} joined")
show_avatar(remote.metadata.get("avatar_url"))

metadata is None unless the peer connected with a backend-minted token that carried it. See Authentication.

Don't trust metadata for security. Your backend signed it, so the values originated from you, but a peer with a leaked token could keep using it. Use server-side authorization (the channel claims in the token) for access decisions, not client-side metadata.

metadata may refresh on reconnect. If the remote peer rotated their token during a signalling disconnect (and reconnected with a fresh token carrying updated metadata), the SDK refreshes the value before the peer transitions back to "connected". No dedicated metadata-changed event fires — read remote.metadata fresh whenever you need the latest value rather than caching the result of an initial read.

from metered_realtime import StateChange

@peer.on(PeerJoined)
def on_join(ev: PeerJoined) -> None:
remote = ev.peer

@remote.on(StateChange)
def on_state(sc: StateChange) -> None:
if sc.to == "connected":
# Re-read remote.metadata here if you display usernames / avatars —
# it may have changed across the reconnect.
update_name_tag(remote.id, (remote.metadata or {}).get("username"))

What you can do

await remote.send(data)None

Send data directly to this peer. Server-routed (not P2P) — shorthand for peer.send_to(remote.id, data). Raises after the peer is closed (a stale reference must not ship frames at a peer id the server may have reassigned to a different session). See the routing trade-offs on MeteredPeer.

await remote.send({"whisper": "private"})

remote.create_data_channel(label, **kwargs)

Open a data channel to this peer (negotiation is handled for you). Returns the raw aiortc channel — wrap it in DataChannel for backpressure-aware sends and typed events. **kwargs are forwarded to the underlying connection (e.g. ordered=False).

The channel dies with the current connection; re-open it on StateChange to "connected" after a reconnect (see the reconnect gotcha).

from metered_realtime import DataChannel, StateChange

@remote.on(StateChange)
def on_state(sc: StateChange) -> None:
if sc.to != "connected":
return
raw = remote.create_data_channel("game-state", ordered=False)
dc = DataChannel(raw)
# …use dc.send below

Events

Register typed event classes with the @remote.on(EventType) decorator (or remote.on(EventType, handler)), remote.once(...), and remote.off(...). Handlers may be sync or async — async handlers run as tracked tasks so a slow one can't stall emission, and a handler that raises is isolated (logged, never propagated). You can also consume an event as an async stream with async for ev in remote.events(EventType): ....

EventFieldsWhen
StateChangefrom_, toUnderlying peer-connection state changed. Drives per-peer UI / readiness gating.
Tracktrack, streams, metadataRemote sent a new media track. metadata (a StreamMetadata dict) is the per-track / per-stream label the remote attached at send time. Use it to tell apart camera vs screen vs custom sources.
StreamAddedstream, metadataFires once per MediaStream the remote sends, when its first track arrives. Preferred over Track when you want a stream-level (not track-level) handler.
StreamRemovedstreamFires when a previously-seen MediaStream has no live tracks left at your end — the remote removed the stream, stopped the track, or the connection closed. Symmetric with StreamAdded. Not fired during a reconnect — see note below.
DataChannelOpenedchannelRemote opened a data channel toward you. channel is the raw aiortc channel — wrap it in DataChannel.
NegotiationErrorerrSDP offer/answer negotiation failed for this peer. Mostly ignorable — the SDK handles recovery. err is an Exception.
IceCandidateErrorerrAn inbound ICE candidate was rejected. Usually benign (a stale candidate from a previous network). err is an Exception.

StateChange field note

StateChange.from_ carries a trailing underscore because from is a Python keyword. Both from_ and to are state strings from PeerConnectionState.

Track vs StreamAdded — which to use

Most call UIs render one tile per remote MediaStream. For that, StreamAdded is what you want — it fires once per stream rather than once per track:

from metered_realtime import StreamAdded, StreamRemoved

tiles = {} # stream.id -> your renderer / recorder

@remote.on(StreamAdded)
def on_stream(ev: StreamAdded) -> None:
role = (ev.metadata or {}).get("role") # e.g. "camera" | "screen"
tiles[ev.stream.id] = make_renderer(ev.stream, role)

@remote.on(StreamRemoved)
def on_stream_gone(ev: StreamRemoved) -> None:
renderer = tiles.pop(ev.stream.id, None)
if renderer is not None:
renderer.stop()

Use the lower-level Track event when you need per-track control — e.g. routing audio tracks into a different pipeline from video, or laying out individual tracks differently from how they're grouped into streams. Track.streams is a tuple[MediaStream, ...] (empty for an unaffiliated track); Track.track is the raw aiortc track.

from metered_realtime import Track

@remote.on(Track)
def on_track(ev: Track) -> None:
kind = getattr(ev.track, "kind", None)
if kind == "audio":
attach_to_audio_pipeline(ev.track)
elif kind == "video":
stream = ev.streams[0] if ev.streams else None
attach_to_video_tile(stream, ev.metadata)

A MediaStream exposes .id, get_tracks(), get_audio_tracks(), and get_video_tracks(). See Media.

Reconnect note for StreamAdded / StreamRemoved

The SDK keeps your RemotePeer reference valid across the local peer's transient reconnects, re-establishing the connection behind the scenes. When it does, the remote's tracks arrive again on the rebuilt connection, so StreamAdded fires again — but the MediaStream is a fresh object (same stream.id, different object identity). If you cached the stream object, re-take your reference each time StreamAdded fires, keyed by the stable stream.id:

renderers = {}  # stream.id (stable across reconnect) -> renderer

@remote.on(StreamAdded)
def on_stream(ev: StreamAdded) -> None:
renderer = renderers.get(ev.stream.id)
if renderer is None:
renderer = make_renderer()
renderers[ev.stream.id] = renderer
renderer.bind(ev.stream) # re-bind to the NEW MediaStream object

StreamRemoved is suppressed during a reconnect — the old connection's tracks end, but you don't get false "they left" signals. The fresh StreamAdded on the rebuilt connection is the canonical re-bind point.

(Edge case: if the remote actually removed a stream during your disconnect window, you'll see neither a StreamRemoved for it nor a StreamAdded for the same id on reconnect — the stream simply doesn't reappear. Diff your stream-id set against what you've previously seen if you need to detect this.)

StateChange — per-peer connection state

@remote.on(StateChange)
def on_state(ev: StateChange) -> None:
if ev.to == "connected":
mark_ready(remote.id)
elif ev.to == "reconnecting":
mark_pending(remote.id)
elif ev.to == "closed":
drop(remote.id)

ev.to == "reconnecting" covers two cases:

  • WebRTC-level reconnect (ICE failed/disconnected, the SDK is recovering the connection)
  • Signalling-level reconnect (the connection dropped, the SDK is reconciling peers on reconnect)

Both surface the same way so your code doesn't have to distinguish. The intermediate "connecting" hop of a rebuilt connection is filtered out, so you see connected → reconnecting → connected.

The pc escape hatch

remote.pc exposes the underlying aiortc RTCPeerConnection for things the SDK doesn't surface directly:

  • getStats() for connection diagnostics
  • raw aiortc events the SDK doesn't bubble up
stats = await remote.pc.getStats()

To open a data channel, prefer remote.create_data_channel(...) over reaching through pc — it handles negotiation for you.

remote.pc goes stale on reconnect

When the SDK re-establishes a survivor's connection, your RemotePeer reference stays valid, but its pc property now points at a different object. If you stored pc = remote.pc somewhere, that variable still points at the old (now-closed) connection.

The right pattern: re-read remote.pc every time you need it, OR wire to StateChange"connected" so you re-grab it whenever it changes. This fires once on the initial connect AND once per reconnect cycle, so it works correctly across reconnects.

from metered_realtime import DataChannel, StateChange

channels = {} # peer id -> currently-open DataChannel

@remote.on(StateChange)
def on_state(ev: StateChange) -> None:
if ev.to != "connected":
return
old = channels.pop(remote.id, None)
if old is not None:
old.close() # discard the stale channel
raw = remote.create_data_channel("data")
dc = DataChannel(raw)
channels[remote.id] = dc

What survives a reconnect

Reference you holdSurvives the local peer's reconnect?
The RemotePeer instance itselfYes — same object across the drop
remote.idYes
remote.metadata (peer-level)Yes (may be refreshed if the remote rotated their token)
remote.stateYes (transitions to "reconnecting" then back to "connected")
remote.pcObject identity changes — re-read after StateChange to "connected"
Stream-level metadata from StreamAddedYes — re-delivered before track re-attachment on the rebuilt connection
A MediaStream object from StreamAdded / TrackObject identity changesstream.id is stable, but it's a new object. Re-take it on each StreamAdded
A channel from remote.create_data_channel(...) (or the wrapping DataChannel)No — tied to the old connection; re-open on reconnect

For the full reconnect playbook, see Reconnect Best Practices.

See also