A threaded message board for Meshtastic. Rooms have their own conversations, replies nest under the message they answer, and new replies bring the whole thread back to the top.
Built from Firefly, using its Flask, Socket.IO, SQLite, vnode, meshdb, and UDP multicast backend. The Harbor interface has light and dark modes, compact desktop panels, and a full-width mobile board.
The first account to sign in on a fresh installation becomes an admin (creating an account also signs it in). On an existing installation, the oldest account becomes the initial admin when this update is first started. Admins see Admin in the user menu, opening /admin.
The dashboard lists all users and their profiles, active or idle browser sessions, observed login time, messages received/sent, the active radio profile, and receiver statistics. It refreshes every 30 seconds. Admins can grant or remove admin access; the last admin cannot lose the role or delete their account until another admin is assigned. Admin routes enforce permissions on every request.
Admins can view each profile’s rooms, their primary/secondary order, and key sizes without exposing PSKs. Edit user changes a username and optionally resets its password; leaving the password blank keeps it. Delete user requires confirmation, removes the account, profiles, private messages, and session records, and disconnects its live chat sessions. Shared room messages remain. Admins cannot delete themselves through this page or remove the last admin.
Edit room renames a room or replaces its PSK on that profile only. Leave the replacement PSK blank to keep the current key. Remove removes the channel entry from that profile; it keeps other profiles and stored history. Keep at least one room per profile. Deactivate a profile currently using the radio before changing its rooms or deleting its user. Concurrent channel edits are rejected when the saved channel list has changed. Changing a name or PSK changes the mesh channel; share a new invitation with participants. Automatic dashboard refresh pauses while an editor is open.
Session tracking begins with this update and persists across restarts. A browser is active when its heartbeat was received in the last 90 seconds. Background tabs count as connected time; gaps without heartbeats count at most 90 seconds, so login time is an estimate. Packet counters cover the current server run. Message totals count retained database messages; shared room receives are counted for each subscribed profile. Password hashes, PSKs, session tokens, and message contents are excluded from admin reports.
The board loads the selected room’s 12 most recently active threads instead of downloading every room’s history. Older threads load automatically near the bottom; Load older threads is also available. Long threads load 40-message sections with the original, latest message, and a bounded amount of ancestor context. Load more replies loads another section; expanded threads also load it when that control approaches the viewport. A small notice identifies ancestry that has not loaded yet.
Only nearby threads are mounted in the page. Loaded history stays cached while navigating, and an active reply composer stays mounted to preserve its draft and focus. Each room keeps its scroll position. Tapback summaries include all known reactors even before every historical reaction row loads. Unread messages retain their unread state until seen; fetching a history page does not mark it read.
Live messages refresh their affected thread and lightweight unread/room previews. A reconnect refreshes the active room’s recent threads; older pages remain available. SQLite maintains a thread/activity index as messages arrive and builds it automatically for existing history during startup. The upgrade preserves messages and profile-specific history-clearing boundaries. DMs retain their existing bounded 200-message history.
Actual app captures using fictional sample messages, nodes, and telemetry. The sample mesh transport is stopped; these images do not represent live radio delivery.
Threaded board, dark mode — fuller conversations with nested replies, grouped emoji tapbacks, room unread counts, and starred nodes.
Node directory — grouped roster with expanded connection details, saved traceroute, telemetry, and position map.
Map data © OpenStreetMap contributors.
Mobile conversations and tapbacks — expanded reply threads with emoji bubbles. Tap a bubble to see who reacted.
Python 3.11–3.14 is supported. From this folder:
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python start.pyOpen http://localhost:5069 and create an account. The same username and password fields serve Sign In and Create Account.
- Open Profiles from the account menu or navigation.
- Create a profile with your long name, short name, and existing mesh channel name and PSK.
- Click Activate. MeshThreads opens the first channel’s messages.
In Create New Profile or Edit Profile, choose Detect 1-byte key channels to add public channels configured by any account in this install’s shared database that have received at least one successfully decoded packet from another node. Local virtual-node echoes, PKI DMs, and undecodable packets do not qualify. Evidence is stored for the exact channel name/key pair and survives restarts. Existing configurations need a fresh qualifying packet after this update; old hash-only message records are not treated as proof. Detection keeps your manual entries and skips duplicates. Review the channels before creating or saving the profile. Only channel names and one-byte PSKs are shared; private 16/32-byte keys and profile/account details are excluded. This reads known configurations, not a radio scan: packet hashes alone cannot recover channel names. See Meshtastic channel configuration for public channel key conventions.
In Edit Profile, use Move up and Move down to reorder channels, then save. The first channel is the primary channel. Reordering keeps each room’s message history.
Profiles are virtual nodes on the mesh. Each has its own identity and channels. When an account has exactly one profile, signing in automatically activates it and opens its first channel. Accounts with zero or multiple profiles open Profiles instead. The active profile’s card provides Deactivate.
MeshThreads uses Meshtastic UDP multicast. It does not connect directly to a radio over Bluetooth or serial. Your network must deliver the multicast traffic to this machine, and the channel name and PSK must match the mesh you want to use.
- Threads: every new top-level message starts a conversation. Replies use standard Meshtastic
reply_idmetadata, and replies to replies form indented branches. Any new reply bumps the original thread to the top. - Emoji reactions: flagged Meshtastic reactions and single-emoji replies appear as bubbles at the bottom right of their original message, in rooms and DMs. Tap a bubble to see its senders. Sending a single emoji as a reply sets the standard
emojiflag; standalone emoji posts remain regular messages. Flags, skin tones, and joined emoji sequences are supported. Existing single-emoji replies are recognized when history loads. - Expanded by default: reply branches stay open until manually collapsed. Reply links are always visible, including on touch screens.
- Unread activity: room counts, thread badges, and New labels identify unread messages. A channel is marked read after all its loaded unread messages have been visible in a focused tab. Collapsed or offscreen replies keep the channel unread.
- History: older posts remain available for thread reconstruction. If an original message was never received or its history was cleared, the board labels the missing original and groups the available replies.
- Direct messages: conversation headers identify the other node. Delivery status and retry controls help track outgoing messages.
New room adds a channel to the active profile with a cryptographically random 32-byte PSK. Room names allow up to 12 UTF-8 bytes.
Room settings lets you rename a channel, rotate its PSK, copy its Meshtastic URL, or leave the room. Changing the name or key changes the mesh channel; share the updated link with participants. Keep at least one channel per profile. Leaving a room preserves its stored history.
Invite to room sends a DM containing an invitation and the selected room’s standard Meshtastic channel URL. Anyone can invite a node to a room on their active profile. The link includes the room’s PSK.
Recipients choose Join room before the channel is added to their active profile. Join by URL accepts the same links. Repeated acceptance reopens the room; links containing multiple channels are rejected to avoid importing unrelated settings. Sending an invitation queues it for transmission—check its DM delivery status for acknowledgement.
The node directory uses a compact Starred / Other nodes roster with search, All/Starred filters, and sorting by last heard, name, or hops. Stars persist with your account across sessions and profiles.
Click a node header to expand its details. DM, invite, and star buttons keep their own actions. Tapping a sender name in chat or a node in the right-hand rail jumps to its directory entry and expands it.
Expanded details include:
- Received SNR, rooms where the node was heard, and public-key availability.
- Latest saved traceroute in both directions, including reported signal values and receipt time. Results persist per profile and destination across restarts.
- Latest stored telemetry by category: device, environment, power, air quality, radio statistics, and host metrics when reported. Missing readings are omitted, and each category shows its update time.
- An interactive map of the last reported position, with coordinates, altitude when available, and the position update time.
- Exchange node info and Traceroute actions.
Messages from unknown senders automatically trigger a node-info request after five seconds, using the active virtual profile and the received channel. If node info arrives during the delay, the request is skipped. Requests are staggered and limited to once per sender every ten minutes.
Telemetry loads when details are expanded and refreshes with directory updates. Viewing telemetry does not send extra telemetry requests over the radio. Metric definitions follow the Meshtastic telemetry schema.
Network map shows nodes with reported positions. Tap a marker, then its node ID, to open that node’s directory entry.
Desktop shows rooms on the left, conversations in the center, and nodes on the right. The sidebar displays the active profile and Web/Mesh status; tapping the profile name opens Profiles.
On phones, Rooms & navigation opens a compact drawer so conversations use the full screen width. Its button shows the total unread count. Choosing a destination or tapping the account name closes the drawer.
The header account menu contains Profiles, Account settings, and Logout. The sun/moon button switches light and dark mode and remembers your choice in that browser. Nunito Sans is bundled locally; Bootstrap, Socket.IO client, Leaflet, and map tiles require network access.
Firefly’s FIREFLY_* environment variables remain supported.
| Variable | Default for native startup |
|---|---|
FIREFLY_HOST |
0.0.0.0 |
FIREFLY_PORT |
5069 |
FIREFLY_MCAST_GRP |
239.0.0.69 |
FIREFLY_UDP_PORT |
4403 |
FIREFLY_DATABASE_FILE |
firefly.db |
FIREFLY_RUNTIME_DIR |
firefly_runtime/ beside the database |
FIREFLY_SECRET_KEY |
Generated persistently when unset |
For example:
FIREFLY_PORT=5069 .venv/bin/python start.pyKeep the database and its matching runtime identity directory together when backing up or restoring. Do not run the same restored identity simultaneously in Firefly and MeshThreads. The upstream documentation covers inherited configuration and identity migration.
docker compose up --build -d
docker compose logs -f meshthreadsOpen http://localhost:5069. The service is meshthreads, the container is meshthreads-app, and the persistent volume is meshthreads_data with Compose’s project prefix.
The default Compose configuration uses Linux host networking for UDP multicast. Docker Desktop requires multicast-capable networking; on macOS, native Python startup gives the app direct access to LAN multicast. See Docker setup and networking and .env.example. The example environment file uses Docker paths.
While signed in, open /api/network-status to inspect receiver state, channel and port counts, decoding failures, and listener errors. It excludes message bodies and PSKs. Counters reset when the app restarts.
inbound_by_portcounts received traffic, excluding local UDP echoes.local_echoes_by_portreports locally sent packets echoed over UDP.- Port
1is text,4is node info,67is telemetry, and70is traceroute.
Node-info and traceroute requests choose a channel where the destination was discovered, even when another private room is open. Their status messages identify the channel used. If no inbound packets arrive during a reply test, check multicast delivery. If packets arrive but cannot be decoded on a configured channel, check its name and PSK.
Browser reconnects reload stored history so messages received during a connection interruption can reappear. End-to-end delivery still requires a reachable mesh; encrypted DMs also require the recipient’s public key.
.venv/bin/python -m unittest discover -s tests -p 'test_*.py'
node --test tests/test_*.jsTests cover identity migration, channel routing, receive handling, message snapshots, thread ancestry, room links and invitations, account isolation, favorites, saved traceroutes, telemetry, login/profile behavior, unread state, and mobile navigation. Radio transport is mocked in API tests; hardware delivery requires testing on a real Meshtastic network.
MeshThreads is a Firefly derivative and retains the original GPLv3 license. Nunito Sans is distributed under its OFL license.
Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk.





