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.8). 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
Metadata
Release files for relayly 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| relayly-0.6.0.tar.gz | 25.0 kB | Without provenance | Details for relayly-0.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| relayly-0.6.0-py3-none-any.whl | Python 3 | none | any | Details for relayly-0.6.0-py3-none-any.whl |
Total release size: 44.8 kB
Release files / relayly-0.6.0.tar.gz
| Download URL | relayly-0.6.0.tar.gz |
|---|---|
| Size | 25.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f5b7607d306dbeb3b55fd390b6d872999ac5d84548133e3b5bed43457e57ecbb
|
|
BLAKE2b-256 checksum How to use checksums |
a7c94f2de570983271672e9c9bee4514a34fd8507af34acb9c577deb0dfa7486
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Release files / relayly-0.6.0-py3-none-any.whl
| Download URL | relayly-0.6.0-py3-none-any.whl |
|---|---|
| Size | 19.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ce4f67e07f223c7908c32e257fcd80585bce9e24ed485446406690d491c9f7f3
|
|
BLAKE2b-256 checksum How to use checksums |
3086955e1b8eb4271764a83825a39dfb88a0b10311a3c9daed3f3ff339c8a775
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Security for relayly 0.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.