lnmsg: a terminal LXMF messenger

Status: design record. All ten open questions are decided.

This began as a draft for argument in which nothing was decided. Every significant decision was presented as options with trade-offs and a stated preference, and the preference was an opening position rather than a conclusion. Between 2026-08-08 and 2026-08-10 all ten questions were settled, so the document graduates from discussion to record and the remaining work is issues and batches.

The rejected alternatives are kept throughout, on all five pages. They are the why: a decision recorded without the options it beat is an assertion, and the next person to ask "why not SQLite / why not modal keys / why not a daemon" has to re-derive the argument from nothing.

1. What the program is

A terminal client for reading and sending LXMF messages over Reticulum, connected to a running lnsd or rnsd shared instance, the same way lnomad connects.

It must talk to a propagation node. A propagation node is the mailbox that holds messages addressed to a client that was not reachable. Without it, a laptop that is closed for eight hours simply does not receive mail, and the program is a toy. With it, the program is usable on hardware that is off most of the time, which is the actual deployment.

Scope explicitly excluded: hosting a propagation node. The library side of hosting has since been written: leviculum-lxmf carries both ends of the client to node exchange and the node role that stores uploads and answers /get (leviculum-lxmf/src/lib.rs:16-21, leviculum#384 part 1), and the node to node direction, peer sync and the /offer path, in its peering module (leviculum-lxmf/src/lib.rs:23-34, leviculum#384 part 2). The crate still performs no I/O, so a host owns links, resources and stamp-validation scheduling, and the two client modules lnmsg is built on keep hosting out in their own headers (leviculum-lxmf/src/router/propagation_runtime.rs:3-5, leviculum-lxmf/src/propagation_client.rs:5-8). Hosting is a separate program and a separate argument — and it now exists: the channel design record (Public channels over LXMF) reopens hosting, because the channel retrieval side only works on nodes we run.

The name

The house convention is one ln* counterpart per reference tool (Client tools, "A counterpart for every reference tool"), and the existing family is lnsd, lnstatus, lncp, lnomad. There is no single reference tool to be a counterpart of: LXMF messaging in the reference world lives inside NomadNet and Sideband, not in a standalone utility.

Decided 2026-08-10: lnmsg. Rejected: lnmail promises mail while the UI is chat (the input-model decision); lnchat promises chat while the protocol delivers mailbox behaviour on slow paths. "Message" is the only word the protocol can always honour, and lnmsg send in a cron job explains itself.

2. The ten decisions

#QuestionVerdictWhere
1Single process or daemon plus clientC built as A first: one process now, the router and store behind an interface so daemon mode is a wiring change. The lnsd-resident variant is rejected twice over. The core/frontend boundary is binding from day one, so a GUI is a frontend swap.Architecture
2Modal, always-insert, focus-follows-pane, or prefixC, focus-follows-pane, with a command palette and no modes. Enter in compose sends — the quiet keyboard's one named exception — Alt-Enter newline, empty-buffer guard, send_on_enter switch for email style. Ctrl-C clears to a recoverable draft and never quits.UI
3SQLite or a pure-Rust storeSQLite, via rusqlite with bundled. The musl static build was tried and works, FTS5 included. Identity-scoped schema, two timestamps, raw msgpack fields, attachments out of line.Storage
4Default sync interval, adaptive or notAsymmetric adaptivity: fifteen minutes as a hard never-exceeded upper bound, faster (about two minutes, decaying) after activity. Silent lengthening rejected as a breach of trust.Mailbox
5Trust: three states or four, derived "suspicious"?Exactly three — known, unknown, blocked. Trust follows from having named someone. No suspicious state: name collisions and name changes are normal mesh life, handled by display disambiguation.Mailbox
6Delivery ledger: visible, keypress, or debug?One keypress away in the per-message detail view, plus a one-cell coloured state glyph per message, all from one shared state table, with an ASCII fallback.UI
7Does lnmsg send block?No. Exit 0 means "queued cleanly" and claims nothing more. A success prints nothing (amended 2026-08-21: the ID came off stdout; it stays reachable as LNMSG_ENQUEUED id=… in the event log). --wait opts into blocking with timeout-is-not-failure exit codes; lnmsg status <id> answers later.Architecture
8Retention policyText forever, attachments under a total-bytes budget (about 500 MB by default, oldest evicted, the message row keeps name, hash and size and says so). Ledger rows share the message's retention. A per-conversation age cap is deliberately deferred.Storage
9The namelnmsg.above
10Close library gaps first or discover by building?Triage. Gaps 1, 2, 3 and 8 — announce names, the propagated-awaiting-collection state, file-backed storage, Display on errors — close in one library wave before lnmsg starts. The other eight become issues met in build order.Architecture

3. Scale and honesty about hardware

This runs on machines from a Raspberry Pi upward, which sets a few hard numbers.

Memory. The whole model plus the visible window, not the whole history. lnomad's eager full-page layout (lnomad/src/tui.rs:910-934) is acceptable for a page and not for a conversation. Attachments in a byte-budgeted cache, not a count-budgeted one (lnomad/src/image_cache.rs:11-14).

Disk. SD cards die from writes. This argues against NomadNet's file-per-message plus a full .index rewrite on every change, and for a store that appends. It also argues for batching persist() rather than calling it on every PersistenceRequested, at the cost of a bounded window of loss.

CPU. Proof-of-work is the one unbounded computation in the system. On a Pi it must run off the core lock and at low priority, and the UI must be able to say "this will take a while" with a number that was measured on that class of hardware.

The core lock is the real budget. 5 ms per hook call (PROCESSOR_TICK_BUDGET, leviculum-std/src/driver/processor.rs:181), against 3.2 ms to verify a 1 MiB message's signature per The core lock budget. A messenger doing anything expensive inside the hook stalls the node's inbound path for every other client of the shared instance. Everything that is not the router's own state machine belongs on the far side of a queue.

Airtime is the scarcest resource of all. Every design choice on these pages that trades bytes for clarity — a sync poll, an announce, a read receipt we are not going to invent — is spending the one thing that cannot be bought back.

4. Compatibility constraints

Non-negotiable, per the project's first priority.

  • No wire-format changes. Everything on these pages is expressible in LXMF as it is. The delivery ledger, the collision warning, the cost estimate and the mailbox screen are all local views over data the protocol already carries.
  • No new fields. If threading or replies are wanted, they use FIELD_THREAD (0x08), FIELD_REPLY_TO (0x30) and FIELD_REPLY_QUOTE (0x31) with the reference's semantics (reference/LXMF/LXMF/LXMF.py:15, :23-24), not a private encoding.
  • Unknown fields round-trip, as the library already guarantees (leviculum-lxmf/src/message.rs:5-8). A reply composed by us to a message from a newer client must not silently drop what we did not understand.
  • Names on the wire are bytes, and we sanitise for display without altering what we forward.
  • Interoperability is tested, not assumed. Per the project's test discipline, this program needs interop tests against real Python LXMF peers and a real propagation node, positive and negative, before it is called finished. leviculum-lxmf-node exists precisely to make such A/B comparisons possible with one driver (leviculum-lxmf-node/src/lib.rs:12-18), and the same trick applies here.

5. Provenance

Prior art, condensed

Two programs were read end to end before any of this was decided, and most of the arguments on the other four pages are arguments with one of them.

NomadNet. Storage is one directory per conversation named by the peer hash and one file per message named by the LXM hash, with unread and failed side-car counters and a msgpack .index that caches timestamp, state, title and the full content of every message (NomadNet's Conversation.py, lines 62-71, 120, 236, 93-109 and 944-960). Attachments are stripped out into storage/attachments/<hash>/ with a manifest and sanitised names, which is good. Everything else about the storage is not: scan_storage() does a full listdir and constructs a ConversationMessage for every file on every call, and update_message_widgets() rebuilds every widget and replaces the whole listbox on every change (Conversations.py, lines 2254-2291). NomadNet commit 7bc6911 exists specifically because calling the conversation list on every announce caused file-descriptor starvation during announce storms. There is no pruning, no paging and no search anywhere.

Its chat pane is a two-column layout with the editor in the frame footer and initial focus on it. Composition is an inline MessageEdit with a readline mixin; there is no $EDITOR integration, and send is Ctrl-D. Every command is Ctrl-modified, so plain typing always reaches the edit box, at the cost of an exhausted and context-overloaded Ctrl namespace: Ctrl-X is "delete conversation" in the list and "clear history" in the body, Ctrl-U is "ingest URI" and "purge failed", Ctrl-P is "my QR" and "paper message". Delivery state is glyph and colour only: the distinction between SENT, DELIVERED and propagated-SENT genuinely exists and gets three different styles, but it is never stated in words, and propagated shares a style with paper messages. Two honest touches: signature failure is rendered in plain English ("Unknown Origin", "Invalid Signature"), and on load a message stuck mid-flight that is no longer in the router's pending set is forced to FAILED.

Identity handling is where NomadNet is genuinely ahead of everything else: four trust levels with real UI consequences, and, uniquely, duplicate display names raise WARNING. Notification is a terminal bell, literally sys.stdout.write("\a").

columba. A Room database, fifteen entities, and two schema decisions worth taking. Every row is scoped by identityHash with composite primary and foreign keys, so multiple local identities coexist with cascading isolation. And receivedAt (local clock) is stored separately from timestamp (sender clock), with sorting on COALESCE(receivedAt, timestamp) and an explicit comment that this is immune to sender clock skew. NomadNet has the same problem and solves it worse, as a user-toggled sort mode on Ctrl-O. Paging3 with a DESC query and a reversed layout is the right answer to NomadNet's rebuild-everything problem.

Its propagation handling adds three things NomadNet lacks: a user-configurable sync interval, real failover to an alternative relay when one dies, and a structured SyncResult / SyncProgress sum type where manual syncs report loudly and periodic ones stay silent. The relay is modelled as a contact with an isMyRelay flag, which is a nice way to make the relationship visible. Contact status is a persisted enum — ACTIVE, PENDING_IDENTITY, UNRESOLVED — rather than a live key lookup, and name resolution has a documented precedence: in-memory cache, then the user's own nickname, then the announced name, then the stored conversation name. User nickname beating announced name is exactly right.

What columba gets wrong: it has no trust model at all. A search for trustLevel / isTrusted across its app, domain and data modules returns nothing outside tests, there is no duplicate-name detection, and consequently no trust gate on relay auto-selection. It will happily adopt the nearest stranger's relay. Its delivery display also collapses sent and propagated into the same single check mark, which is precisely the distinction that matters most on a delay-tolerant network. Its per-message detail screen, showing delivery method with a one-sentence explanation plus hop count, interface, RSSI and SNR, is the best idea in either program and is trivial to do in a terminal.

Neither program prunes history.

The citation convention on these pages

Claims about existing code carry file:line. Where something could not be verified, it says so.

In-tree citations were re-pinned against this tree when the concept was promoted (2026-08-10); the draft had been written days earlier and the leviculum-lxmf crate had moved substantially under it. The citation guard (leviculum-std/tests/doc_citations.rs) resolves every one of them.

Claims about the vendored references use the same form under reference/, for example reference/LXMF/LXMF/LXMRouter.py:38, which is what every other page in this book does.

NomadNet and columba are not vendored here and are not in this repo, so the guard cannot resolve a citation into either and would report one as a file that had been deleted or renamed. Their line references are therefore written with the file name backticked and the lines outside it — NomadNet's Conversation.py, lines 62-71 — which says exactly as much and does not claim a path this tree holds. Every such reference names NomadNet or columba in the same sentence, so a reader always knows which tree to open.