Building a Mesh Network Emergency Distribution Kit (And Hitting a File Download Wall)

Date

Date

April 24, 2026

April 24, 2026

Author

Author

lisa zhao

lisa zhao

Part of the Poutine Node series — building decentralized mesh infrastructure in Calgary, Alberta.

If the internet went down tomorrow, how would you help your neighbours get onto a mesh network? They'd need software they can't download. They'd need firmware they can't flash. They'd need documentation they can't reach. The bootstrap problem for decentralized networks is real, and most of the existing tutorials assume you can just pip install things.

I spent a session this week trying to solve that problem. I got most of the way there. The last mile — actually getting the files onto someone's laptop — turned out to be harder than the rest of the work combined. This post documents what I built, where it broke, and how I plan to fix it.

The design

The idea started simple. My Raspberry Pi already hosts a NomadNet node with tutorial pages on how to set up Reticulum. What if the same Pi also hosted the actual Reticulum software? A visitor's laptop connects to my WiFi, browses to my node over Reticulum, reads the tutorial, downloads the installer bundle, and walks away with everything they need to stand up their own node. No internet required at any point.

I scoped the audience into three rings:

Ring 1: Consumers. Have a laptop or phone. Want information and messaging. Maybe just browse pages from my node over WiFi.

Ring 2: Contributors. Have a laptop they can dedicate. Want to extend the mesh by running their own node.

Ring 3: Radio operators. Have LoRa hardware. Want to add long-range links between WiFi islands.

Most existing Reticulum content assumes Ring 3. But Ring 2 — just laptops on WiFi — is the realistic path for most neighbours in an outage. Laptops are common. LoRa boards are not. So the primary target became "a laptop with nothing but Python installed."

What I built

Two offline installer bundles.

The Reticulum bundle contains Python wheels for rns, lxmf, nomadnet, and esptool, pre-downloaded for Linux, macOS, and Windows. A snapshot of MeshChat's source. RNode firmware for LilyGo LoRa32 v2.1, Heltec LoRa32 v3, and T-Beam boards. The Sideband APK for Android sideloading. Setup scripts that run pip install --no-index --find-links=wheels so everything installs from local files with no internet access. A beginner-friendly README, a usage guide, RNode flashing instructions for both rnodeconf (the easy path) and esptool.py (the fallback), a contact page, a config template, and a licenses summary.

The MeshCore bundle contains MeshCore firmware binaries for Heltec v3 (companion USB, companion BLE, and repeater variants) and T-Deck. Flashing instructions using esptool.py. The same esptool wheels. A bridge setup guide for anyone who wants to connect MeshCore to Reticulum.

Total size: about 141 MB, mostly because the Sideband APK alone is 113 MB.

Two new Micron pages went under the tutorial: "Downloading Reticulum With No Internet" and "Downloading MeshCore With No Internet." The tutorial index got a new "Emergency Distribution" banner at the top pointing to both.

I wrote a finalize script that runs on the Pi itself (which has internet) to grab the firmware files and APK from GitHub, because the sandbox where the initial build happened couldn't reach GitHub's release CDN. The Pi did the downloads, packed the tarball, and dropped it into ~/.nomadnetwork/storage/files/reticulum_offline_bundle.tar.gz.

And then I got to the download.

The wall

The pages rendered. The "Emergency Distribution" banner showed up. The link to download the bundle was there. I clicked it. MeshChat's progress bar moved to 100% and then… nothing. No file on my Mac. No entry in Chrome's download history. Nothing.

I tried a 24-byte test.txt instead of the 141 MB bundle, assuming it was a size limit. Same behaviour. Hang at 0%, sometimes "100%" with no file appearing.

Typing URLs into MeshChat's address bar manually got different errors. Using the node's Primary Identity hash gave "Could not find path to destination." Using the LXMF delivery hash made the download start, but then stall.

I dug through MeshChat's source code to see how file downloads actually work. The flow is:

  1. Browser sends nomadnet.file.download WebSocket message to MeshChat's Python backend

  2. Backend creates a NomadnetFileDownloader to fetch from the target node

  3. On success, the entire file is base64-encoded and packed into a single JSON message

  4. That message goes back to the browser over WebSocket

  5. Browser JavaScript decodes the base64 and triggers a download prompt

I had a theory that 141 MB was too big for the WebSocket. Base64 inflates by ~33%, so we'd be shoving ~188 MB through a single WebSocket message, which is well past most buffer limits. But that didn't explain test.txt also failing at 24 bytes.

I checked NomadNet's config. No file serving settings to enable or disable — files in ~/.nomadnetwork/storage/files/ are served automatically. The file existed, was readable, had the right permissions. Direct Python could read it fine.

I restarted services half a dozen times. Cleared caches. Reset MeshChat. Checked versions. Both RNS (1.1.4) and MeshChat (v2.3.0 plus 9 commits) were current. Two relevant bug fixes in MeshChat's release notes matched my exact symptoms:

  • v2.1.0: "Fixed bug where downloading files from Nomad Network never finished when reaching 100%"

  • v2.3.0: "Fixed bug where downloading files from nomadnetwork was not working for some nodes"

Both should have been present in my build. Both had been fixed. And yet I was reproducing both symptoms.

At that point I stopped. Whatever was breaking was either a Pi 3B+ specific issue, a Python 3.13 quirk, or a regression in one of the nine post-v2.3.0 commits I had. Figuring out which would have meant either git-bisecting MeshChat or filing an issue with Liam. Either is a full project in itself.

The pragmatic path forward

I realized, about six hours in, that I was solving the wrong problem.

The whole point of the emergency distribution kit is that someone in an outage can download software to their laptop. In that scenario, they probably don't have MeshChat installed either — that's one of the things they're trying to install. So making MeshChat the download mechanism is kind of circular.

A plain HTTP server on the Pi, serving the exact same files over a regular browser, solves the whole problem. Any laptop with a browser can download. No Reticulum stack needed. No WebSocket issues. No stuck-at-100% bugs. The Micron tutorials continue to work over the mesh, which is where they belong — the actual bulky download just switches to HTTP.

sudo tee /etc/systemd/system/poutine_files.service > /dev/null << 'EOF'
[Unit]
Description=Poutine Node File Server (HTTP)
After=network.target

[Service]
Type=simple
User=pizzagal
Group=pizzagal
WorkingDirectory=/home/pizzagal/.nomadnetwork/storage/files
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 0.0.0.0
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

That's it. Seventeen lines of systemd, one line of Python. Serves files from the same folder NomadNet already uses, so there's zero duplication.

The Micron page can point at the HTTP URL as the primary download method. Someone in the emergency scenario connects to the Pi's WiFi, opens their browser, navigates to http://pizza.local:8080/, picks the bundle they want, and it downloads through the browser's own download manager — which is extremely good at handling 141 MB files, because that's what browsers are for.

Elegant? No. The "everything over the mesh" aesthetic is broken. But in an outage, elegance is less useful than actually working.

What I'll try later to make MeshChat downloads work

I don't want to abandon the all-mesh version forever. It's cleaner conceptually, and for people who ARE on the mesh already (other nodes, not just local visitors), it's the right mechanism. Future troubleshooting ideas:

Split the bundle into pieces under 10 MB each. Individual wheels, individual firmware zips, individual documentation files. Each small enough that base64-over-WebSocket doesn't stress anything. The setup script on the visitor's laptop reassembles them. This is annoying but would isolate whether size is actually the issue.

Test with stock MeshChat v2.3.0 instead of master HEAD. I'm nine commits past the release tag. One of those commits may have regressed file downloads. A simple git checkout v2.3.0 followed by a restart would rule this in or out in ten minutes.

Run MeshChat with --log-level DEBUG or similar. Right now the journalctl output only shows "using existing link for request" over and over. There has to be more verbose logging available that would show the actual state transitions — request sent, bytes received, transfer complete, response sent to browser. If I can see WHERE it stops, I'll know which side of the handoff is broken.

File a GitHub issue with Liam. Attach the test.txt-fails case with logs. It's a reproducible, minimal scenario that doesn't depend on my specific bundle. Whatever's broken is probably broken for others too.

Try on a Pi 4 or x86 Linux box. If downloads work on different hardware, something about the Pi 3B+ (ARM, maybe memory pressure) is the trigger. If they fail everywhere, it's a software regression.

Try a different browser. I tested only with Chrome. Safari or Firefox might behave differently if the base64-to-download handoff is a browser-API quirk.

What actually worked today

Despite the download wall, the session wasn't wasted:

  • The bundles themselves are solid. They're on the Pi, 141 MB of real content, ready to serve. The finalize script reliably fetches firmware and APK from GitHub. Anyone can scp the tarball off manually and it works.

  • Five translated index pages got built — Spanish, French, Mandarin Chinese, Hindi, Russian. Each with translated welcome prose, a note that the rest of the node is in English, and a prominent link to the guestbook for anyone interested in hosting a localized node.

  • Cross-links between the Poutine Node and the RSS Warnings Canada node now work in both directions. Visitors to either can find their way to the other.

  • The "Send a Message" button finally works. It used to be a broken lxmf:// link. Now it's a proper contact page that displays the LXMF address with instructions for MeshChat, Sideband, and NomadNet clients.

  • The tutorial index got a new top-level "Emergency Distribution" banner explaining the concept and linking to both disaster pages. Even with the download link temporarily broken, the scaffolding is there.

Lessons from the session

MeshChat is caching a lot more than it admits. Most "it didn't update" symptoms were actually the frontend showing stale pages. Full browser tab close/reopen cleared more state than hitting refresh. The MeshChat restart was usually necessary too, not just NomadNet.

Micron file link syntax uses a single colon, not double. `[Download`:/file/filename.ext] — not ::. The double-colon appears in MeshChat's URL bar as a display convention (separating node hash from path), but when writing the Micron source, you use a single colon. Same as page links.

Reticulum nodes expose multiple destination hashes. The Primary Identity, the nomadnetwork.node destination, and the lxmf.delivery destination are all different hashes derived from the same identity. File and page requests go to one, message delivery goes to another. Mixing them up produces surprising routing behaviour.

Always verify on disk, not in the message. Three separate times this session I thought a change had deployed when it hadn't. Either scp didn't land, or the file was edited but not saved, or a systemd restart missed the right service. cat and grep on the actual file every time. Trust only what's physically there.

The "downloaded 100%" progress bar in MeshChat is unreliable. It reaches 100% whether or not the file actually arrives at the browser. The only way to confirm a successful download is to look at the browser's actual downloads folder.

Debugging an async WebSocket handoff between Python and JavaScript is genuinely hard on a Pi. There's no easy way to watch the bytes go past. Console logs in the browser only catch what the JavaScript code explicitly logs. journalctl on the Pi only catches what the Python code explicitly logs. The middle — where the actual transfer happens — is opaque from both sides.

What this series has actually become

When I started writing the Poutine Node series, it was going to be a straightforward "here's how I set up a Reticulum node in Calgary" guide. Post one, hardware. Post two, flashing. Post three, software. A clean linear tutorial.

What it has actually become is a real-time record of building infrastructure that's supposed to work in emergencies, with the full ugliness of that work exposed: the dead testnets, the cached pages, the identity hashes that rotate on you, the third-party tools that break on large files, the ISPs that block non-standard ports, the forks of projects you need to use instead of the main branch. Meta-communications work is full of this.

I think that's actually more useful than a clean tutorial. A clean tutorial shows you the happy path. The happy path works great until it doesn't. A real record of building a thing, including the 12 dead-ends that got in the way, gives you the debugging mindset you'll actually need when your own build breaks in a slightly different way.

The emergency distribution kit is 90% working. The HTTP server workaround makes it 100% functional for the real-world use case, even if less elegant. The MeshChat download issue is still open. I'll come back to it.

Until next time.

Have questions or ideas? Leave a note in the node's guestbook, or message the LXMF address at 6a15d141be5fe304ba89e611851dba6d. This blog series tracks a physical Reticulum node in Calgary. You can visit it over the mesh if you're on the network, or read along here.

Related posts

April 23, 2026

How to Build NomadNet Nodes on a Raspberry Pi: A Complete Tutorial

Description

April 23, 2026

How to Build NomadNet Nodes on a Raspberry Pi: A Complete Tutorial

Description

April 23, 2026

Adding Emergency Alerts to the Pi, and Finally Setting Up Backups

Description

April 23, 2026

Adding Emergency Alerts to the Pi, and Finally Setting Up Backups

Description

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Lisa Zhao, 2025

XX

Lisa Zhao, 2025

XX