Every frame on the WebRTC data channel between two MyOwnMesh peers is
a JSON object tagged by a kind discriminator. The source of truth
for these types is crates/myownmesh-core/src/protocol/.
PROTOCOL_VERSION = 1
SIGN_DOMAIN_TAG = "myownmesh-mesh-auth-v1:"
TRYSTERO_APP_ID = "myownmesh-cloud-mesh-v1"
A receiver that sees an unknown kind silently drops the frame. New
optional message kinds are gated per-peer via the features matrix
on the hello frame so a sender doesn't waste cycles on a peer that
doesn't speak them.
Each variant below lists its discriminator and the fields it carries. All field names are snake_case.
First frame on a fresh data channel from each side.
| Field | Type | Notes |
|---|---|---|
protocol |
u32 | Wire-protocol version. v1 today. |
device_id |
string | Bare-pubkey Device ID (base32-lowercase, 52 chars). |
label |
string | Self-reported human label. Cosmetic. |
nonce |
string | Random 32-byte challenge, base32-lowercase. |
verification_code |
string | 6-char [a-z0-9]. Read aloud over voice. |
capabilities |
object? | CapabilityAdvert — see below. Optional. |
max_connections |
u32? | Hint to the topology selector. |
features |
string[] | Capability ids the sender claims. |
app_version |
string? | Cosmetic. |
The other side signs
SIGN_DOMAIN_TAG || nonce || my_device_id || their_device_id
with their ed25519 secret key and returns the signature in
auth_response.
Proves possession of the secret key matching hello.device_id.
| Field | Type | Notes |
|---|---|---|
signature |
string | Base32-lowercase ed25519 signature of the domain-tagged payload. |
Sent once the local side has cleared the peer (either auto-approved
from the roster, or the user clicked "approve"). Empty payload. Both
sides must observe each other's approve before the connection
transitions to ACTIVE.
Sent when the local side rejects the peer. Carries an optional reason string. The peer should not reconnect until the user approves again.
| Field | Type | Notes |
|---|---|---|
reason |
string? | Optional human-readable explanation. |
| Field | Type | Notes |
|---|---|---|
t |
i64 | Sender's monotonic timestamp (ms). Echoed back unchanged so the sender can compute RTT against its own clock. |
ping cadence: HEARTBEAT_INTERVAL_MS = 30_000. Silent peers past
HEARTBEAT_TIMEOUT_MS + WAKE_DETECTION_THRESHOLD_MS (~75 s) escalate
to Tier 4 re-handshake.
"I'm not going to send you application traffic for now — keep the data channel open as a heartbeat path."
| Field | Type | Notes |
|---|---|---|
reason |
string? | Surfaced in the Activity log. Optional. |
Reverses shelve. Empty payload. The receiving side may now expect
app traffic again.
Each side tracks local_shelved (we sent shelve) and remote_shelved
(they sent it) independently. A connection is effectively shelved
when either flag is set.
Push an updated CapabilityAdvert to peers. Receivers replace their
cached copy wholesale.
| Field | Type | Notes |
|---|---|---|
capabilities |
CapabilityAdvert |
New advertisement. |
| Field | Type | Notes |
|---|---|---|
tags |
string[] | Embedder-defined capability tags. |
app_version |
string? | |
max_connections |
u32? | |
extra |
json | Embedder-defined structured advertisement. |
| Field | Type | Notes |
|---|---|---|
request_id |
string | Caller-generated, unique within in-flight map. |
method |
string | Embedder-defined dispatch key. |
payload |
json | Opaque to the mesh. |
streaming |
bool | When true, expect rpc_stream_chunk+rpc_stream_end rather than rpc_response. |
| Field | Type | Notes |
|---|---|---|
request_id |
string | Echoes the request id. |
ok |
json? | Result payload on success. Mutually exclusive with error. |
error |
string? | Error message on failure. |
| Field | Type | Notes |
|---|---|---|
request_id |
string | |
seq |
u64 | Monotonic sequence number. WebRTC preserves order, so this is informational. |
payload |
json | One chunk of the streamed response. |
| Field | Type | Notes |
|---|---|---|
request_id |
string | |
error |
string? | Set when the stream terminated abnormally. |
Carries embedder payloads on a named typed channel. The mesh treats
the payload opaquely; embedders define their own serialization via
Channel<T>.
| Field | Type | Notes |
|---|---|---|
channel |
string | The channel name; same on both sides. |
payload |
json | The serialized application body. |
Side A Side B
│ │
├── hello {device_id, nonce, code, ...} ─────────▶ │
│ ◀───────── hello {device_id, nonce, code, ...} ─┤
│ │
│ verifies B's claimed device_id against pubkey ─▶ │
│ ◀────────── auth_response {signature(payload)} ─┤
├── auth_response {signature(payload)} ─────────▶ │
│ │
│ each side either: │
│ a) finds peer in roster → auto-approve │
│ b) prompts user with verification_code │
│ │
├── approve ───────────────────────────────────▶ │
│ ◀──────────────────────────────────── approve ──┤
│ │
│ ACTIVE — app traffic flows │
│ ◀──── ping / pong / channel / rpc_* / shelve ──▶│
The exact byte-shape of the domain-tagged payload that both sides sign:
SIGN_DOMAIN_TAG + nonce + "|" + my_device_id + "|" + their_device_id
— see crate::signing::handshake_payload. The | separators ensure
no nonce value can be reinterpreted as part of a device id when the
concatenation is parsed back. Both device_id fields are the
canonical base32-lowercase pubkey portion (display suffixes stripped).
-
Unknown
kind: receiver silently drops the frame. New message kinds added in future revisions don't break older peers. -
Unknown fields: serde deserialization ignores extra fields by default. Embedders can add fields to
CapabilityAdvert.extrawithout protocol-level coordination. -
Optional fields with sensible defaults: a v1 receiver missing an optional field treats it as the default (
None, empty list, etc.). -
featuresgate optional message kinds: senders consult the peer's advertisedfeatureslist before sending an optional frame. Older peers that don't advertise a feature don't receive it. -
Bump
PROTOCOL_VERSIONonly when an existing message's shape changes incompatibly. Additive changes don't bump.
Out-of-band signaling splits across two Nostr event kinds by message class:
- Presence / announce travels as a NIP-01 stored regular event,
kind
1077(stored range 1000–9999). Stored so a late joiner'sREQ since=now-300sis replayed by the relay with everyone's recent announces — without that, discovery degenerates into a star around whichever existing peer happens to re-announce inside the new joiner's window. - Connection negotiation — offer / answer / ICE candidate / leave —
travels as an ephemeral event, kind
21077(ephemeral range 20000–29999), which relays forward live but never persist. Negotiation is point-in-time and must not be replayed onto a future session, so it is deliberately ephemeral.
The driver subscribes to both kinds, and the receive path enforces the
split: an announce is honoured only on 1077, an
offer/answer/candidate/leave only on 21077 (so a stale directed
message replayed from history is dropped). Constants
SIGNALING_EVENT_KIND = 1077 and SIGNALING_EPHEMERAL_KIND = 21077
live in crates/myownmesh-signaling/src/nostr/event.rs.
The event content is a JSON envelope:
{
"from": "<sender device_id>",
"to": "<recipient device_id, or null for broadcast>",
"kind": "offer" | "answer" | "candidate" | "leave" | "announce",
...kind-specific fields
}Room tag: ["r", "<room_handle>"] where the handle is
SHA-256(app_id || ":" || network_id) — deterministic across
runtimes so two peers using the same (app_id, network_id) land in
the same Nostr room.
Periodic announce cadence: one publish on startup, one safety-net
re-publish at +30 s, then every 2 min (ANNOUNCE_BACKOFF_MS +
ANNOUNCE_STEADY_MS in crates/myownmesh-signaling/src/upstream.rs).
Discovery doesn't rely on the periodic cadence — it relies on the
relay's storage of our last announce plus engine-side reactive
reflection on every inbound announce (engine::handle_signaling_inbound).
The periodic publish is only there to refresh storage inside the
relay's retention window.
Full Nostr-driver behavior — relay selection, subscription replay
on reconnect, transition-only logging — is documented in
crates/myownmesh-signaling/src/upstream.rs.
{ "kind": "<discriminator>", // ...kind-specific fields }