relayly
Python SDK for Relayly - a self-hosted, end-to-end encrypted WebSocket relay for local-first apps.
Async-first (asyncio). Encryption is device-to-device Noise XX
(Noise_XX_25519_ChaChaPoly_BLAKE2s) via noiseprotocol (see "Why noiseprotocol?"
below); the relay itself holds no key material. See
docs/PROTOCOL.md
for the full wire spec.
Install
pip install relayly
Quick start
import asyncio
import relayly
async def main():
key = relayly.load_or_generate_key("~/.relayly/device.key")
# device_token comes from POST /api/v1/devices
client = await relayly.connect("wss://relay.example.com/ws", relayly.Options(
device_id="my-laptop",
device_token=device_token,
private_key=key,
))
async for msg in client.messages():
print(f"[{msg.from_device}]", msg.payload.decode())
asyncio.run(main())
Pairing
v1 links exactly one peer per device. Pairing again replaces whatever was linked
before, it doesn't add a second one alongside it. Multi-peer support is a roadmap
item (docs/ROADMAP.md, v0.7). Don't build for N simultaneous peers against this
version.
Devices pair using a short 6-digit code shared out-of-band (or via QR). Both
accept_pair() and code.wait() block until the Noise handshake actually completes
(not just until the code exchange), so the peer they resolve with is immediately safe
to send() to.
# Device A - request a code
code = await client.request_pair_code()
print("Share this code:", code.short)
print("QR URL:", code.qr_code_url("wss://relay.example.com"))
peer = await code.wait() # blocks until the other device pairs
print("Paired with", peer.id)
# Device B - accept the code
peer = await client.accept_pair("483921")
Peer key pinning
Each peer's authenticated static key is pinned on first pairing and checked on every
handshake after — this pin, not the relay, is the real security boundary
(docs/PROTOCOL.md §7). By default it's stored at ~/.relayly/peers.json, the same
schema every other official SDK reads/writes, so a shared machine can keep one pin
store across languages:
client = await relayly.connect(url, relayly.Options(
device_id=device_id,
device_token=device_token,
private_key=key,
peer_store_path="~/.relayly/peers.json", # this is the default
))
A peer presenting a different key than its pin raises PeerKeyMismatchError — this is
never auto-retried; unpinning is an explicit action (remove the entry from the store,
or use relayly.PeerStore directly).
Sending messages
await client.send(peer.id, b"hello!")
await client.send(peer.id, "hello!".encode())
send() raises NotReadyError if the peer's session isn't up yet — in normal use this
only happens briefly after a reconnect forces a re-handshake; use on_ready to know
when it recovers.
Reconnection
The client reconnects automatically with exponential backoff, and re-runs the Noise
handshake per docs/PROTOCOL.md §6 (the device with the lexicographically smaller ID
re-initiates; the existing session keeps working until the replacement completes):
relayly.Options(
device_id="my-laptop",
device_token=device_token,
private_key=key,
reconnect_delay=2.0, # initial delay in seconds (default: 1.0)
max_reconnect_delay=30.0, # backoff ceiling (default: 60.0)
on_disconnect=lambda err: print("disconnected:", err),
on_reconnect=lambda: print("reconnected"),
on_ready=lambda peer_id: print("session ready with", peer_id),
on_peer_status=lambda peer_id, online: print(peer_id, "online:", online),
)
Set reconnect_delay=-1 to disable automatic reconnection.
Key management
# Generate a fresh key
key = relayly.generate_key()
# Save and load manually
key.save_to_file("~/.relayly/device.key")
key = relayly.load_key_from_file("~/.relayly/device.key")
# Load or generate in one call (recommended)
key = relayly.load_or_generate_key("~/.relayly/device.key")
Options
| Option | Type | Default | Description |
|---|---|---|---|
device_id |
str |
- | Unique ID for this device. Required. |
device_token |
str |
- | From POST /api/v1/devices. Required. |
private_key |
PrivateKey |
- | X25519 private key. Required. |
peer_store_path |
str |
~/.relayly/peers.json |
Pinned peer key storage path. |
ping_interval |
float |
30.0 |
Keepalive ping interval (seconds). |
reconnect_delay |
float |
1.0 |
Initial reconnect delay. Set to -1 to disable. |
max_reconnect_delay |
float |
60.0 |
Backoff ceiling (seconds). |
on_disconnect |
Callable |
None |
Called with the exception when connection drops. |
on_reconnect |
Callable |
None |
Called after a successful reconnect. |
on_ready |
Callable |
None |
Called whenever a peer's session becomes usable for send(). |
on_peer_status |
Callable |
None |
Called on the paired peer's online/offline transitions. |
Why noiseprotocol?
docs/PROTOCOL.md requires Noise_XX_25519_ChaChaPoly_BLAKE2s. Unlike TypeScript
(where no maintained library fit and the state machine had to be hand-written), Python
has noiseprotocol (PyPI, import name noise), which supports this exact suite by
name (NoiseConnection.from_name(b'Noise_XX_25519_ChaChaPoly_BLAKE2s')) and delegates
its DH/cipher/hash backends to cryptography — an actively maintained, audited
library. The Noise-pattern orchestration code in noiseprotocol itself hasn't been
released since 2020, but it's a thin, near-literal transcription of the spec sitting on
top of cryptography's primitives, which is exactly where the real risk would be. This
SDK's use of it is verified byte-for-byte against flynn/noise (the Go implementation
already used server-side and in sdk/go) using fixed keys and a deterministic random
source, not just "the library claims to support it."
Requirements
- Python 3.11+
websockets >= 12.0noiseprotocol >= 0.3.1cryptography >= 41.0.0
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file relayly-0.5.0.tar.gz.
File metadata
- Download URL: relayly-0.5.0.tar.gz
- Upload date:
- Size: 25.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7baf8e9c45b5f8b8ff9dc9eaa893010531e26f90074c96cc6a6aa8f6b32cc6b9
|
|
| MD5 |
9430630832edea5f0eccacd62c1d4d61
|
|
| BLAKE2b-256 |
156986846dfd639e5ee363ab7d73863a776e168ae1cbe438c3628498360e3074
|
File details
Details for the file relayly-0.5.0-py3-none-any.whl.
File metadata
- Download URL: relayly-0.5.0-py3-none-any.whl
- Upload date:
- Size: 19.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fd82a4a2ddac40ccc8f261db42ccafd2d759e73642087d673ad21ac9b4e5dfa9
|
|
| MD5 |
740886073859528d2e9c900bf27b3e31
|
|
| BLAKE2b-256 |
080f06bf81e924d8a857d533b48cbe1ef222b7f356bd406864eb1ef2624b0138
|