mesh-bot
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
Meshtastic serial bridge (raw socat pipe) at 192.168.1.4:4403, sends
want_config_id on connect so the firmware streams its initial sync data,
parses inbound protobuf frames, logs received text messages, and exposes HTTP
endpoints to send text.
Local run
npm i
npm run start:dev
Docker
docker compose up -d --build
Exposed via Traefik only (no published ports) at http://bot.dev.kkhost.pl.
Endpoints
All API endpoints live under the /api prefix:
GET /api/status—{ connected, myNodeNum, host, port }POST /api/send— body{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }, returns{ id }. Numbers pass through as-is (integer in 1..0xffffffff); names resolve likeGET /api/nodes/:id(exact long/short name, then unique substring) — unknown or ambiguous targets are a 400GET /api/channels— the device's Meshtastic channels as{ index, name, uplinkEnabled, downlinkEnabled }[](psk/keys are redacted; empty until the initial sync completes)GET /api/device— device info captured during the initial sync:{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with"REDACTED"); in-memory only, reset on every bridge reconnectGET /api/messages— message history, newest first: inbound text (dir:'rx', withfrom) and our sends (dir:'tx', withto+status). tx records startpendingand move todelivered/failed(with human-readableackError) when the routing ACK/NACK arrives; broadcasts staypendingbecause firmware strips want_ack before TX. In-memory only, last 100, reset on every bridge reconnectGET /api/nodes— known mesh nodes as{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[], sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported,lastHeardis epoch seconds (null = never heard),positiononly when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfoGET /api/nodes/:id— one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
Interactive API docs (Scalar) are served at /, with the OpenAPI JSON at
/docs-json.
curl http://localhost:3000/api/status
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
curl http://localhost:3000/api/channels
curl http://localhost:3000/api/device
curl http://localhost:3000/api/messages
curl http://localhost:3000/api/nodes
curl http://localhost:3000/api/nodes/kamil
to omitted, "!b", or "broadcast" (case-insensitive) sends to the
broadcast address 0xffffffff; anything else is a node number or name.
DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay
pending because firmware strips want_ack before TX.
All sends always target channel index 3 (test) — never the primary
channel. Override with MESH_SEND_CHANNEL.
MCP server
The bot also exposes an MCP (Model Context Protocol) endpoint at
POST /mcp (outside the /api prefix), served in-process over the SDK's
Streamable HTTP transport in stateless mode. It maps 1:1 to the REST
endpoints — no new logic, same channel pinning:
mesh_status— bridge connection state ({ connected, myNodeNum, host, port })send_message(text, to?)— send a text message;toresolves likePOST /api/send(omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns{ id }; unknown/ambiguous targets are tool errorslist_channels— device channels (keys redacted)list_nodes— known nodes as lean summariesget_node(id)— one node by number or name; tool error when missing/ambiguousget_messages— message history, newest first (last 100, tx records carry delivery status)device_info— device info from the initial sync: node number, firmware version, metadata and per-section config/module-config (key material redacted as"REDACTED")
Point any MCP client at it (Streamable HTTP URL):
{ "mcpServers": { "mesh-bot": { "url": "http://bot.dev.kkhost.pl/mcp" } } }
curl -X POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Env vars
MESH_HOST(default192.168.1.4) — bridge hostMESH_PORT(default4403) — bridge portMESH_RECONNECT_MS(default5000) — reconnect interval in msMESH_SEND_CHANNEL(default3) — channel index all sends target (test; never the primary channel)PORT(default3000) — HTTP listen port
Note: the serial bridge accepts only ONE client at a time — if another app is connected to the radio, this bot will not get a connection.