A browser-based Reticulum messaging client. Connects either directly to an RNode LoRa modem over Web Bluetooth or Web Serial, or to any running Reticulum daemon (rnsd) over a WebSocket bridge, and exchanges encrypted LXMF messages with Sideband, NomadNet, MeshChat, and other Reticulum nodes anywhere on the network.
Live app: https://thatsfguy.github.io/reticulum-lora-webclient/
No build step, no framework, no bundler. Plain ES modules, loaded directly in the browser. The LoRa path runs entirely in the browser with no server. The TCP-via-WebSocket path needs a small bridge process to sit between the browser and an existing rnsd — pick either the prebuilt Go binary (no runtime to install) or the Python script (tools/ws_bridge.py, 130 lines).
- Connects over any of three transports:
- Web Bluetooth to an RNode (primary, Chrome/Edge/Brave on desktop and Android).
- Web Serial to an RNode (desktop fallback).
- WebSocket to a local or remote
rnsdvia a small bridge script (any modern browser, including Safari and Firefox).
- Configures the radio (frequency, bandwidth, spreading factor, coding rate, TX power) and turns it on — when talking to an RNode. When talking to
rnsdover WebSocket there is no radio to configure; the network config lives on the daemon side. - Generates and persists an Ed25519 / X25519 Reticulum identity in IndexedDB.
- Sends and receives Reticulum announces, auto-announces once at connect and every five minutes thereafter so relay identity caches stay warm.
- Encrypts and decrypts LXMF messages for opportunistic single-packet delivery using the standard Reticulum ECDH + HKDF + AES-256-CBC + HMAC-SHA256 scheme.
- Accepts incoming Reticulum Link handshakes and receives link-delivered LXMF messages. We act as link responder only — we validate LINKREQUESTs, emit LRPROOFs signed with our long-term Ed25519 key, receive LRRTT acknowledgements, decrypt inbound link traffic, and send per-packet PROOF receipts back so the sender does not retry forever. Sideband and MeshChat both round-trip cleanly this way.
- Filters the contact list by LXMF
name_hashso announces from telemetry beacons, heartbeats, or other non-LXMF destinations do not pollute it. Contacts get an unread-count badge and a small delete button in the sidebar. - Stores identity, contacts, and message history locally in IndexedDB. Messages are sorted in the conversation view by their IndexedDB insertion order, which keeps the timeline correct even when a clockless LoRa sender reports a nonsense timestamp. Nothing leaves your browser except over the radio link.
- Link initiation — we are responder only. Messages we originate are always delivered opportunistically, which caps them at roughly 250–300 bytes of content.
- Resources — multi-packet transfers over an established link (needed for messages larger than a single packet). So no file or image attachments.
- Ratchet emission on outbound announces — we parse ratchet fields on inbound announces so the signature still validates, but we do not yet emit our own ratchet.
- Outbound retry queue — a send that fails has no "pending" or "failed" state in the UI yet.
- No propagation node / store-and-forward support. Both parties must be on the air at the same time.
- No multi-hop transport routing tables. Single-hop LoRa only.
- No IFAC, no LXMF stamps (we handle them on inbound, but do not emit them), no GROUP destinations.
See CLAUDE.md for the scope rules and implementation plan, and docs/PROTOCOL_NOTES.md for the detailed Reticulum / LXMF interop findings accumulated while building this client.
| Platform | Web Bluetooth | Web Serial | WebSocket (TCP via bridge) | Works? |
|---|---|---|---|---|
| Chrome Android | Yes | No | Yes | Primary target |
| Chrome/Edge desktop | Yes | Yes | Yes | Dev and daily use |
| Brave desktop | Yes | Yes | Yes | Works |
| Safari (iOS/macOS) | No | No | Yes | WebSocket only |
| Firefox | No | No | Yes | WebSocket only |
WebSocket works everywhere, which is the practical way to use the client from Safari, Firefox, or iOS. The LoRa-over-RNode paths require a browser that implements Web Bluetooth or Web Serial.
Web Bluetooth requires HTTPS (or http://localhost). GitHub Pages and any other HTTPS host are fine. WebSocket from an HTTPS page must be wss:// (see the TCP section below for the mixed-content caveat).
Because it is all static files with ES module imports, any HTTPS static host works. Locally:
# from the project root
python -m http.server 8000Then open http://localhost:8000/ in Chrome, Edge, or Brave. localhost is treated as a secure origin, so Web Bluetooth and Web Serial are both available without a certificate.
For a public deploy, push to gh-pages (or any static bucket) and visit the HTTPS URL directly. No build step.
- Connect. Click
Connect (BLE)and pick your RNode from the Bluetooth chooser, or clickConnect (Serial)and select the USB serial port, or clickConnect (WebSocket)with a bridge URL to reach a remoternsd(see the TCP section below). The webapp will detect the modem, read firmware version and battery, and auto-start the radio with the values in the collapsible Radio Configuration panel — or, on the WebSocket path, skip all radio config and go straight to the messaging UI. - Set your display name and click
Send Announce. This broadcasts your identity and destination to the network so other Reticulum nodes can learn how to reach you. Your LXMF address is shown underYour Identity. - Wait for announces. When another node announces, it shows up in the contact list on the left.
- Open a conversation. Click a contact to open the conversation view, type a message, and hit Enter. Incoming messages from that contact land in the same view.
Identity persists across reloads. Export Identity writes a JSON file containing your private keys; New Identity generates a fresh keypair (and will change your LXMF address).
The "Connect (WebSocket)" option lets the web client join a Reticulum network through an existing rnsd instead of talking to a local LoRa radio. This is how you use the client from Safari, Firefox, or iOS (none of which have Web Bluetooth or Web Serial), how you use it from a machine that has no RNode attached, and how you reach a wider Reticulum mesh that spans TCP, I2P, or another backbone configured on the daemon side.
Browsers cannot open raw TCP sockets — the security model only exposes HTTP, WebSocket, and WebTransport. So the web client's "TCP" option really speaks WebSocket to a small bridge script which sits in front of your rnsd's TCP interface and copies bytes in both directions:
┌──────────────┐ WebSocket ┌──────────────┐ TCP ┌─────────┐ LoRa / I2P / TCP
│ Browser │ ◄────────────► │ ws_bridge.py │ ◄──────► │ rnsd │ ◄─────────────────► Reticulum network
│ web client │ (HDLC frames) │ │ │ │
└──────────────┘ └──────────────┘ └─────────┘
- The web client builds raw Reticulum packets the same way it does for the LoRa path, but frames them in HDLC (
0x7Eflag,0x7Descape) instead of KISS before handing them to the transport. - The bridge process — either the Go binary (
ws_bridge.exeon Windows,ws_bridgeon Linux/macOS, prebuilt and attached to eachbridge-v*GitHub release) or the Python script (tools/ws_bridge.py) — accepts WebSocket connections, opens a TCP connection to anrnsdrunning aTCPServerInterface, and forwards raw bytes in both directions without parsing any frames. rnsdreceives HDLC frames from the bridge exactly the way it does from any other TCP peer — the bridge is indistinguishable on the wire from a local TCP client.
The Go binary is the default suggestion: ~3-4 MB, no runtime dependency, no pip install, instant start. The Python script is there as a no-build fallback if you already have Python and prefer not to download a binary.
Identity and all protocol work stays in the browser. rnsd is only acting as a transport — it does not own your Reticulum identity, does not see your private keys, and does not decrypt your messages. From rnsd's point of view, the browser is a peer node on its TCP interface.
1. Install and configure rnsd on the machine that will run the bridge (can be the same machine as the browser, or a server on your network).
pip install rnsEdit ~/.reticulum/config (create it if it does not exist) and add a TCP server interface:
[[RNS TCP Server Interface]]
type = TCPServerInterface
interface_enabled = True
listen_ip = 0.0.0.0
listen_port = 4242
Along with whatever other interfaces you want to use as your network backbone — another TCPClientInterface pointing at a public RNS node, an I2PInterface, an AutoInterface for LAN discovery, a RNodeInterface if you have an RNode plugged in directly, etc. See upstream Reticulum documentation for options.
Start rnsd:
rnsdLeave it running. You should see a line like Listening for TCP connections on 0.0.0.0:4242.
2. Get the bridge. Pick one of the two paths.
2a. Prebuilt Go binary (recommended). Grab the latest from the bridge releases page and save it next to your other tools. Pick by platform:
ws_bridge-*-windows-amd64.exe— Windows 10/11 64-bitws_bridge-*-linux-amd64— Linux 64-bitws_bridge-*-darwin-arm64— macOS Apple Silicon
Then verify the download against the published SHA256SUMS.txt:
sha256sum -c SHA256SUMS.txt # Linux / macOS / Git Bash
certutil -hashfile ws_bridge-*.exe SHA256 # PowerShell on WindowsOn Linux / macOS, chmod +x ws_bridge-* once after downloading.
2b. Python script (alternative). If you'd rather not download a binary:
pip install websocketsThe Python bridge depends only on websockets (stdlib asyncio does the rest). rns is already installed from step 1.
3. Start the bridge. It listens on ws://localhost:7878 by default. The Reticulum daemon target (host:port) is supplied by the webapp at connect time — the bridge itself takes no rnsd flags (Go bridge) or uses defaults (localhost:4242, Python bridge).
# Go binary (Windows)
ws_bridge.exe # listen on localhost:7878
ws_bridge.exe -bind 0.0.0.0 -port 7878 # LAN-visible, custom port
# Go binary (Linux / macOS)
./ws_bridge-*-linux-amd64 # same defaults
# Python script
python tools/ws_bridge.py
python tools/ws_bridge.py --ws-host 0.0.0.0 --ws-port 7878 --rnsd-host 10.0.0.5 --rnsd-port 4242You'll see ws_bridge listening on ws://localhost:7878 (Go) or the equivalent two-line Python banner. That's the signal the bridge is up.
Per-connection rnsd target — the practical difference between the two bridges: the Go bridge accepts the rnsd host:port from the webapp via query parameters on every connection, so one running bridge can serve any number of webapp instances pointed at any number of different rnsds without restart. The Python bridge ignores those query parameters and always uses its own --rnsd-host/--rnsd-port flags from startup; the same webapp UI works against either bridge.
4. Open the web client — either the live GitHub Pages URL or a local python -m http.server 8000 copy — and hit Connect (WebSocket). Two fields in the connect card:
- WebSocket bridge URL — defaults to
ws://localhost:7878. Change only if your bridge runs elsewhere. - Reticulum daemon (host:port) — the rnsd you want to reach (e.g.
rns.michmesh.net:7822for the public mesh, orlocalhost:4242for a local daemon). Required by the Go bridge; ignored by the Python bridge but harmless to fill in.
Both fields persist across reloads (localStorage). The log panel will print WebSocket connected and Connected to Reticulum network via WebSocket; the messaging panel appears without any radio-config step.
5. Announce yourself. Enter a display name and click Send Announce. Within a second or two your announce should show up in any other Reticulum client connected to the same network — including Sideband and MeshChat if they are on the same backbone.
If you load the web client from https://thatsfguy.github.io/reticulum-lora-webclient/ and try to connect to ws://localhost:7878, the browser will refuse. Modern browsers block plain ws:// connections from HTTPS pages as a mixed-content policy. Three ways around it:
-
Load the web client locally, not from GitHub Pages.
python -m http.server 8000from the repo root and openhttp://localhost:8000/. Nowws://localhost:7878is same-origin in terms of scheme compatibility and the browser allows it. This is the fastest way to try the TCP path. -
Serve the bridge as
wss://with a certificate the browser trusts. With the Python bridge, edittools/ws_bridge.pyto wrap thewebsockets.servecall in anssl_context. The Go binary doesn't currently have a built-in TLS flag — option 3 below is the right path for that. Either way, any cert works as long as the browser trusts it — letsencrypt, a self-signed cert you imported into the OS trust store, or a development cert frommkcert. Then update the URL field in the web client towss://your.domain:7878. -
Use a reverse proxy. Run nginx or caddy in front of the bridge with a TLS cert, terminating TLS and forwarding
wss://to the plain bridge. This is the production story for anything exposed to the internet, and the recommended way to put TLS in front of the Go binary.
Option 1 is fine for one-machine testing. Option 3 is the right answer for anything you want to keep running.
The browser owns your Reticulum identity. Your Ed25519 and X25519 private keys live in IndexedDB in the browser where you are running the web client. The bridge and the rnsd never see them. If you expose the WebSocket bridge to the open internet without TLS, an attacker between you and the bridge can observe every encrypted Reticulum packet you send and receive, but cannot impersonate you or read your LXMF messages (both ends of the ECDH are protected inside the Reticulum protocol). That said, running plaintext WebSocket to a bridge is still a bad idea for general use; use wss:// for anything beyond localhost.
Public-facing rnsd instances that accept TCP connections should probably require IFAC (interface access codes) or be tunneled through something with authentication. The bridge is a dumb forwarder — it will happily connect any WebSocket client to the rnsd it is configured to talk to. If you expose the bridge publicly without locking down the rnsd, anyone who can reach the WebSocket port can inject packets into your Reticulum network.
- "WebSocket error before open" immediately after clicking Connect. The bridge is not running, or is listening on a different port, or the URL in the field is wrong. Verify with
curl -v http://localhost:7878/— a running bridge will respond with an HTTP 400 (WebSocket Upgrade Required), which is good. - Connection opens then immediately closes, bridge logs
cannot reach rnsd.rnsdis not running, or its TCP interface is on a different port, or is bound to a different address than the bridge is trying to connect to. Check thernsdlogs forListening for TCP connections on …. - Connected but no announces appear.
rnsdhas no upstream network interface configured (only the TCP server interface, which is how the bridge reached it). Edit~/.reticulum/configto add a backbone interface that actually touches other nodes. - Announces appear but nobody can reach you. Check that you have clicked
Send Announceat least once, and that the log is showingPeriodic announce skippedevery 5 minutes without error. Relay identity caches do expire; that is why the periodic re-announce is mandatory. - Works on Chrome but not Safari. You are probably loading the live GitHub Pages URL and running into the mixed-content block. Serve the static files locally (
python -m http.server 8000) and try again.
All Reticulum protocol logic runs in the browser — identity, announce, encrypt/decrypt, LXMF, link handshake, retry queue, packet receipts. What changes between transports is only how the finished raw Reticulum packet gets from our browser out onto the network.
┌──► KISS ──► RNode fw ──► SX126x ──► LoRa RF (BLE / Serial path)
│
Browser (all protocol logic) ────┤
│
└──► HDLC ──► WebSocket ──► ws_bridge ──► rnsd ──► network (WebSocket path)
The BLE / Serial path needs an RNode and gives you direct-to-LoRa messaging with no server. The WebSocket path needs rnsd and a small bridge script, but runs everywhere (including Safari, Firefox, iOS) and can reach any Reticulum network rnsd is connected to — LoRa via a local RNode, TCP backbones to public nodes, I2P, AutoInterface LAN discovery, whatever you configure on the daemon side.
reticulum-lora-webclient/
index.html Single-page app shell
css/style.css Dark theme
js/
ble-transport.js Web Bluetooth NUS byte stream
serial-transport.js Web Serial byte stream
websocket-transport.js WebSocket byte stream (for the TCP-via-bridge path)
kiss.js KISS frame encode/decode for the RNode path
hdlc.js HDLC frame encode/decode for the rnsd path
rnode.js RNode command layer (detect, configure, send/recv over KISS)
rnsd-interface.js Reticulum-direct interface over HDLC+WebSocket
(exposes the same shape as rnode.js so app.js doesn't branch)
reticulum.js Reticulum packet header encode/decode + constants
identity.js Ed25519 + X25519 keypair, identity hash, destination hash
crypto.js ECDH + HKDF + Token (AES-256-CBC + HMAC-SHA256)
announce.js Build, parse, and validate Reticulum announces
link.js Reticulum Link: responder validation, initiator handshake,
LRPROOF build/verify, link_id derivation, signalling encoding,
Token encrypt/decrypt over the derived link key
lxmf.js LXMF message pack/unpack + signature
store.js IndexedDB for identity, contacts, messages
app.js UI controller and state management
tools/ Python RNS-based offline verifiers + ws_bridge.py
tests/ Level-2 round-trip harness against RNS reference
docs/PROTOCOL_NOTES.md Reticulum / LXMF interop findings reference
Libraries (@noble/curves for Ed25519/X25519 and @msgpack/msgpack for LXMF payload serialization) are loaded from a CDN via an import map in index.html. Web Crypto handles AES-CBC, HMAC, HKDF, and SHA-256 natively.
The tools/ directory contains Python scripts that validate the web client's wire output against the Python RNS reference, plus the WebSocket bridge used by the TCP connection option.
tools/ws_bridge.py— WebSocket↔TCP forwarder used by the "Connect (WebSocket)" option to reach a local or remoternsd. Requirespip install websockets. See the TCP (WebSocket) connection section above for setup.tools/identity_info.py— dumps every derivable public piece of an exported identity (enc/sig/ratchet private and public bytes, identity hash, LXMF destination hash). Read-only, never touches network.tools/verify_lrproof.py— runs a self-test of RNS's Ed25519, X25519, and HKDF primitives, then verifies a real LRPROOF hex string (lifted from the web client log) againstIdentity.validateto prove our link-proof signatures are byte-compatible with upstream.tools/verify_announce.py— builds anlxmf.deliveryannounce with RNS using the web client's identity and runs it throughIdentity.validate_announce, proving our announce format is acceptable to the upstream reference.tools/rns_responder.py— runs Python RNS as a link responder against a supplied LINKREQUEST data field, captures the LRPROOF bytes RNS would emit, and prints them field by field for a byte-for-byte diff against the web client's own output.
All depend only on rns, umsgpack, and (for the bridge) websockets from pip.
- Open the browser DevTools console to see stack traces. The in-page log shows a terse one-line error, but the full trace only lives in the console.
- The webapp listens for
errorandunhandledrejectiononwindowand mirrors the message into the log, so uncaught errors from async handlers still show up. store.jsuses a single IndexedDB database namedreticulum-webclientwith object stores foridentity,contacts, andmessages. To wipe local state, open DevTools then Application then Storage then Clear site data.- The KISS parser accumulates bytes across BLE notifications and emits complete frames on FEND boundaries. BLE splits frames at arbitrary points, so any per-notification framing assumption will break.
- Reticulum destination hashes are computed with the identity hexhash outside the name hash input, matching upstream
Destination.hash(identity, app_name, *aspects). The hexhash appears only in the human-readableDestination.name, never in on-wire hashes. - LRPROOF packets have a special framing exception in upstream
Packet::pack: the 16-byte destination slot of the header carries the link_id instead of the SINGLE destination's hash, and the flag byte's destination-type bits are hardcoded toLINKregardless of the destination the packet was constructed with. OurbuildPacketmatches this by acceptingdestTypeanddestHashas explicit parameters rather than deriving them from a destination object. - Every accepted CONTEXT_NONE data packet on an established link gets an immediate PROOF packet sent back, carrying the 32-byte SHA-256 of the received packet's hashable part plus an Ed25519 signature of that hash. Without this packet receipt, the sender's delivery-receipt timeout fires and it retries on a fresh link, producing a "same message keeps arriving" loop.
- Periodic re-announcement is mandatory for inbound link delivery, not cosmetic. Relays validate inbound LRPROOFs by looking up the responder's identity in their own
Identity.known_destinationscache, and that cache gets GC'd — without a periodic refresh the LRPROOF is dropped at the relay before ever reaching the initiator. Seedocs/PROTOCOL_NOTES.md§14 for detail. - See
docs/PROTOCOL_NOTES.mdfor the full set of protocol-layer findings, including the destination hash formula, Web Crypto AES-CBC auto-padding gotcha, LXMF wire format differences between opportunistic and link delivery, stamp handling for signature verification, and the clockless-sender timestamp workaround.
All Reticulum protocol work — identity generation, ECDH key exchange, AES-256-CBC encryption, HMAC authentication, Ed25519 signing, LXMF message packing — runs inside your browser (or the Android WebView). The radio or daemon on the other end of the transport only sees fully encrypted packets.
What is protected:
- Message content is end-to-end encrypted. Each LXMF message uses a fresh ephemeral X25519 key exchange, HKDF-SHA256 key derivation, and AES-256-CBC + HMAC-SHA256 (Reticulum's Token / modified Fernet construction). Neither the transport layer, relay nodes, nor the rnsd daemon can read your messages.
- Delivery receipts on link-delivered messages include an Ed25519 signature that is computationally unforgeable without the responder's private key.
- Announce signatures are Ed25519-signed over the full announce body including the destination hash, public key, name hash, random hash, ratchet (if present), and app data. Forging an announce for a destination you do not own the private key for is infeasible.
What is NOT protected (known limitations):
- Private keys at rest are stored unencrypted in the browser's IndexedDB. Anyone with access to your browser profile — browser extensions with matching host permissions, device backup tools, physical access to an unlocked device, or root access on Android — can extract them. The Export Identity file is likewise unencrypted JSON containing the complete signing and encryption private keys. Treat it like a password.
- No forward secrecy. The ratchet key is generated once at identity creation and is never rotated. If an attacker obtains your private key, they can decrypt previously captured messages. Full ratchet rotation is deferred to a future release.
- BLE transport is cleartext at L2. Web Bluetooth does not request BLE bonding, so the NUS link between your device and the RNode modem is not encrypted at the Bluetooth radio layer. An observer within Bluetooth range (~10 m) can see the encrypted Reticulum packets and their headers (destination hashes, packet types, sizes, timing) but cannot decrypt message content.
- WebSocket
ws://to remote hosts exposes packet headers. When using the WebSocket transport to a non-localhost destination overws://(notwss://), Reticulum packet headers are visible to network observers on the path. Message content remains end-to-end encrypted, but destination hashes, packet types, and timing metadata leak. The app shows a visible warning banner when a non-localhostws://connection is active. Usewss://for remote connections if your bridge supports TLS. - Metadata. Reticulum packet headers contain 16-byte destination hashes in cleartext by design. Any observer on the radio channel or transport path can correlate who is communicating with whom by watching destination hashes, even though they cannot read message content. Periodic announces broadcast your full 64-byte public key, display name, and destination hash to the mesh every five minutes. This is inherent to the Reticulum protocol, not specific to this client.
- Map tiles. The Nodes view loads map tiles from OpenStreetMap (
tile.openstreetmap.org). The tile server sees your IP address and the geographic region you are viewing, though it does not see your Reticulum identity or messages.
Recommendations for alpha testers:
- Do not run this on a device where untrusted browser extensions have access to your browsing data.
- Use
wss://(notws://) for any WebSocket connection to a remote host. - Keep your Export Identity JSON file in a secure location (password manager, encrypted drive). Anyone who obtains it can impersonate you and decrypt your messages.
- Understand that your display name and destination hash are broadcast to the mesh every five minutes. Do not use a display name that reveals information you want to keep private.
- reticulum-rnode — the RNode firmware this client talks to.
- reticulum-lora-repeater — a repeater node built on the same LoRa stack. Its
docs/RATCHET_PROTOCOL.mdis the canonical reference for how Reticulum 0.7+ announces are laid out on the wire. - markqvist/Reticulum — upstream Python Reticulum.
- markqvist/LXMF — upstream LXMF message format.