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): ....
| Event | Fields | When |
|---|---|---|
StateChange | from_, to | Underlying peer-connection state changed. Drives per-peer UI / readiness gating. |
Track | track, streams, metadata | Remote 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. |
StreamAdded | stream, metadata | Fires once per MediaStream the remote sends, when its first track arrives. Preferred over Track when you want a stream-level (not track-level) handler. |
StreamRemoved | stream | Fires 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. |
DataChannelOpened | channel | Remote opened a data channel toward you. channel is the raw aiortc channel — wrap it in DataChannel. |
NegotiationError | err | SDP offer/answer negotiation failed for this peer. Mostly ignorable — the SDK handles recovery. err is an Exception. |
IceCandidateError | err | An 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 hold | Survives the local peer's reconnect? |
|---|---|
The RemotePeer instance itself | Yes — same object across the drop |
remote.id | Yes |
remote.metadata (peer-level) | Yes (may be refreshed if the remote rotated their token) |
remote.state | Yes (transitions to "reconnecting" then back to "connected") |
remote.pc | Object identity changes — re-read after StateChange to "connected" |
Stream-level metadata from StreamAdded | Yes — re-delivered before track re-attachment on the rebuilt connection |
A MediaStream object from StreamAdded / Track | Object identity changes — stream.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
MeteredPeerDataChannel— backpressure-aware wrapper for channels you open or receive- Media —
MediaStreamandStreamMetadata - Errors & Codes
- Reconnect Best Practices