Federated social hubs for Reticulum.
Speakeasy is a decentralized, topic-based discussion network that runs entirely over the Reticulum Network Stack. Hubs federate with each other, clients can attach to any hub, and every record — chat message, bulletin, profile — is signed by its author's RNS identity, so authorship survives relaying across untrusted hops.
| File | Role |
|---|---|
speakeasy_daemon.py |
Hub daemon: announces itself, accepts client/peer links, relays and stores verified records |
reti_speakeasy.py |
Textual TUI client: host discovery and ranking, channels, bulletin board, comments, profile |
fed_engine.py |
S2S wire protocol (msgpack frames) and the epoch anti-entropy state machine |
speakeasy_db.py |
SQLite storage, verification choke point, epoch Merkle roots, local moderation state |
signing.py |
Canonical byte encodings, signing, and verification |
operator_iface.py |
LXMF endpoint that pages the hub operator, accepts management commands, and exchanges peer operator recommendations |
speakeasy_admin.py |
Offline CLI for reviewing the channel-request queue |
There is no server-side account system; identity is the RNS keypair.
- Every message, bulletin, and profile carries an Ed25519 signature over a canonical
encoding (
signing.py) of its fields. - Nothing is stored or re-relayed until
SpeakeasyDB.verify_and_*validates that signature against the claimed identity hash. That is the single choke point. - A public key is only accepted for an identity if it hashes to that identity's hash, so a peer cannot present its own key alongside somebody else's hash.
- Hubs re-pack records from their own storage when relaying, so a hub never forwards unverified bytes on a peer's behalf.
- Keys reach the people who need them by gossip, not by configuration: hubs push the
public keys and signed profiles they hold, so a client that has never met the author of
a relayed message can still verify it. A record whose author is still unknown is parked
(bounded) and an
IDENTITY_REQis sent, rather than silently discarded — mesh links do not guarantee that a key arrives before the message it signs.
Hubs reconcile history with epoch-bucketed anti-entropy:
FED_HELLO -> advertise channels + epoch bucket size
EPOCH_SYNC_REQ -> "what is your Merkle root for (channel, epoch)?"
EPOCH_SYNC_RESP -> roots; a mismatch means divergence
DELTA_REQ -> "here are the ids I hold; send what I lack"
DELTA_PUSH -> the missing signed records (MDU-sized batches)
FED_HELLO also carries optional peer operator metadata now: when LXMF operator control is
enabled, a hub advertises its operator endpoint hash there so peer hubs can learn where to
send blacklist recommendations out of band.
DELTA_REQ doubles as an advertisement of what the requester holds, so reconciliation is
symmetric: both sides end an exchange with the same records. Peers must agree on
federation.epoch_bucket_sec, otherwise epoch roots can never match — mismatches are
detected at FED_HELLO and logged.
A hub joining an existing mesh inherits history, not just new traffic. Two details make that work:
- The responder volunteers epochs. A hub with an empty database cannot name a single
historical epoch to ask about, so
EPOCH_SYNC_REQalso carries how far back the requester is willing to reconcile, and the peer offers roots for populated epochs inside that horizon. Without this, two hubs only ever compare the epoch they are both currently in — where they trivially agree — and history predating the link is never transferred. - Backfill is bounded, not a bulk transfer. Only epochs that actually hold messages are
compared, so a quiet fortnight costs nothing (at a 300 s bucket, two weeks is over 4000
epochs, nearly all empty). Each round covers at most
MAX_SYNC_EPOCHSchannel-epochs and successive rounds walk the window further back, wrapping around when they reach the oldest history. Merkle responses are chunked to fit the MDU.
The horizon is federation.max_sync_history_days, clamped to storage.message_ttl_days:
chasing history the node would delete on its next retention sweep would make two hubs trade
the same records forever.
With federation.auto_discover_peers enabled (the default) a hub registers an RNS announce
handler filtered to the speakeasy.host aspect, so hubs find each other simply by being on
the same Reticulum network — static_peers is a fallback, not a requirement. Announces from
the hub itself are ignored, discovered peers are linked at most once, and discovery stops at
node.max_clients.
For client-side host filtering, announces can optionally include a tiny fixed-size channel
summary (plus channel count) so the client can pre-filter likely hubs by channel before
connecting. This is a probabilistic prefilter, not an authority source: exact channel lists
still come from FED_HELLO after link establishment.
Channels are not purely local configuration. A client can request one (n in the TUI),
which reaches the hub as a CHANNEL_REQ and lands in a pending queue; nothing is created
yet. When the operator approves it, the hub signs the channel under its own identity and
federates it as a signed CHANNEL_ADD. Receiving hubs verify the approver's signature and
key-to-hash binding before adding it, accept it even if their own allowed_channels
predates the approval, and ignore replays, so the same approval circulating around a mesh
converges instead of looping.
Channel nominations are federated to peer hubs by default: when one hub accepts a
CHANNEL_REQ, it relays the nomination (including the original requester hash) so other
operators can review the same request independently for their own hub. Set
moderation.receive_federated_channel_nominations to false to opt out of receiving
federated nominations while still handling local client requests.
Approval happens over LXMF when moderation.operator_lxmf_hash is set: the hub messages the
operator on each request and accepts approve <channel> / deny <channel> replies only
from that exact source hash. Otherwise use the CLI, which drives the same queue:
python speakeasy_admin.py pending
python speakeasy_admin.py approve lounge
python speakeasy_admin.py deny spam
python speakeasy_admin.py add events --description "Community events"
python speakeasy_admin.py pause lounge
python speakeasy_admin.py resume lounge
python speakeasy_admin.py block spampause keeps a channel defined but refuses traffic, resume re-enables it, and
block refuses both traffic and future requests for that channel.
When moderation.operator_lxmf_hash is configured, the daemon starts an LXMF control endpoint
and sends an automatic startup heartbeat (Speakeasy online) to the configured operator hash.
If the route to the operator is not ready yet, the node retries delivery every 30 seconds until
it succeeds. This creates or refreshes a message thread in your LXMF client so operator actions
can stay in one place.
Send one command per line in the LXMF thread; multiple lines are processed in order.
| Command | Effect |
|---|---|
help or ? |
Show full command help |
status or stats |
Node status (uptime, links, channel state counts, pending requests) |
pending or requests |
List pending channel nominations |
recent [N] or audit [N] |
Show most recent operator actions (default 10, max 50) |
channels |
List channels and status (active, paused, blocked) |
recommend <identity> <reason> |
Broadcast a blacklist recommendation to known peer operators |
recommendations [N] or recs [N] |
Review aggregated inbound blacklist recommendations |
blockid <identity> [reason] |
Locally block an identity on this hub and purge its stored content |
unblockid <identity> |
Lift a local hub-level identity block |
approve <channel> |
Approve queued request and propagate signed channel add |
deny <channel> |
Deny queued request |
add <channel> [description] |
Create + approve channel immediately |
pause <channel> |
Keep channel but refuse traffic |
resume <channel> |
Re-enable channel traffic |
block <channel> |
Refuse channel traffic and future channel requests |
status
pending
approve lounge
recommend deadbeefdeadbeef spam flood from mirror hubs
recommendations 5
blockid deadbeefdeadbeef repeated forged traffic
recent 5
pause off-topic
channels
Operator actions are stored locally in the node database as an audit trail
(approve, deny, add, pause, resume, block, recommend, blockid,
unblockid, startup heartbeat, and inbound recommendation receipts), and can be
reviewed at any time from the same LXMF thread with recent.
Blacklist recommendations are advisory rather than automatic enforcement. A hub stores
inbound recommendations with their source and rationale, suppresses exact duplicate
submissions, and the recommendations view groups repeated reports by target identity so
the operator can see how many distinct peers have flagged the same identity before acting.
Command authorization is strict: only messages received from the exact configured
moderation.operator_lxmf_hash are accepted. Messages from any other LXMF identity are ignored.
Not yet verified on real hardware: the LXMF path has only been smoke-tested — the endpoint starts and announces, and the source-hash authorisation is in place, but issuing
approve <channel>from a second live LXMF peer (Sideband, MeshChat) has not been exercised end to end. Confirm it when running a hub on your own hardware withmoderation.operator_lxmf_hashpointed at your own address; the CLI above drives the same queue in the meantime.
Much of Reticulum runs on a Pi or a similar SBC with an SD card, so an unbounded message
table is a real failure mode — the node dies of a full disk instead of degrading by
forgetting old chatter. Retention is applied in three escalating steps on a timer
(storage.prune_interval_hours):
- Age — messages older than
storage.message_ttl_daysare dropped. - Depth per channel — each channel keeps at most
storage.max_messages_per_channelof its newest messages. The cap is per channel rather than global so one flooded channel cannot evict a quiet one's entire history. - Hard ceiling — if the database still exceeds
storage.max_db_mb, the oldest messages are shed until it fits. This is the backstop that makes a hub safe to run unattended.
The ceiling is measured against the size the database will occupy once free pages are
reclaimed, not its current size on disk. A DELETE only moves pages onto SQLite's freelist,
so straight after steps 1 and 2 the file still measures its old size — and step 3 reading
that would conclude the earlier pruning achieved nothing and shed the entire remaining
history while the real payload sat at a fraction of the budget.
Only message history is ever pruned. If the database is over budget with no messages left
to drop — the schema, indexes, identities and profiles alone exceeding the ceiling — the hub
logs a warning and stops, rather than deleting the keys it needs to verify anyone. Space
freed by deletes is returned to the filesystem by VACUUM on
storage.vacuum_interval_hours; SQLite is in WAL mode, so expect a -wal file alongside
the database in addition to the configured budget. Set any of these limits to 0 to
disable that step.
Blocking works at two distinct scopes: the node operator blackholes an identity for the whole node with Reticulum's own tooling, and a user blocks someone for their own client only. Speakeasy honours both.
The operator scope is RNS's blackhole list, which Speakeasy reads rather than duplicating:
rnpath -B <identity_hash> # blackhole an identity
rnpath -B <identity_hash> --duration 24 # ...for 24 hours
rnpath -b # list blackholed identities
rnpath -U <identity_hash> # lift itRNS applies the list to pathing: announces and paths for a blackholed identity are
dropped. That alone does nothing about a spammer whose records arrive relayed inside a
hub's frames, carrying no path of their own — so Speakeasy also applies it at the record
layer. A blackholed identity's messages, profiles, bulletins, channel requests and channel
approvals are refused before verification (their records are perfectly well signed, so the
signature check would happily let them through), their keys are neither learned nor
gossiped onward, records already stored from them are no longer relayed, and inbound links
from them are torn down. The list is consulted per record, so a block applied with rnpath
against a running node takes effect on the next frame rather than the next restart.
From the client, /block <handle|hash> blocks an identity for that client only and
purges what they already posted from its database, /unblock lifts it and /blocked lists
both scopes, marking operator blackholes as such. The TUI also exposes the same local list
through a dedicated Peer Blacklist manager. Blocked authors are filtered out of already-stored
chat, bulletin, and calendar history when those views are rendered.
At the hub/operator scope, blockid <identity> [reason] and unblockid <identity> do the
same thing for the daemon's own local database: a blocked identity's stored messages,
bulletins, comments, profiles, and authored calendar events are purged from that node while
future records from the same identity continue to be refused by the record-layer checks.
Peer operators can also exchange blacklist recommendations over LXMF. Those do not block
anyone automatically; they populate the operator review queue so each hub can decide whether
to apply a local block or a node-wide rnpath -B blackhole.
The BBS now has a real local lifecycle rather than acting as a flat append-only list.
- Bulletins auto-archive after 7 days by default.
- The active and archived sets can be browsed separately from the BBS view.
- The author of a bulletin can delete it locally from the client.
- The selected bulletin can be manually archived or restored from the BBS controls.
- When the BBS tab is active, the shared input box at the bottom of the screen becomes a reply box for the selected bulletin, storing threaded comments under that bulletin.
Comments are currently local client state: they are stored in the client's database and shown in the bulletin viewer, but they are not yet federated over the Speakeasy wire protocol.
A client block is stored in the client's own database, not in RNS's blackhole list, and so
will not appear in rnpath -b. This is deliberate: that list is node-wide state owned by
the master RNS instance, and Speakeasy processes are normally shared-instance clients, so
writing to it from a client changes only that process's in-memory copy — and the next
rnpath -B rewrites the shared file wholesale and silently drops the entry. It is also the
wrong scope: one user muting a bore should not blackhole them for every application on the
machine. Use rnpath -B when you do want a block to apply node-wide, including to a hub's
federated records.
pip install -r requirements.txt
python speakeasy_daemon.py speakeasy_config.jsonOr with Docker (state, including the database and identity, persists in ./data):
docker compose up --buildThe sample compose file mounts ./reticulum_conf to /etc/reticulum inside the
container. That directory should contain a Speakeasy-specific Reticulum client
configuration, not the full config of a transport node you are trying to attach to.
If Speakeasy should connect to an existing shared Reticulum instance over TCP, put
that client-only shared-instance config in ./reticulum_conf/config.
pip install -r requirements.txt
python reti_speakeasy.py [instance_name]instance_name selects an identity and database under ~/.reti_speakeasy, so several
clients can share one machine. Press h to pick a discovered host, p to edit your
profile, b to post a bulletin, k to manage the local peer blacklist, n to request a
new channel, m to manage per-host channel visibility, x to purge local data for an old
channel from this client database, and r to restore a previously purged channel. On the
BBS tab, the bottom input box posts a comment to the selected bulletin rather than sending a
chat message. The same purge action is also available from chat input via /dropchannel <name>
or /purgechannel <name>. The client mirrors the channel list its hub advertises, and refuses
to "send" to a channel the hub does not carry instead of storing a message nobody will ever
receive.
Host recovery is proactive: the client keeps a DB-backed host cache, scores candidates by hops/load/freshness, actively probes known hosts on startup, and can filter discovered hubs by their announced channel summaries before connecting.
speakeasy_config.json drives the daemon:
| Key | Effect |
|---|---|
node.name |
Hub name, announced to clients and used for the identity filename |
node.bandwidth_class |
LOW_MESH, MEDIUM_MESH, or HIGH_SPEED |
node.sync_interval_sec |
How often static peers are (re)connected |
node.announce_interval_sec |
Host announce cadence |
node.max_clients |
Inbound link ceiling; further links are torn down |
static_peers |
Destination hashes of hubs to federate with |
federation.epoch_bucket_sec |
Anti-entropy epoch width; must match peers |
federation.settlement_delay_ms |
Pause before initiating a peer link |
federation.auto_discover_peers |
Federate with any hub heard announcing, not just static_peers |
federation.include_channel_summary_in_announces |
Includes a compact channel summary in announces for low-overhead client host prefiltering |
channels.allowed_channels |
Channels this hub hosts; operator-approved channels are accepted on top of this |
moderation.accept_channel_requests |
Whether clients may queue channel requests |
moderation.receive_federated_channel_nominations |
Whether this hub accepts channel nominations relayed from federated peers |
moderation.operator_lxmf_hash |
LXMF address for operator control; the only source accepted for LXMF management commands |
channels.channel_blocklist |
Channels rejected outright |
channels.max_message_bytes |
Maximum message size accepted or signed (capped at MAX_MESSAGE_CONTENT_BYTES so a record always fits one frame) |
storage.db_filename |
Database name inside ~/.reti_speakeasy |
federation.max_sync_history_days |
How far back backfill reconciles; clamped to storage.message_ttl_days |
storage.message_ttl_days |
Retention window for the periodic prune sweep |
storage.max_messages_per_channel |
Per-channel message cap; 0 disables |
storage.max_db_mb |
Hard database size ceiling, enforced by shedding oldest history; 0 disables |
storage.prune_interval_hours |
Retention sweep cadence |
storage.vacuum_interval_hours |
How often freed pages are returned to the filesystem |
logging.* |
Log level and optional log file |
Session/load accounting on the daemon only counts links that are actually ACTIVE, and closed
links are pruned before announce/load decisions. That prevents reconnect churn from inflating
the hub's advertised load or exhausting node.max_clients with stale link objects.
pip install -r requirements.txt
pip install pytest ruff
ruff check .
pytest -qTests cover wire-frame round trips, signature tampering and impersonation rejection, two in-process hubs converging over a simulated link, key/profile gossip and out-of-order key recovery, signed channel approval and its propagation, announce-driven peer discovery, operator LXMF management and blacklist recommendations, local identity blocking, and the client-side bulletin/calendar moderation and lifecycle controls.