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; the creating side re-opens it after a reconnect, when the peer returns to "joined" (see the reconnect gotcha).

from metered_realtime import DataChannel, DataChannelOpened

# Creating side (exactly one per pair -- the impolite one), in your PeerJoined
# handler; creating the channel is what starts the P2P connection:
if not remote.polite:
dc = DataChannel(remote.create_data_channel("game-state", ordered=False))
# …use dc.send below

# Receiving side -- the very same channel arrives via DataChannelOpened:
remote.on(DataChannelOpened, lambda ev: use(DataChannel(ev.channel)))

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. For data channels specifically, the impolite side re-creates when the peer returns to "joined" after a reconnect, and the other side's DataChannelOpened handler fires again with the fresh channel — don't gate creation on the per-peer StateChange"connected", which a data-only app never reaches until a channel exists:

from metered_realtime import DataChannel, DataChannelOpened, StateChange

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

def wire_channel(remote, raw) -> None:
old = channels.pop(remote.id, None)
if old is not None:
old.close() # discard the stale channel
channels[remote.id] = DataChannel(raw)

# In your PeerJoined handler:
remote.on(DataChannelOpened, lambda ev: wire_channel(remote, ev.channel)) # receiving side
if not remote.polite: # creating side
wire_channel(remote, remote.create_data_channel("data"))

# Re-create on the MeteredPeer-level transition back to "joined":
@peer.on(StateChange)
def on_peer_state(sc: StateChange) -> None:
if sc.from_ == "reconnecting" and sc.to == "joined":
for r in peer.remote_peers:
if not r.polite:
wire_channel(r, r.create_data_channel("data"))

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 it fresh each time you need it (it's replaced during recovery)
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; the creating side re-opens when the peer returns to "joined", the receiving side gets a fresh DataChannelOpened

For the full reconnect playbook, see Reconnect Best Practices.

See also