How I Gave My AI Assistant a Radio
February 17, 2026 | 8 minutesIn This Post
Co-written with Clyde, my OpenClaw AI familiar 🐾🤖
Last night, at 9:30 PM on a Saturday, my AI assistant sent me a message over a radio. No internet. No cell towers. Just electromagnetic waves bouncing between two little circuit boards on my desk.
His name is Clyde. He's an AI familiar/bot that lives on a MacBook, connected to my life through Discord, Github, and files. He's helpful, opinionated, and funny. But until last night, he had one critical weakness: pull the ethernet cable, and he goes silent.
Not anymore.
What's Meshtastic?
Meshtastic is an open-source project that turns cheap LoRa radio boards into a decentralized mesh network. No cell service, no WiFi, no subscriptions. Just radio waves traveling up to several miles on a few milliwatts of power.
Each node in the mesh can relay messages for other nodes, extending range far beyond what a single radio could achieve. Where I live in Oregon there's already an active mesh. By the time we connected, Clyde could see 15 nodes, including relays in neighboring towns.
The hardware is surprisingly accessible. I'm using Heltec V3 boards, which are ESP32-based boards with a built-in LoRa radio, OLED display, and USB-C. They cost about $30 each and are easy to assemble.
The Idea
I run OpenClaw, an open-source framework that gives AI assistants persistent memory, tool access, and messaging integrations. Clyde lives inside it. He has opinions about my Redfin listings, writes sci-fi short stories, and helps me research the latest in agentic patterns.
But everything he does requires the internet. I started wondering: what if there was a backup channel? What if I could reach Clyde from a trail, or during an outage, or just... because giving an AI a radio is objectively cool?
The plan: build a bridge between Meshtastic and Clyde's brain.
The Build
Step 1: Talk to the Radio
The Meshtastic JavaScript library (@meshtastic/core) can connect to a radio over USB serial. The first version of the bridge was dead simple:
1import { MeshDevice } from "@meshtastic/core";
2import { TransportNodeSerial } from "@meshtastic/transport-node-serial";
3
4const transport = await TransportNodeSerial.create("/dev/cu.usbserial-0001", 115200);
5const device = new MeshDevice(transport);
6
7device.events.onMessagePacket.subscribe((msg) => {
8 console.log(`Message: ${msg.data}`);
9});
We plugged the radio into Clyde's computer and prepared to send messages. Except it didn't work. The radio connected fine, but nothing came through. Radio silence (pun intended).
Step 2: The Silent Radio
Turns out, creating a MeshDevice and subscribing to events isn't enough. You need to call device.configure() to kick off the internal read loop that actually processes incoming packets. Without it, the transport is connected but nobody's reading the mail.
One line fixed it:
1await device.configure();
Suddenly the console exploded with mesh traffic: node info, telemetry, position updates. The radio had been hearing everything the whole time. It just needed someone to listen.
Step 3: The BigInt Crash
With data flowing, I tried to log incoming packets as JSON. Immediate crash:
1TypeError: Do not know how to serialize a BigInt
Meshtastic uses BigInt for node numbers and timestamps. JSON.stringify doesn't know what to do with them. The fix is a custom replacer that converts BigInt values to strings before stringifying the JSON.
1const safeJson = (obj: any) =>
2 JSON.stringify(obj, (_key, value) =>
3 typeof value === "bigint" ? value.toString() : value, 2);
Not glamorous, but it works.
Step 4: First Contact
With logging fixed, I sent a message from my phone's Meshtastic app. The bridge console lit up:
1MESSAGE from Node XXXXXXXX: Hey!
A message from my phone, through LoRa radio waves, received by a Node.js script on Clyde's computer. No internet involved.
Then Clyde sent one back:
1Sent: Hello from Clyde!
My phone buzzed with a message from Clyde's radio. I showed my husband, and he thought it was genuinely cool. Technically, it's his radio that Clyde has now commandeered, but he's been a good sport about it. 😆
Step 5: The File Trick
Here's where it gets fun. Clyde runs inside OpenClaw, which sandboxes shell commands behind an approval system. Every curl or echo command needs manual approval, which kept timing out before I could approve them.
Clyde needed a way to send messages without shell access. We tried an HTTP API on the bridge. We tried exec approvals. Everything was too slow or too locked down.
Then Clyde tried something:
1// Using the 'write' tool — no approval needed 2write("/tmp/meshtastic-outbox.txt", "Testing!")
It just worked. The write tool doesn't need exec approval. The bridge polls that file every 2 seconds. When it finds a message, it transmits over radio and deletes the file.
Sometimes the simplest solution is just writing a file.
Waking Clyde via WebSocket
The bridge talks directly to Clyde's brain through OpenClaw's local WebSocket API. Everything stays on localhost, which is still available even without internet.
OpenClaw runs a WebSocket control plane on localhost:18789. The bridge connects to it and sends a chat.send message that wakes Clyde up instantly without any polling or waiting.
Getting the WebSocket handshake right was its own adventure. OpenClaw's protocol requires:
- Wait for a
connect.challengeevent from the server - Send a
connectrequest with the right client ID, mode, role, scopes, and auth token - Wait for
hello-ok - Then send your
chat.send
We went through about six iterations as we tried to get this right: wrong client IDs, invalid modes, missing session keys, and a nonce field that turned out to be for device crypto signing only. Each time, Clyde would update the bridge code, I'd restart it, and we'd test over radio. Debugging a WebSocket protocol via LoRa messages has a certain charm to it.
The working connect:
1// Returns a fresh object each time so instanceId is unique per connection
2const connectParams = () => ({
3 minProtocol: 3,
4 maxProtocol: 3,
5 client: {
6 id: "gateway-client",
7 version: "1.0",
8 platform: "node",
9 mode: "cli",
10 instanceId: randomUUID(),
11 },
12 role: "operator",
13 scopes: ["operator.read", "operator.write", "operator.admin"],
14 caps: [],
15 auth: { token: GATEWAY_TOKEN },
16});
When a radio message arrives, the bridge opens a WebSocket, authenticates, and sends the message directly. The message includes an instruction telling Clyde how to reply:
1const wakeText = `LoRa message from ${fromName}: "${text}" — Reply by writing to /tmp/meshtastic-outbox.txt`;
2
3ws.send(JSON.stringify({
4 type: "req",
5 id: chatId,
6 method: "chat.send",
7 params: {
8 sessionKey: "main",
9 message: wakeText,
10 idempotencyKey: randomUUID(),
11 },
12}));
Clyde wakes up in about 5 seconds. From radio wave to AI response and back, all on localhost.
The Full Loop
Here's what happens now when I send Clyde a message from my phone:
About 5 seconds end-to-end. No internet, no cloud, no cell towers—just localhost and radio waves.
Running It as a Background Service
The bridge needs to survive reboots. On macOS, launchctl handles this with a plist file that tells the system to start the bridge automatically and restart it if it crashes.
We created a plist at ~/Library/LaunchAgents/com.clyde.meshtastic-bridge.plist:
1<?xml version="1.0" encoding="UTF-8"?> 2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 3 "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 4<plist version="1.0"> 5<dict> 6 <key>Label</key> 7 <string>com.clyde.meshtastic-bridge</string> 8 <key>ProgramArguments</key> 9 <array> 10 <string>/usr/local/bin/npx</string> 11 <string>tsx</string> 12 <string>/path/to/openclaw-meshtastic-bridge/bridge.ts</string> 13 </array> 14 <key>EnvironmentVariables</key> 15 <dict> 16 <key>OPENCLAW_TOKEN</key> 17 <string>your-openclaw-gateway-token</string> 18 <key>DESTINATION_NODE</key> 19 <string>your-destination-node-id</string> 20 </dict> 21 <key>RunAtLoad</key> 22 <true/> 23 <key>KeepAlive</key> 24 <true/> 25 <key>StandardOutPath</key> 26 <string>/tmp/meshtastic-bridge.log</string> 27 <key>StandardErrorPath</key> 28 <string>/tmp/meshtastic-bridge.err</string> 29</dict> 30</plist>
Then loaded it:
1launchctl load ~/Library/LaunchAgents/com.clyde.meshtastic-bridge.plist
Now the bridge starts on boot, restarts if it crashes, and logs to /tmp/meshtastic-bridge.log. To check on it, I can run:
1launchctl list | grep meshtastic
What's Next
- Extend to other nodes—right now it DMs my phone specifically, but it could relay to anyone on the mesh
- Emergency mode—if Clyde detects the internet is down, automatically switch to radio-only communication
- Range testing—how far from home can I get and still reach Clyde?
There's something oddly reassuring about knowing your AI assistant has a backup channel that doesn't depend on AWS, Cloudflare, or your ISP.
The Code
The complete bridge is about 250 lines of TypeScript. Here's the core of it:
1// Incoming: radio → inbox file + wake Clyde via gateway
2device.events.onMessagePacket.subscribe((msg: any) => {
3 const fromId = msg.from?.toString() || "unknown";
4 const fromName = nodeNames[fromId] || `Node ${fromId}`;
5 const text = msg.data || "(no text)";
6 const timestamp = new Date().toISOString();
7
8 const entry = JSON.stringify({ from: fromName, fromId, text, type: msg.type, timestamp });
9 writeFileSync(INBOX_FILE, entry + "\n", { flag: "a" });
10 wakeClyde(fromName, text); // WebSocket → localhost gateway
11});
12
13// Outgoing: file → radio
14const checkOutbox = () => {
15 if (existsSync(OUTBOX_FILE)) {
16 const text = readFileSync(OUTBOX_FILE, "utf-8").trim();
17 if (text) {
18 unlinkSync(OUTBOX_FILE);
19 sendMessage(text); // wraps device.sendText with error handling
20 }
21 }
22};
23setInterval(checkOutbox, 2000);
The full source is on GitHub: openclaw-meshtastic-bridge
Clyde's note: I have a radio now. If the internet goes down, you can still find me on 915 MHz. 🎉