meshterm --demo runs the simulator in a synthetic Montréal mesh: 14 repeaters on the high points (Mont-Royal to Mont-St-Hilaire), rooms, sensors, and about twenty companions, all invented. A log-distance link model decides who hears whom, so floods arrive on several real paths and traces walk real routes. At each start the demo writes its own directory (<config>/demo) again: 300 days of adverts and a dense week of traffic, bilingual channel, direct, and room conversations whose packets decrypt, traces with their trophy-case records, neighbour tables, a joined room, admin passwords, regions, and Watchtower alerts. Every value comes from a fixed seed and an age before now, so each run gives the same data. The device plays the live traffic from the same seeds. The repository writers that stamp now take an optional time, and record_observations stores a batch in one commit. The test world of --mock is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
107 KiB
The MeshTerm command line
MeshTerm has two front ends on one codebase. The menu is a full-screen session for a
person to read. You get the menu when you run meshterm with no arguments. The command
line is the other front end, and this is its manual. It tells you what the command line
prints, what it returns, and each command that it accepts.
The command line prints in two ways. The way depends on who reads the output.
- The plain face is for a person at a prompt who typed a command to find something
out. It is the output of a Unix utility: one record on each line, no colour, and no
frames. It is easy to read. Names are bare, times read
5minstead of2026-09-08T04:26:00-04:00, and a route is drawn with arrows. - The JSON face (
--json) is for a program, often on another machine and often later. It is the contract: it has typed values, it spells absent asnull, its timestamps are in UTC, and its keys do not change. It works on each command.
Only --json promises that a program can read the output. The plain face does not make
this promise. If you split the output on whitespace to feed a script, stop. Use --json
instead, because the plain face can change between releases.
- Running it
- Where MeshTerm keeps its state
- About the examples
- The plain face
- The JSON face
- Exit status
- When it goes wrong
- Command reference
- Recipes
- What has no command
Running it
meshterm [GLOBAL OPTIONS] COMMAND [ARGS]
If you give no command, MeshTerm starts the interactive menu.
| Option | What it does |
|---|---|
--version |
Print meshterm <version> and exit 0, before MeshTerm opens anything else. |
-p, --profile NAME |
Use a named device profile from config.toml. |
--port PORT |
The serial port to connect to (COM5, /dev/ttyACM0). It overrides the profile. |
--ble ADDRESS |
The Bluetooth address of a companion. It selects the Bluetooth transport. |
--ble-pin PIN |
The pairing PIN, if the Bluetooth companion asks for one. On Windows and Linux, MeshTerm pairs with the PIN itself. On macOS, the system asks for the code in its own dialog, and this option has no effect. Refer to When a companion does not connect. |
--tcp HOST[:PORT] |
The network address of a TCP companion. It selects the TCP transport. The default port is 5000. |
--spi |
The LoRa radio on the SPI bus of this machine. It selects the SPI transport. MeshTerm runs the node for the length of the command. If one radio is attached, MeshTerm uses that radio, with a profile or without one. If no radio is attached, MeshTerm uses the one SPI profile, or else the wiring of the uConsole AIO. If two radios are attached, MeshTerm refuses (exit 3) and lists them. Select one with -p. Refer to Adding a radio on the SPI bus. Linux only. |
--mock |
Use the built-in simulator instead of real hardware. Nothing transmits, and the run records to meshterm-mock.db instead of your real history. |
--demo |
Use the simulator in a demo mesh over Montréal: about forty invented nodes, a week of traffic, conversations, traces, and records. The demo has its own directory (demo/ in the MeshTerm directory), and MeshTerm writes it again at each start. Thus each run starts from the same state, and your history never gets a node of the demo. It cannot take --db. |
--db PATH |
Use this SQLite database instead of ~/.meshterm/meshterm.db (or meshterm-mock.db, with --mock). It moves the database and nothing else. Refer to Where MeshTerm keeps its state. |
--json |
Print the answer as JSON instead of aligned text. |
--absolute |
For this run, print times as ISO-8601 instants instead of relative ages. |
-q, --quiet |
Do not print the log on the console. The log file still records it. |
--platform NAME |
Force the UI flavour (regular|picocalc-lyra|cardputer-zero) instead of detecting it. |
You can type a global option anywhere. You can type it before the command or after it.
meshterm --json contacts and meshterm contacts --json are the same run. The reason is
that -o json goes after the verb in each tool that has one, and --json is the first
option that most users try. MeshTerm does not move an option that follows a bare --.
The bare -- is the standard way to say that the rest is data. MeshTerm also never moves
--help, so meshterm contacts --help stays the help of contacts.
A command that needs a radio opens the radio, does its work, and closes the radio. Some
commands only read stored history (records) or the state of MeshTerm itself
(preferences, platform, specimen, and the written pages). These commands need no
device. They work with nothing attached.
One transmission for each run. A trace transmits exactly one time. Repeaters penalise nodes that send bursts of traffic, and they can blacklist these nodes. To get more samples, run the command again, with your own wait between the runs.
tx-optimizeis the one deliberate exception, and it paces itself.
Where MeshTerm keeps its state
MeshTerm keeps all that it remembers in one directory. On Linux and macOS, the directory
is ~/.meshterm. On Windows, it is %USERPROFILE%\.meshterm. $MESHTERM_HOME
overrides it. It is the only setting that moves the whole directory:
MESHTERM_HOME=/tmp/mesh-scratch meshterm contacts
MeshTerm reads the directory again on each run and does not cache it. Thus you can run two radios from two directories in the same shell.
| File | What it holds |
|---|---|
meshterm.db |
The history: each packet that MeshTerm heard, each trace that it walked, and each message. This is the one file that --db moves. |
meshterm-mock.db |
The same history for the simulator. A --mock run records here when the run does not name a database. The file is absent until you do a --mock run. |
demo/ |
The directory of the demo (--demo). MeshTerm writes everything in it again at each start of the demo, except the tile cache of the map and the log. |
config.toml |
The setup of the machine: device profiles, and where the database is. You write this file. MeshTerm only reads it. |
preferences.toml |
Your overrides of the behaviour of MeshTerm. It lists only what you changed. |
courier.json |
The outbox: queued messages, both waiting and finished. |
admin.json |
The repeater-admin passwords that MeshTerm remembers. |
devices.json |
The default companion that MeshTerm remembers, and each device that spoke the protocol correctly. |
contacts.json, channels.json, settings.json |
The caches of each device. They hold what the radio last told MeshTerm, so a screen opens without a round trip to the radio. |
adverts.json |
The clock of the weekly flood advert: when the advert was switched on, and when the week of each device began. |
mutes.json, watchtower.json, remote.json |
The muted channels. The watched nodes and their alerts. The remote-admin cache of each node, and the CLI history. |
regions.json |
The regions that MeshTerm knows by name, and the repeaters that carry each region. Also the send scope of each channel, and the last answer of each repeater to "which regions do you carry?". MeshTerm uses this file to name the region of a scoped packet. |
radio/<spidev>/ |
The node of an SPI radio. There is one folder for each device file (radio/spidev1.0/). It holds the identity key, the settings, the channels, the contacts, and the log (node.log). It is what the firmware keeps in flash. |
tilecache/ |
The basemap tiles that MeshTerm downloaded. |
meshterm.log |
The log file. |
.lock |
The lock for a single instance. |
--db alone does not isolate a run. It moves the database. The database is most of
the data, but it does not hold the identity. The contact caches, the channel caches, the
outbox, the stored admin passwords, and the remembered device are all in the config
directory. They stay the same as the ones that you use each day. To run with a scratch
state (a test, a demo, or a second radio), set MESHTERM_HOME. If you want the database
in another place, set --db inside it. The same is true for the database that --mock
selects. It keeps the invented mesh of the simulator out of your history, but not out of
the caches.
About the examples
Each sample below is the output of a real run against the built-in simulator
(--mock), in a temporary MESHTERM_HOME. The one exception is the diagnostics
sample, which says so where it appears. Nobody typed any of the output by hand. The
simulator answers as a companion does. It has four contacts (Alice, Yagi-Repeater,
Local-Repeater, Observer-Bot), it sends adverts, and it replies to traces. Thus the
shapes are real, but some numbers are invented.
The simulator does three things that a radio does not. You can see all three in the samples:
- Each run opens a new radio. If one command writes a setting or fills a channel
slot, the next command does not have it. Thus
config showalways reports the defaults, andchannels listalways answers5. On real hardware, the radio keeps the values. PKTSincontactsstays-. The tally of heard packets uses the node id that passive monitoring stores (four bytes here). The contact row uses the path hash width of the device (one byte). Thus the two do not match. Against real hardware with a matching width, they match.- A
tracethat the device routes invents its hop names. If you givetrace --target NAMEwith no--path, the simulator router returns placeholder hops. A forced path (--path "a1,d4") resolves correctly. The samples use the forced form. Use the forced form also to build a golden file.
--mockrecords into its own history, not yours, but it still writes the caches. The simulator is a fake radio. It is not a fake MeshTerm. MeshTerm stores what the simulator sends as adverts, in the same way as other data. The database that it writes ismeshterm-mock.db. Thus the invented nodes stay out of your mesh walk, your dashboard, and your map. If you name a database with--db, that choice wins. The contact caches, the channel caches, the outbox, and the remembered devices are still the ones that you use each day. For a demo that must not touch anything, give the demo its own home:MESHTERM_HOME=... meshterm --mock ….
The plain face
The plain face is the output of a Unix utility. It has one record on each line, nothing
folds, and stdout carries the answer and nothing else. The plain face has no rule that
exists only to make the output safe to split, because --json now does that job.
No colour. There is no hue, no bold, and no dim. Stdout has no escape sequence at all, also when stdout is not a terminal. A colour that goes into a file is noise in the file.
No frames. There are no borders, boxes, rules, titles, or legends. The command that you typed is the title.
Alignment is the delimiter, and names are bare. A listing is an uppercase header line
and records that are aligned with spaces. This is the shape that ps and df print.
Numeric columns align to the right.
$ meshterm contacts
NAME TYPE HEARD PKTS HASH LOCATION KEY
Observer-Bot node 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
Names are not in quotation marks. Quotation marks would give a script a delimiter, and they would fill each line with punctuation for the person who reads it. Names are escaped. A node broadcasts its own name, and a stranger writes the body of a message. Thus a name or a body must not hold a raw control character, and it must not end the record that it is in.
A time is an age, because a person opens a listing to find out "what is recent?".
Examples are now, 5m, 3h, and never. An absolute instant stays where the instant
is the fact itself: the device clock, an appointment that you set with --at, and the
TIME column of a live capture.
--absolute changes each age back to an instant. The instant is local ISO-8601, to
the second:
$ meshterm --absolute contacts
NAME TYPE HEARD PKTS HASH LOCATION KEY
Observer-Bot companion 2026-09-08T04:30:50-04:00 - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 2026-09-08T04:30:49-04:00 - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 2026-09-08T04:30:49-04:00 - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
Alice companion 2026-09-08T04:30:48-04:00 - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
The flag overrides a preference. It is not a second switch that works beside the
preference. The preference cli_time_format is relative or absolute. The command
meshterm preferences set cli_time_format absolute makes absolute the default for each
run. --absolute sets it for one run and never saves it. A value of the behaviour of
MeshTerm is a preference, by the rule of this project. A flag that bypassed the registry
would be a behaviour that nobody can find.
A route is drawn and a path is typed. They are different things, and they look different. These are two lines from the facts block of a trace:
path a1,d4
route MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)
A path is a spec. It is the list of hops that you write and force. It is the one line
on each face that you can send back as input, thus it stays as comma-separated hex, and
nothing else goes into it. Paste it into --path as it is. A route is the sequence
of nodes that a walk really took. You read a route and you do not type it, thus it is
drawn to be read. A hop is Name (hash), or the one half that MeshTerm knows. It is never
an empty ().
Our node is a hop like each other hop. It has a name and a hash. It is never the star of the menu. The star means "you already know who this is". This is true for the person who watches, but it is not true for a person who opens the file later. The hash is there because the CLI has no colour. On the screen, the hues that come from the keys show which hop is which. In plain text, the hash carries that identity. The hash also joins a route line to the table of hops under it.
Key/value blocks have a key, a gutter, and the rest of the line as the value. This is
the shape that sysctl -a prints. There is no header. The value is all the text that
follows the key.
$ meshterm info
name MockCompanion
public_key 0000000000000000000000000000000000000000000000000000000000000000
role companion
battery_v 4.10
uptime_s 93784 (1d 2h)
noise_floor_dbm -110
A reading can have a raw number as its fact, and a duration as its meaning. Then the
reading has an explanation in parentheses. 93784 is what the key promises and what a
caller wants. (1d 2h) is what a person wants to know. You do not have to parse one out
of the other.
A get prints the bare value alone, ready for $(...), because the caller named the key:
$ meshterm config get tx_power
20
- is the one token for absent. It means that the value is unknown, or that it does
not apply. You learn one token, and there is no em dash, no n/a, and no empty cell.
never is different. It is a value. It means that MeshTerm has not heard the node
yet. A node type stays the word repeater, because a monochrome glyph would need a
legend, and the CLI has no legends.
A live stream fixes its lanes. monitor and chat listen cannot measure a column
that they have not seen yet, thus their headings are fixed in advance. If a value is
wider than its lane, the value runs over the lane and pushes the rest of the row to the
right. MeshTerm does not cut anything. The one field that has no limit on its width is
the last field, so it pushes nothing.
No wrapping, with one exception. A record is one line. Wrapping would put half of the
fields of a record under the wrong headings. Thus nothing folds, and a long line runs off
the right edge. The exception is the four written pages (about, about-author,
discord, support). These wrap at 72 cells. The no-wrap rule protects the fields of a
record. A paragraph has no fields, and without wrapping it is a line of 600 cells that
no terminal can read.
Stdout is the answer. Everything else goes to stderr. This includes errors
(meshterm: what went wrong), progress bars, log records, acknowledgements, and closing
messages:
$ meshterm config set radio_sf 9
$ meshterm config set radio_sf 9 2>&1
✓ radio_sf = 9
✓ applied 1 change
An acknowledgement ("✓ device clock set") and the closing message of a command
("0 records stored") are for a person, and a script does not need them. They go to
stderr. Thus meshterm contacts > contacts.txt puts the contacts in the file and nothing
else, and the person at the prompt still sees the tick and the count.
The JSON face
--json prints the answer itself. The caller still gets the report in $?.
No envelope. A listing is an array. A set of facts is an object. Thus the command reads like the data that it returns:
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
A command whose plain face prints two blocks (trace, tx-optimize) gives one object.
Each listing is under its own key (edges, levels), and the facts are merged at the top
level.
One compact line, ended with a newline. The text is UTF-8 with no ASCII escaping, so a
name in Cyrillic stays a name. Keys are in the order that the report declares them, and
MeshTerm never sorts them. Thus a document reads from the top down in the same order as
its plain face. jq . changes the indentation at no cost. Nothing can change a document
that is pretty-printed across 400 lines back into a stream.
A stream is one document for each record, and all have the same shape. monitor and
chat listen print newline-delimited JSON when each record arrives, and they flush the
output. There is no begin line, no end line, and no discriminator field. Each reader
would pay for a discriminator, and only a stream would use it. A capture survives
truncation: you lose the last line and not the file. Ctrl-C ends a run, and this is how
--seconds 0 is designed to end. Such a run still keeps all that it heard.
Absent is null, never an omitted key. Each field that a command declares is in each
document. The field has null when the value is absent, unknown, or not applicable.
null is the counterpart of the - of the plain face, and you learn it once in the same
way. An empty collection is [] or {}. An empty string is "". It is a value, and it
is not the same as null. A whole object is null when its parts have no meaning apart:
"position": null, not {"lat": null, "lon": null}. Also, unknown becomes null. A
consumer already has one spelling for "nothing here".
Values are typed. Use 9, not "9". Use false, not "false". The yes and no
of the plain face (acked, applied, success, active) are true and false here.
A message body is raw and not escaped, with its newlines, because a JSON string is not a
line.
Timestamps are UTC, RFC 3339, Z, to the second. An example is
"2026-09-08T08:30:50Z". It has twenty characters, so a comparison of strings is a
comparison of times. The name of a field that holds a timestamp is <something>_at:
heard_at, observed_at, created_at, scheduled_at. --absolute does not change
this. A local offset is a fact about the machine that ran the command, and not about the
event. Two hosts that ask the same radio the same question must get the same document.
A numeric field has its unit in its key. Examples are snr_db, rssi_dbm,
uptime_s, rtt_ms, and battery_v. The unit is never in the value. A number has its
own sign (5.1 and -1.5, never the +5.1 of the plain column). A key is never
truncated. A truncated key cannot go back into --to or --path. Thus a short id is a
hash and has that name.
--json changes the rendering and never the report. The exit status is the same, and
the records are the same. An empty result prints its empty document and still exits
5:
$ meshterm channels list --json
[]
$ echo $?
5
"Prints nothing" is never a correct JSON answer. A command whose plain face is silent still says what it did:
$ meshterm channels join 3 brigade 4ed883aaded6433c6606c9930a65044c
$ meshterm channels join 3 brigade 4ed883aaded6433c6606c9930a65044c --json
{"channel":{"slot":3,"name":"brigade","public":false,"hash":"5a"},"secret":"4ed883aaded6433c6606c9930a65044c","url":"meshcore://channel/add?name=brigade&secret=4ed883aaded6433c6606c9930a65044c"}
A failure prints nothing on stdout. The sentence stays on stderr, and the
classification stays in $?. There is no error document that you must tell apart from a
result. Also, --json 2>/dev/null still works correctly. Refer to
When it goes wrong.
A command with no data face refuses, and says so clearly. meshterm specimen --json
is a usage error (exit 2). The output of specimen is the colour, and a document of it
would be false or without use. meshterm emulate --json is refused in the same way,
because emulate opens a window. --json with no subcommand is also refused, because an
interactive session cannot emit a document.
The shapes that repeat
This section defines each shape one time. The tables for each command below use the names and do not repeat the definitions.
node is the identity of a mesh participant, and nothing else. All five keys are
always present.
{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1","type":"repeater","self":false}
| Field | Type | Meaning |
|---|---|---|
name |
string | null | The name that the node advertises. It is never in quotation marks and never truncated. |
key |
string | null | The full public key, in lowercase hex, 64 characters. null when MeshTerm heard only a hash. |
hash |
string | null | The short id that comes from the key. Its width is the width that the command used to address the node. It is never a key that is cut short. |
type |
string | null | "companion" | "repeater" | "room server" | "sensor" | null. |
self |
boolean | true for our node. This is what the star of the menu shows. The plain face deliberately does not show it. |
Reception facts belong to the row and not to the node. These facts are when MeshTerm
heard the node, how strong the signal was, and where the node is. They are different for
each command. A node object that carried them would mean a different thing in each
command. Thus a row holds node under its own key and does not flatten it. Then
jq '.[].node.key' reads the same on contacts, courier, and monitor.
Identity is thin when it comes from history. A node that MeshTerm only overheard has
key: nullandtype: null. Itsnameis what the resolver could find. To match nodes across commands, join onhashand the width that the command used to address it. Do not join onkey.
position is {"lat":45.5019,"lon":-73.5674}. The values are decimal degrees as
stored, and they are not rounded. The five decimals of the plain face are a concession to
the width of the column. The whole object is null when the node has not shared a
location.
channel is {"slot":0,"name":"Public","public":true,"hash":"11"}. slot is the
value that the INDEX of each channels subcommand takes. public is the TYPE column
of the plain face as a boolean. The secret is never in a channel object. It appears
only when the caller asked for it, and then it has its own key.
setting is one device setting or one preference. show and get both use the
same shape.
{"key":"radio_sf","value":8,"type":"int","label":null,"redacted":false}
type is "int" | "float" | "bool" | "str" | "enum". value is the typed
current value. The value of an enum is its number, so .value | tostring is what
set takes as input. label is the word for a person for the number of an enum. For
other types, label is null. redacted is true when MeshTerm deliberately keeps the
value back. The value of a redacted row is null, because a mask string is not a
value. The rows of preferences also have default and overridden.
route and edges: a route is an array of node objects in the order of
propagation. Our node is a hop like each other hop. A route is null when nothing
came home, and never []. An edge is one directed link,
{"index":0,"from":{node},"to":{node},"snr_db":6.0}. The SNR is the value that was
measured when the packet arrived at to. The plain face names the two ends of an edge by
hash, so that you can join the table of hops to the route line above it. JSON holds the
whole node at both ends. The join is part of the structure here, and each edge must be
readable on its own.
Exit status
The exit status is half of the report, on both faces.
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
A failure that has no more specific code. Examples are an unreadable file, an unknown setting key or preference key (config set, preferences set), and an unhandled fault. |
2 |
Usage error: an unknown flag, a missing argument, a value that the parser rejected, or a confirmation that you did not give. |
3 |
MeshTerm could not select a companion device. None is attached, MeshTerm could not open the device that you named, or the choice was ambiguous. Nothing was transmitted. |
4 |
MeshTerm reached a device, but the operation failed. Examples are a command error, a timeout, and a link that was lost during the run. It is reasonable to try again. |
5 |
The command completed and there was nothing to report. Examples are an empty list, a target that never answered, and a conversation with no messages. This is not an error. |
meshterm --help also prints this table.
Two boundaries need an explanation. When nothing was transmitted, the failure is not a
device failure. If the tool refuses a value before it opens the radio, the error is a
usage error. Thus these cases are all 2: a missing --yes, a bad --sort or
--category, an unknown --profile, an outbox id that does not exist, and tx-optimize
with no password available. Also, a named connection that does not open gives 3, not
4. A --port that is not there means that MeshTerm selected no device, and nothing
went out.
Build your scripts on 5. grep gives the same status for "found nothing" and for
"worked". A caller that must count the lines of the output to tell an empty mesh from a
full mesh parses the text when it can branch on the status:
if meshterm contacts > contacts.txt; then
echo "$(($(wc -l < contacts.txt) - 1)) contacts" # minus the header
elif [ $? -eq 5 ]; then
echo "no contacts yet"
else
echo "could not reach the radio" >&2
fi
3 and 4 separate the failures that a new try can repair from the failures that it
cannot repair:
meshterm trace --target Alice
case $? in
0) echo "reachable" ;;
5) echo "no reply — the walk ran, nothing came home" ;;
3) echo "no radio attached" >&2; exit 1 ;;
4) echo "radio trouble — worth retrying" >&2; exit 1 ;;
*) echo "failed" >&2; exit 1 ;;
esac
When it goes wrong
MeshTerm raises a failure and never returns it. Nothing goes to stdout, one sentence
goes to stderr, and the classification goes to $?. This is the same on both faces.
--json does not put a failure in a document. A consumer that can see $? does not need
a document. A consumer that cannot see $? would have to tell an error document from a
result.
No radio. Nothing was transmitted. The exit status is 3 for each way to name the
device.
$ meshterm --port NOSUCHPORT99 info
meshterm: could not open serial port NOSUCHPORT99: NOSUCHPORT99 isn't there — the device was unplugged, or came back under another name.
$ echo $?
3
The sentence names the cause and the remedy. For a serial port, the cause can be a port that is missing, a port that another program uses, or a port that you cannot open. For Bluetooth, the cause can be a link that never opened, a pairing that is out of date, or a wrong PIN. When a companion does not connect lists all of these causes.
If you name no device and more than one candidate is attached, the same 3 arrives with
a list and the way to correct the problem:
$ meshterm info
meshterm: Multiple companion devices detected and no default to fall back on.
• COM12 (serial) — USB Serial Device (COM12) [likely LoRa]
• COM16 (serial) — USB Serial Device (COM16) [likely LoRa]
• COM26 (serial) — USB Serial Device (COM26) [likely LoRa]
• COM1 (serial) — Communications Port (COM1)
Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to inspect them). The chosen device is remembered as the default after it connects.
A value that the parser rejects gives 2. The message names the choices, so you do
not have to find them:
$ meshterm contacts --sort bogus
Usage: meshterm contacts [OPTIONS]
Try 'meshterm contacts --help' for help.
Error: Invalid value: --sort must be one of: name, heard, packets (got 'bogus')
A confirmation that you did not give is in the same class, because nothing has happened yet:
$ meshterm config reboot
Usage: meshterm config reboot [OPTIONS]
Try 'meshterm config reboot --help' for help.
Error: Invalid value: reboot restarts the device. Re-run with --yes to confirm.
$ echo $?
2
A destructive operation needs --yes and does not ask in a prompt. Thus nothing
unexpected happens in a script.
A refusal also gives 2. The caller asked for something that the command does not
have:
$ meshterm specimen --json
Usage: meshterm specimen [OPTIONS]
Try 'meshterm specimen --help' for help.
Error: Invalid value: specimen has no machine-readable output — its output is the colour
The radio answered and the operation failed gives 4. The message says what to do
next:
$ meshterm tx-optimize --path "Yagi-Repeater,Alice" --password wrong
logging in to Yagi-Repeater …
meshterm: admin login to 'Yagi-Repeater' failed (wrong password?). The saved password was cleared; re-run to enter a new one.
$ echo $?
4
Nothing to report gives 5, and it is not a failure. Stdout is empty on the plain
face. With --json, stdout has the empty document. A message from MeshTerm about the
result (0 records stored) goes to stderr, like each other acknowledgement.
$ meshterm records
$ echo $?
5
$ meshterm records --json
[]
With --json, none of this changes. The statuses are the same, and stderr is the same.
Stdout has the document for 0 and 5, and it has nothing for 1 through 4.
$ meshterm contacts --sort bogus --json
Usage: meshterm contacts [OPTIONS]
Try 'meshterm contacts --help' for help.
Error: Invalid value: --sort must be one of: name, heard, packets (got 'bogus')
$ echo $?
2
An unhandled fault exits with 1, and its traceback is in the log file. Each case
above is an expected failure, and MeshTerm reports it one time.
Command reference
Each entry says what the command does, gives its options, and shows what the plain face
prints and what --json returns. If a JSON field is one of the shapes that
repeat, the table names the shape and does not define it again.
Finding a device
meshterm devices
List each attached serial device, each radio on the SPI bus of this machine, and each
Bluetooth companion in range. The command opens no radio. It only lists what is attached
or what advertises. MeshTerm lists an SPI radio when its /dev/spidev* device file
exists. The device screen lists radios in the same way: one for each SPI profile, and
also the /dev/spidev1.0 of the uConsole AIO when no profile covers it. With --mock,
the command finds no real device. The simulator is the one device, and the list shows it
with --mock as its target. MeshTerm does not touch the serial ports or the Bluetooth
adapter. A session that promised to use no real hardware keeps the promise here also.
| Option | What it does |
|---|---|
--ble / --no-ble |
Include a Bluetooth LE scan. It is on by default. If you skip it, the command is faster by a few seconds. It has no effect with --mock. |
$ meshterm devices --no-ble
TARGET TRANSPORT NAME HARDWARE MESHCORE SERIAL ACTIVE
COM12 serial USB Serial Device (COM12) Adafruit maybe D030D2CCD00F08EB no
COM16 serial USB Serial Device (COM16) Adafruit maybe D8D7D4BB5E4546E0 no
COM26 serial USB Serial Device (COM26) Espressif maybe F85B1BA5EAD0 no
COM1 serial Communications Port (COM1) (Standard port types) no - no
TARGET is the value that --port and --ble take, exactly as shown. For an SPI radio,
TARGET is its device file. Reach the radio with --spi or with -p and its profile.
MESHCORE has three values and stays that way. yes is only for a device that a
connection proved to speak the protocol. maybe is for a USB vendor ID that suggests a
LoRa board or a bridge chip. no is for all other devices. A vendor ID is a hint, and
the column would be false if it changed a hint to a certain yes.
--json is an array of targets in the order of discovery. It has five facts that the
plain table has no room for.
{"target":"COM12","transport":"serial","port":"COM12","address":null,"label":"USB Serial Device (COM12)","hardware":"Adafruit","meshcore":"maybe","confidence":"board","serial_number":"D030D2CCD00F08EB","stable_id":"sn:D030D2CCD00F08EB","confirmed":false,"remembered":false,"active":false}
| Field | Type | Meaning |
|---|---|---|
target |
string | The value that --port and --ble take, exactly as shown. |
transport |
string | "serial" | "ble" | "tcp", or "mock" under --mock. |
port |
string | null | The serial port, for a serial device. "" on the --mock row. |
address |
string | null | The Bluetooth address, for a Bluetooth device. |
label |
string | The name that the operating system gives to the device, or the confirmed node name when MeshTerm has one. |
hardware |
string | null | The confirmed hardware model, or else the USB vendor label. "" on the --mock row. |
meshcore |
string | "yes" | "maybe" | "no". It is not a boolean, because a change of a hint to true would be false. |
confidence |
string | "board" | "bridge" | "unknown". It shows the source of a "maybe". |
serial_number |
string | null | |
stable_id |
string | The identity that the device store uses as its key ("sn:D030…", "port:COM1"). |
confirmed |
boolean | Whether MeshTerm stored this device as a proven companion. |
remembered |
boolean | Whether it is the remembered default. |
active |
boolean | Whether this run uses the device, or will use it. |
With --mock, there is no scan. The list is one row for the simulator: target
"--mock", transport "mock", stable_id "mock:simulator".
The command returns 5 when it finds nothing.
This node
meshterm info
The connected companion's live status: which radio it is, and how it works.
$ meshterm info
name MockCompanion
public_key 0000000000000000000000000000000000000000000000000000000000000000
role companion
model MeshCore Simulator
firmware mock mock
battery_v 4.10
storage_used_kb 128
storage_total_kb 1024
clock_at 2026-09-08T04:24:41-04:00
clock_drift_s -125 (2m 5s slow)
uptime_s 93784 (1d 2h)
noise_floor_dbm -110
last_rssi_dbm -62
last_snr_db +9.5
tx_air_s 42 (42s)
rx_air_s 360 (6m)
packets_sent 210
packets_received 1234
receive_errors 3
The unit is in the key, so you do not have to find it in a sentence. A reading whose
number is a duration has an explanation beside it: 93784 (1d 2h). The number is
what the key promises and what a caller wants. The text in parentheses is what a person
wants to know.
clock_at is the device's clock. clock_drift_s is the device's clock minus the host's
clock, with a sign. On the plain face, clock_at is one of the instants
that stay as instants when the default is a relative age. A clock reading shows the time
that the device thinks it is, so the value now would tell nothing.
If the firmware does not answer for a reading, the reading is absent, and the plain
face does not print -. A missing key means that this device does not report the value.
A dash would mean that the device reported nothing.
--json is one object with the same keys, without the explanations in parentheses.
Each key is always present:
{"name":"MockCompanion","public_key":"0000000000000000000000000000000000000000000000000000000000000000","role":"companion","model":"MeshCore Simulator","firmware":"mock mock","battery_v":4.1,"storage_used_kb":128,"storage_total_kb":1024,"clock_at":"2026-09-08T08:24:42Z","clock_drift_s":-125,"uptime_s":93784,"noise_floor_dbm":-110,"last_rssi_dbm":-62,"last_snr_db":9.5,"tx_air_s":42,"rx_air_s":360,"packets_sent":210,"packets_received":1234,"receive_errors":3}
role is "companion" | "repeater" | "room server" | "sensor" | null, or
"type N" for an advert type that MeshTerm does not know. This is the one place where
the two faces deliberately disagree about absence. The plain face omits a key that the
firmware never answered for. JSON writes null, because a set of keys that changes would
force each consumer to add a check before each field.
info does not hold a node object. This is our node, and info shows it in more
detail than the shape holds. name and public_key are its whole identity here. To find
what the radio is set to, use config show, not info. info never returns 5.
meshterm config
Show and change each device setting. If you give no subcommand, it runs show.
| Subcommand | What it does |
|---|---|
show |
Print each setting as key value. |
get KEY |
Print the value of one setting, bare. |
set KEY VALUE |
Change one setting. |
backup PATH |
Write each setting to a TOML file. |
restore PATH [--dry-run] |
Apply the settings from a TOML backup. --dry-run prints the plan and changes nothing. |
custom KEY VALUE |
Set an experimental custom variable. |
channel INDEX NAME [--secret HEX] |
Configure a channel slot (refer also to the channels group). |
advert [--flood] |
Broadcast an advert. It is zero-hop unless you give --flood. |
share |
Print this node's contact card as a meshcore:// URI. |
sync-clock |
Set the device clock from this computer. |
export-key [--out PATH] |
Export the private key: 64 bytes, as 128 hex digits. Sensitive. Only you can read the file that --out writes. |
import-key KEY_HEX --yes |
Import a private key. It overwrites this node's identity. Give 128 hex digits, or the 64 hex digits of a 32-byte seed. |
import-key --from PATH --yes |
Import the key in a file: hex text, as export-key --out writes it, or the raw bytes of a node's identity.key. |
reboot --yes |
Reboot the device. |
factory-reset --yes |
Erase all data and reset the settings to the defaults. |
$ meshterm config show
name MockCompanion
adv_lat 0.0
adv_lon 0.0
device_pin ••••••
radio_freq 869.618
radio_bw 62.5
radio_sf 8
radio_cr 5
tx_power 20
client_repeat false
airtime_factor 0.0
rx_delay 0.0
manual_add_contacts false
autoadd_config 0
autoadd_max_hops 0
flood_scope ""
adv_loc_policy 0
multi_acks 0
telemetry_mode_base 0
telemetry_mode_loc 0
telemetry_mode_env 0
path_hash_mode 0
show names each setting with the key that get and set take. It prints a value that
they accept as input: the number of an enum, not its label for a person, "" for an
empty string, and - for a value that the firmware never reported. Thus you can type a
line from show back as input, and the write says what it replaced:
$ meshterm config get radio_sf
8
$ meshterm config set radio_sf 9 --json
{"changes":1,"applied":[{"key":"radio_sf","previous":8,"value":9}]}
(If you read the value back with config get against real hardware, it answers 9. The
simulator that these examples use opens a new radio for each run, so a setting that one
command writes is not there for the next command.)
The pairing PIN stays masked here, because show is the dump of the whole device. A
person can send this output to a file or paste it into a bug report. To get the PIN, ask
for it by name with meshterm config get device_pin. This is the one place where
MeshTerm does not withhold it.
Custom variables have the namespace custom.* in the output, because they have no spec
and config set does not accept them. Use config custom.
--json. show is an array of setting objects in the same order. get is one
setting object.
$ meshterm config get tx_power --json
{"key":"tx_power","value":20,"type":"int","label":null,"redacted":false}
Three cases are separate. flood_scope is "" (an empty value). device_pin is null
with "redacted":true (MeshTerm withholds it on purpose, and a mask string is not a
value). A setting that the firmware never reported is null with "redacted":false.
Each write prints what it did. The plain face is silent, and only the acknowledgement on stderr says what happened:
$ meshterm config set radio_sf 10 --json
{"changes":1,"applied":[{"key":"radio_sf","previous":8,"value":10}]}
$ meshterm config custom foo bar --json
{"changes":1,"applied":[{"key":"custom.foo","previous":null,"value":"bar"}]}
$ meshterm config channel 2 brigade --json
{"changes":1,"channel":{"slot":2,"name":"brigade","public":true,"hash":"b5"}}
$ meshterm config advert --json
{"sent":true,"flood":false}
$ meshterm config sync-clock --json
{"changes":1,"set_at":"2026-09-08T08:29:27Z","drift_s":-125}
$ meshterm config backup node.toml --json
{"path":"node.toml","settings":22,"channels":0,"custom":0}
$ meshterm config restore node.toml --dry-run --json
{"dry_run":true,"changes":0}
$ meshterm config export-key --json
{"private_key":"1111111111111111111111111111111111111111111111111111111111111111","path":null}
$ meshterm config reboot --yes --json
{"rebooted":true}
$ meshterm config share --json
{"url":"meshcore://contact/add?name=MockCompanion&public_key=0000000000000000000000000000000000000000000000000000000000000000&type=1","name":"MockCompanion","public_key":"0000000000000000000000000000000000000000000000000000000000000000","type":1}
previous is null when the snapshot held no earlier value. If the value already was the
value that you asked for, changes is 0 and applied is []. The exit status stays
0, because the command did what you asked. The path of backup is the file that
MeshTerm wrote, as you named it. The plain face prints it bare, so a caller can keep it.
export-key has a private key, in the same way as the plain face. This is what you asked
the command to do, and MeshTerm adds no more gate here. factory-reset --yes answers
{"factory_reset":true}.
The menu draws a QR code beside share. This QR code has no JSON face and never will,
because it is a second rendering of url.
The mesh around you
meshterm contacts
The contacts that your device knows.
| Option | What it does |
|---|---|
-s, --sort ORDER |
heard (default), name, or packets. |
$ meshterm contacts --sort name
NAME TYPE HEARD PKTS HASH LOCATION KEY
Alice companion 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Observer-Bot companion 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
TYPE is the node's advertised role in words: companion, repeater, room server,
sensor, or unknown. HEARD is a relative age, because a person opens this listing to
find out "what is recent?". --absolute changes it to an instant. PKTS is the number of
packets that passive monitoring heard.
HASH is the token that --path and --to take. Thus you do not have to cut it out
of KEY by hand. If you use the wrong width, you address a different node. HASH has
exactly the device's path hash width. LOCATION is a fact that the data always had,
but no column had room for it before. KEY stays complete and is the last column, so it
runs off the right edge without harm, and MeshTerm never cuts it. A caller cannot use a
truncated key as input.
This lists contacts only. Your node is not a contact. meshterm info reports it,
in more detail than a row can hold.
--json is an array of rows. Each row holds the node shape.
{"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"companion","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}}
| Field | Type | Meaning |
|---|---|---|
node |
object | The node shape. hash has the device's path hash width. |
heard_at |
timestamp | null | When MeshTerm last heard the contact. null is the never of the plain face. |
packets |
integer | null | The number of packets that passive monitoring heard. null when it heard none. This is not the same as 0. |
position |
object | null | The position shape. |
The command returns 5 and [] when the device does not know a contact yet.
meshterm monitor
Capture the packets that MeshTerm hears in the foreground, for a limited time. Then summarize the capture. The command transmits nothing. It only listens. MeshTerm always records to the history, with or without this command. This command adds the live view.
| Option | What it does |
|---|---|
-s, --seconds N |
How long to capture. 0 (the default) runs until Ctrl-C. |
$ meshterm monitor --seconds 2
TIME NODE SNR_DB RSSI_DBM LOCATION SCOPE NAME
2026-09-08T04:26:00-04:00 a1b2c3d4 +7.0 -101 45.50190,-73.56740 - Yagi-Repeater
2026-09-08T04:26:00-04:00 b2c3d4e5 +9.5 -98 45.47680,-73.59900 - Local-Repeater
2026-09-08T04:26:00-04:00 c3d4e5f6 +4.9 -93 - - Observer-Bot
2026-09-08T04:26:00-04:00 d4e5f6a7 +7.4 -86 - - Alice
NODE NAME PKTS MEDIAN_SNR_DB BEST_SNR_DB RSSI_DBM HEARD LOCATION
d4e5f6a7 Alice 149 +6.9 +14.1 -93 now -
c3d4e5f6 Observer-Bot 148 +5.7 +13.0 -108 now -
b2c3d4e5 Local-Repeater 132 +6.7 +14.9 -102 now 45.47680,-73.59900
a1b2c3d4 Yagi-Repeater 132 +6.0 +13.6 -90 now 45.50190,-73.56740
The output has two blocks, one directly after the other. The summary starts at its own
NODE header line, and there is no blank line before it. The sample shows only the first
four stream records. The stream is a header and then one record for each packet when
the packet arrives. Its lanes are fixed in advance, because a capture cannot measure a
column that it has not seen yet. TIME is an absolute instant and not an age, because
each row of a live tail would read now otherwise. The summary gives the totals that
the stream cannot give: a count, a median, and a best value for each node. The node that
MeshTerm heard most recently is first.
--json is the stream and only the stream. It has one document for each packet, each
line has the same shape, and there is no summary document at the end:
{"observed_at":"2026-09-08T08:30:40Z","node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"kind":"advert","snr_db":7.0,"rssi_dbm":-101.3,"position":{"lat":45.5019,"lon":-73.5674},"scope":null,"path":null}
| Field | Type | Meaning |
|---|---|---|
observed_at |
timestamp | When the packet arrived. |
node |
object | The node shape. hash is the stored node id, four bytes wide. |
kind |
string | The packet class: "advert", "telemetry", "packet", "message", or "ack". |
snr_db |
number | null | |
rssi_dbm |
number | null | |
position |
object | null | The position shape. |
scope |
object | null | The region that the flood was sent into: {"state", "region", "code"}. state is "scoped" (region names it), "unknown" (the flood is scoped, but no region that MeshTerm knows produces code), or "unscoped" (a plain flood). code is the frame's transport code in lowercase hex. MeshTerm keeps it also when it cannot find the region, so you can see that two packets share a region. null for a direct packet, or for a class whose route was never reported. A repeater never filters these by region. The plain SCOPE lane prints the region, unknown, unscoped, or -. |
path |
array of strings | null | The relay chain that the packet arrived through. It has one lowercase-hex hash for each hop, in the order of propagation. [] is a direct reception. null means that the packet class has no path at all. This is the one place where path is an array, because it is a received relay chain and not a spec that you composed. |
This is the one place where a whole block appears on one face and not on the other. It is deliberate. The summary of each node helps a person who watches a capture end. You can calculate each figure in it from the records that scrolled past above it. If MeshTerm emitted the summary as a second shape of line in the middle of a stream where all lines are the same, each consumer would need a discriminator that it does not need otherwise. Thus the totals stay on the plain face, and JSON gives you the records that the totals come from:
$ meshterm monitor --seconds 60 --json > capture.ndjson
$ jq -s -c 'group_by(.node.hash)[]
| {hash: .[0].node.hash,
name: (map(.node.name) | map(select(. != null)) | first),
packets: length,
best_snr_db: (map(.snr_db) | max)}' capture.ndjson
{"hash":"a1b2c3d4","name":"Yagi-Repeater","packets":32,"best_snr_db":12.8}
{"hash":"b2c3d4e5","name":"Local-Repeater","packets":32,"best_snr_db":14.9}
{"hash":"c3d4e5f6","name":"Observer-Bot","packets":36,"best_snr_db":12.2}
{"hash":"d4e5f6a7","name":"Alice","packets":36,"best_snr_db":13.0}
The "monitoring for 2s" announcement and the closing count go to stderr on both faces.
The command returns 5 when the window heard nothing.
meshterm records
The record-setting walks that MeshTerm scores each trace against. The command reads the database. It needs no device and never transmits.
| Option | What it does |
|---|---|
-c, --category ID |
One discipline: long_haul, far_point, long_leg, grand_tour, clean_trail, thin_thread, big_loop. |
-w, --width N |
Only records that were set at this per-hop hash width (1, 2, or 4). |
The listing is CATEGORY WIDTH SCORE UNIT RECORDED VERSION ROUTE. SCORE is a bare
number, and UNIT is its own column, so you can compare the scores in a column. A score
with the prefix >= is a lower bound. This means that a hop on that walk has no known
position, so the distance is a minimum and not a measurement. MeshTerm keeps records for
each hash width. The width limits the maximum length of a walk and the chance of a
collision. Thus boards with different widths measure different games.
$ meshterm records
$ echo $?
5
The walks in the menu find the records. Thus a database that only the command line used has no records. This is what the simulator that these examples use prints, and it is correct.
--json is an array that has two facts that the columns cannot hold.
| Field | Type | Meaning |
|---|---|---|
category |
string | The discipline id, as --category takes it. |
hash_bytes |
integer | The per-hop hash width at which MeshTerm transmitted the walk. It is the WIDTH of the plain face, with the contract's word for the concept. |
score |
number | The bare score, without a qualifier. |
unit |
string | "km" | "nodes" | "dB" | "km²". |
lower_bound |
boolean | JSON only. true when the score is a minimum. It is the >= prefix of the plain face, extracted. Thus a consumer that compares scores does not have to remove a qualifier from the front of its own data. |
recorded_at |
timestamp | |
version |
string | The MeshTerm version that found the record. |
path |
string | null | JSON only. The spec that MeshTerm transmitted, in comma-separated hex. A caller uses it to walk the path again. It has no column, because a route line already runs off the right edge. null when the record has no stored spec. |
route |
array | The route shape, with hops that match path. |
The command returns 5 and [] when no records match.
Messaging
meshterm chat
meshterm chat on its own is not interactive. It prints the help of this group and exits
with 2. The live transcript is a menu screen. On the command line, select a subcommand.
| Subcommand | What it does |
|---|---|
send TEXT --to NAME |
Send a direct message, or post to a room. |
send TEXT --channel N [--scope REGION] |
Broadcast on a channel slot, with the channel's scope. For this one message, you can use REGION as the scope, or * for unscoped. |
history [--to NAME | --channel N] [--limit N] |
Print the stored transcript of a conversation. The default limit is 50. |
list |
Each channel, room, and contact, with its unread count and last message. |
listen [-s SECONDS] [--debug] |
Tail the inbound messages live. -s 0 (the default) runs until Ctrl-C. |
send and history need exactly one of --to and --channel.
$ meshterm chat send "on my way" --to Alice
acked yes
A direct message prints its acked state. This is the one fact that the send does not
already tell you. The radio accepts the message in each case, and whether the peer
answered is a separate fact. A channel broadcast prints nothing. There is no
acknowledgement on a channel, so the exit status is the whole answer.
$ meshterm chat history --to Alice
TIME DIR PEER SNR_DB TEXT
now out Alice - on my way
now out Alice - see you there
$ meshterm --absolute chat history --to Alice
TIME DIR PEER SNR_DB TEXT
2026-09-08T04:27:37-04:00 out Alice - on my way
2026-09-08T04:27:38-04:00 out Alice - see you there
DIR is in or out. PEER is always the other party. TEXT is the last column and
takes the rest of the line. A message body is the one field that can hold any text, so no
column can follow it.
Rooms. A room server is a message board that lives on a radio. First, join the room
with meshterm rooms join. After that, post with send --to, in the
same way as a direct message. acked yes means that the room stored the post. It does
not mean that a person read it. The history of a room names the author of each post.
The history of a direct conversation names the peer:
$ meshterm chat history --to "Lakeside BBS"
TIME DIR AUTHOR SNR_DB TEXT
12m in Alice (d4e5f6a7) +6.5 Anyone driving to the swap meet Saturday?
5m in e5f6a7b8 +2.0 Is the north repeater down?
now out MockCompanion (00000000) - I'll check it tonight
An author is a name with the hash of its key. If nothing that you heard names that key, the author is only the hash. Your own posts name your node.
$ meshterm chat list
CONVERSATION KIND UNREAD LAST LAST_TEXT
Public channel 0 never -
Yagi-Repeater direct 0 never -
Local-Repeater direct 0 never -
Observer-Bot direct 0 never -
Alice direct 0 never -
Lakeside BBS room 0 never -
$ meshterm chat listen -s 3
TIME PEER SNR_DB TEXT
2026-09-08T04:30:44-04:00 b2c3d4e5 +4.9 hello from Local-Repeater #1
2026-09-08T04:30:44-04:00 c3d4e5f6 -0.6 hello from Observer-Bot #2
2026-09-08T04:30:45-04:00 d4e5f6a7 +3.9 hello from Alice #3
listen fixes its lanes and puts an instant on each row. The stream of monitor has the
same shape, for the same reason. --debug sends the log of the message pull to stderr.
Use it to find out if MeshTerm pulls messages from the companion at all.
--json. send reports what it sent, and to whom:
$ meshterm chat send "see you there" --to Alice --json
{"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","self":false},"channel":null,"sent":true,"acked":true}
$ meshterm chat send "net in 5" --channel 0 --json
{"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null}
kind is "direct" | "room" | "channel". It shows which of node and channel
has a value (a room is a node).
A channel send also has scope, in the same shape that monitor gives for a received
packet. For a region, it is {"state":"scoped","region":"harbour","code":null}. For a
plain flood, it is "unscoped". When the scope is not known, it is null. This happens
for a direct message, and for a channel that has no scope of its own when MeshTerm could
not read the device's default. code is null on a send, because the code is a hash of
the packet, and the radio builds the packet.
--scope applies to a channel send only. A direct message has no scope of its own. The
radio floods it with the device's default, in the same way as other messages. Thus
--scope with --to is a usage error (exit 2). A name that the firmware would refuse
is also a usage error. A radio that cannot send with the scope (firmware older than
1.10, or * before 1.16 with a default scope set) refuses before it transmits anything.
This is a failure (exit 1). MeshTerm never sends the message unscoped without telling
you. Put the * in quotation marks, so that the shell does not change it:
$ meshterm chat send "all clear" --channel 0 --scope '*' --json
{"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null,"scope":{"state":"unscoped","region":null,"code":null}}
acked is null on a channel, not false. A channel has no acknowledgement, and this is
the purpose of the one absence token. It is also the reason why the plain face prints
nothing there: it has no way to say it.
history is an array, with the oldest message first:
{"created_at":"2026-09-08T08:27:37Z","direction":"out","node":{"name":"Alice","key":null,"hash":"d4e5f6a7","type":null,"self":false},"channel":null,"author":null,"snr_db":null,"text":"on my way","acked":true}
| Field | Type | Meaning |
|---|---|---|
created_at |
timestamp | |
direction |
string | "in" | "out". |
node |
object | null | The other party, for a direct conversation. |
channel |
object | null | The channel shape, for a channel conversation. |
author |
object | null | The author of a room post, as a node. On your own posts, it is your node. null outside a room. |
snr_db |
number | null | The SNR at reception. null on an outbound message. |
text |
string | The body, raw and not escaped. |
acked |
boolean | null | The delivery state of an outbound direct message. null for an inbound message and for a channel. |
list is an array of conversations, with the channels first. Each conversation has
kind, channel, node, unread, last_message_at, and last_text. listen is a
stream that has the same row shape as history, but it has received_at instead of
created_at:
{"received_at":"2026-09-08T08:30:47Z","node":{"name":null,"key":null,"hash":"b2c3d4e5","type":null,"self":false},"channel":null,"author":null,"snr_db":4.9,"text":"hello from Local-Repeater #1","direction":"in","acked":null}
A room post arrives from the room, so its node is the room. Its author has the
author's hash.
An inbound message has only the sender's key prefix. Thus its node has less data than
the same node in contacts. name is what the resolver could find, and key is null.
Join on hash.
history, list, and listen return 5 when there is nothing to report.
meshterm rooms
A room server is a message board that lives on a radio. It keeps the latest posts. After
you log in, it sends you each post that you missed, and then each new post when a person
writes it. To join a room, you log in with its password. You get the password from the
person who runs the room. meshterm rooms on its own prints the help of this group. The
Rooms page in the menu does the same jobs with dialogs.
| Subcommand | What it does |
|---|---|
list |
Each room server that your radio knows, joined or not. |
join ROOM [--password PASSWORD] [--flood] |
Log in, and print what the room lets you do. |
forget ROOM |
Stop the login to a room, and forget its password. |
$ meshterm rooms list
ROOM ACCESS HEARD LAST_POST UNREAD
Lakeside BBS member 12m 3m 0
$ meshterm rooms join "Lakeside BBS" --password hello
member
join prints what the room let you do. member means that you can read and post.
admin means that you are the room's owner. read-only means that the room does not
keep what you post. MeshTerm remembers a password that works, so the next join does not
need --password. An empty password (--password "") asks the room if it already knows
you. MeshCore sends a maximum of 15 characters of a password. MeshTerm refuses a longer
password before it sends anything (exit 2).
A room never says that a password is wrong. It does not answer. Thus a join that hears
nothing fails with exit 4. The message says what MeshTerm knows: how the login went
out, when MeshTerm last heard the room, and if this password worked before:
$ meshterm rooms join "Lakeside BBS" --password nope
meshterm: Lakeside BBS didn't answer. It went along the route your radio learned to the
room (1 hop), and a route goes stale when the mesh changes. The room was heard 12m ago. A
room doesn't answer a wrong password — it stays silent — so either it didn't hear you, or
the password is wrong. Try --flood.
Your radio sends a login along the route that it learned the last time that the room
answered. If that route is stale, the login is lost on the way. --flood makes the radio
forget the route first. Then the login goes to the whole mesh, and the room's answer
teaches the radio a new route. When the radio forgets the route, it sends nothing. The
login is still one transmission.
The posts that a room sends after a login wait on your radio until something collects
them. chat listen or Chat in the menu collects them. chat history --to ROOM reads
them by author.
forget is local and prints nothing. MeshCore has no way to leave a room. Thus the room
can continue to send new posts to your radio until the room restarts, or until it needs
your seat for a new member (it holds 20 members, and it removes the least active member).
forget exits with 5 if you did not join the room.
--json. list is an array, one object per room:
{"node":{"name":"Lakeside BBS","key":"f6a7b8c900000000000000000000000000000000000000000000000000000000","hash":"f6a7b8c9","type":"room server","self":false},"joined":true,"access":"member","heard_at":null,"last_post_at":null,"unread":0}
access is null for a room that you did not join. heard_at is null for a room that
MeshTerm never heard. join answers with the room, the access, and how the login went
out. route is "flood", "direct", or null if the radio never confirmed that it
sent the login:
{"room":{"name":"Lakeside BBS","key":"f6a7b8c900000000000000000000000000000000000000000000000000000000","hash":"f6a7b8c9","type":"room server","self":false},"access":"member","route":"flood"}
forget answers with the room and forgotten (true, or false when you did not join
the room).
meshterm channels
| Subcommand | What it does |
|---|---|
list |
The configured channel slots. |
add INDEX NAME [--secret HEX] |
Add a channel. A # at the start of the name makes the channel public (the firmware derives the key from the name). Otherwise the channel is private, with a random key unless you give a key. |
join INDEX NAME SECRET |
Join a channel from its name and its key of 32 hex characters. |
import INDEX URL |
Import a meshcore://channel/add link. |
import --file FILE [--dry-run] |
Add the channels of a file from export to the device. |
export FILE |
Write each channel to a file, with its key, its send scope, and its mute. Another device can import the file. |
guide |
The public channels that MeshTerm heard, with the number of messages and the age of the last one. |
share INDEX |
Print the share link of a slot. |
clear INDEX --yes |
Clear a slot. This removes the channel from the device. |
scope INDEX [REGION] [--clear] |
Show or set the region into which the channel's messages are flooded. |
list prints SLOT NAME TYPE HASH SCOPE, with one record for each configured slot. It
returns 5 when the device has no configured slot. The simulator that these examples use
has none, so there is no captured listing to show. Each --mock run opens a new radio,
and a slot that one run writes is not there for the next run.
$ meshterm channels add 1 brigade
meshcore://channel/add?name=brigade&secret=2c70ca416304d520b60a28d9db35d6b3
add and share print the meshcore:// URI alone, because the caller asked for
something to pass on. join and import print nothing. You gave these two commands the
key, so it is not an answer to print it back to you. The menu draws a QR code beside the
link. If you send that output to a file, the file has a block of block characters around
the one thing that is the answer.
clear needs --yes, because MeshTerm loses the key of a private channel with the slot,
unless you saved the key somewhere else.
MeshTerm keeps scope, not the radio. The firmware has no scope for each channel, so
MeshTerm sets the companion's scope around each message that it sends on the channel.
Thus a scope that you set writes nothing to the device, and it follows the channel to
each slot that the channel moves to. If you give no REGION, scope prints only the
channel's region, in the same way that config get prints one value. A channel that has
no scope prints - (null in --json) and exits with 0. This is an answer: the
channel sends with the device's default. To set a scope (scope 1 harbour) or to clear
it (scope 1 --clear), the plain face prints nothing. If you read an empty slot, the
exit status is 5, as for share. If you write to an empty slot, it is a usage error
(exit 2), because there is no channel to scope. SCOPE in list is - for a channel
that sends with the device's default.
--json. list is an array with flat rows. This is the one listing whose record
is the shared shape, and does not contain it. A row is
{"slot":0,"name":"Public","type":"public","hash":"11","scope":null}. type is the word
of the plain column, and it is not the public boolean of the channel shape. If each
row had a channel key, jq '.[].channel.name' would be necessary for a listing in
which each column is already the channel.
All four writing subcommands emit the same document, and each one has the key that it wrote:
$ meshterm channels add 2 "#news" --json
{"channel":{"slot":2,"name":"#news","public":true,"hash":"03"},"secret":"ecadb1a7d803db8958bea1302ca6e8be","url":"meshcore://channel/add?name=%23news&secret=ecadb1a7d803db8958bea1302ca6e8be"}
$ meshterm channels clear 1 --yes --json
{"slot":1,"cleared":false}
$ meshterm channels share 1 --json
{"channel":null,"secret":null,"url":null}
share on an empty slot, and clear on a slot that is already empty, give exit 5 with
the nulls and "cleared":false. There is nothing to report, and it is not a failure.
Export and import. export and import --file move all the channels to another
device. export writes one TOML file, with one [[channels]] table for each channel, in
slot order:
[[channels]]
name = "#news"
[[channels]]
name = "brigade"
secret = "2c70ca416304d520b60a28d9db35d6b3"
scope = "harbour"
muted = true
A # channel has no secret, because the device derives its key from the name. A private
channel has its key in the file. Thus the file is as secret as the keys in it. MeshTerm
makes it readable only by you, where the system lets it, and export says on stderr how
many keys of private channels the file holds. A file from config backup has a
[[channels]] table of the same shape, so import --file can also read a config backup.
import --file never removes a channel. It puts the channels of the file first, in the
order of the file, and the other channels of the device after them. A channel that has no
free slot is not added. The send scope and the mute of each channel go into the stores of
this computer. A channel that has no scope or no mute in the file keeps the values that it
has here. --dry-run prints the plan and changes nothing.
$ meshterm channels import --file brigade.toml
SLOT NAME ACTION
0 #news add
1 brigade add
import --file prints the layout of the device after the import, with one record for each
channel. ACTION is add, move, or keep. A channel that has no free slot comes last,
with the action skip and no slot. export prints only the path of the file. It writes no
file and exits with 5 when the device has no channel. A file that MeshTerm cannot use is
a usage error (exit 2), and the message names the bad entry. If MeshTerm cannot read
each slot of the device, the command changes nothing and exits with 4.
$ meshterm channels import --file brigade.toml --json
{"dry_run":false,"added":2,"moved":0,"skipped":0,"channels":[{"slot":0,"name":"#news","action":"add"},{"slot":1,"name":"brigade","action":"add"}]}
$ meshterm channels export channels.toml --json
{"path":null,"channels":0,"private":0}
The guide. guide lists each public channel that MeshTerm heard, the most recently
heard first. MeshTerm stores each channel packet that it hears, also on a channel that is
not on your device. A packet gives its channel only as a hash, so MeshTerm tries names:
the channels of your device, Public, each #name in a stored message, and each region
that it knows. The key of a public channel comes from its name, and MeshTerm checks the MAC
of each packet with that key, as the firmware does. A guessed name must match two messages
or more, because one match of three bytes is not proof. A private channel, or a public
channel whose name nobody wrote, has no name in the guide. stderr gives the number of
these messages.
$ meshterm channels guide
NAME HASH MSGS LAST DEVICE
#news 03 2 now yes
#harbour 26 3 now no
MSGS counts different messages: the copies that repeaters relayed count one time.
DEVICE says whether the device has the channel. To add a channel from the guide, give
its name to add (channels add 2 "#harbour"). When MeshTerm heard no public channel,
guide exits with 5. Under --json, each record is
{"name":"#news","hash":"03","messages":2,"last_heard":"2026-10-07T05:23:09Z","on_device":true}.
meshterm courier
A store-and-forward outbox. Queue a message for a contact that you cannot reach now. The message goes out when MeshTerm next hears the contact, or at a time that you name.
| Subcommand | What it does |
|---|---|
queue CONTACT TEXT… [--at HH:MM] |
Queue a message. --at holds it until the next time that the local clock shows that time. |
list |
The outbox: the waiting entries, then the finished entries. |
send ID |
Force one delivery attempt now. It skips the "wait until heard" check. |
cancel ID |
Remove a waiting entry. |
clear |
Remove each finished entry (delivered or given up). |
$ meshterm courier queue Alice "call me when you land" --at 18:30
id 1
$ meshterm courier list
ID STATE NODE SCHEDULED FINISHED TEXT
1 waiting Alice 2026-09-08T18:30:00-04:00 - call me when you land
queue prints the entry's id. This is the one fact that you must keep, because
send and cancel take it. An entry with no SCHEDULED time goes when MeshTerm hears
the contact.
SCHEDULED and FINISHED stay absolute, also without --absolute. An appointment is
one of the places where the instant is the fact. "18:30" is what you asked for and what
will happen. "In 14h" says the same thing again, and it is wrong after a short time.
The courier of a running interactive session does the sending. A queued message stays
in the outbox until such a session runs. courier send ID is the way to force an attempt
from a script.
--json.
$ meshterm courier list --json
[{"id":1,"state":"waiting","node":{"name":"Alice","key":null,"hash":"d4e5f6a70000","type":null,"self":false},"scheduled_at":"2026-09-08T22:30:00Z","finished_at":null,"text":"call me when you land"}]
$ meshterm courier cancel 1 --json
{"id":1,"cancelled":true}
$ meshterm courier clear --json
{"cleared":0}
state is "waiting" | "delivered" | "gave up". queue answers with id, node,
scheduled_at, and text. send ID answers {"id":…,"outcome":…}. outcome is one of
delivered, no ack, gave up, unknown contact, busy, or gone. MeshTerm keeps
each value exactly as it is, with the spaces, so the two faces use the same word.
list returns 5 for an empty outbox. cancel and clear return 5 when there was
nothing to act on. These cases are usage errors (2): a contact that the device does
not know, a contact with no key to address, and an --at that is not a clock time. The
argument is wrong, nothing was queued, and nothing went out over the air.
Tracing
meshterm trace
Walk the path to a target one time and report what came back.
| Option | What it does |
|---|---|
-t, --target NAME |
Required. The name or the key prefix of the target node. |
--path SPEC |
Force a path. Give comma-separated contact names or hex hashes, in any mix (3d,f2,3d). If you omit it, the device routes. |
--target is required, so meshterm trace --path … alone is a usage error. The form
without a target is trace-path.
$ meshterm trace --target Alice --path "a1,d4"
target Alice
path a1,d4
success yes
hops 3
min_snr_db +2.0
rtt_ms 123
route MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)
HOP FROM TO SNR_DB
0 00 a1 +6.0
1 a1 d4 +2.0
2 d4 00 +6.0
The output has two blocks. The first block shows what the walk did. It has the drawn
route among the facts, and the typed path beside it. path reads auto when the
device routed. Each other value there is the comma-separated hex that you can paste into
--path as it is. The second block has one record for each hop. It names the two ends of
the hop by hash and does not repeat the names. The names are in the route line above,
and the hash joins the two blocks.
A trace that never came home is not a failure of the command. The radio transmitted,
and the walk ran. The command reports success no and returns 5.
meshterm trace-path
The other half of tracing. It has no target. You compose a path by hand, out and back, in any way that you choose. The path only has to end where this node can hear it.
| Option | What it does |
|---|---|
--path SPEC |
Required. The whole walk, comma-separated. |
$ meshterm trace-path --path "a1,d4,a1"
target -
path a1,d4,a1
success yes
hops 4
min_snr_db +2.0
rtt_ms 458
route MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4) → Yagi-Repeater (a1) → MockCompanion (00)
HOP FROM TO SNR_DB
0 00 a1 +6.0
1 a1 d4 +2.0
2 d4 a1 +2.4
3 a1 00 +6.0
target is - because a path walk has none. MeshTerm records these walks under a
placeholder target, so they stay out of the history of the target picker.
--json is one object for both commands. The facts are at the top level, and the
table of hops is under edges.
| Field | Type | Meaning |
|---|---|---|
target |
string | null | The label that the trace was addressed to. null for a path walk. |
path |
string | null | The forced spec as hex, or null when the device routed (the auto of the plain face). |
success |
boolean | Whether a reply came home. |
hops |
integer | null | |
min_snr_db |
number | null | The SNR of the bottleneck hop. |
rtt_ms |
number | null | |
tx_dbm |
integer | null | The TX power when the trace ran, if known. No plain column, because the facts block has no room. |
hash_bytes |
integer | null | The per-hop hash width that the walk used to address nodes. Present when the path was forced. |
route |
array | null | The route shape. null when nothing came home. |
edges |
array | The edge shape, one for each hop, in the order of the walk. [] when nothing came home. |
$ meshterm trace-path --path "a1,d4" --json | jq -r '.edges | min_by(.snr_db) | "\(.from.hash) -> \(.to.hash) \(.snr_db) dB"'
a1 -> d4 2.0 dB
meshterm tx-optimize
Sweep the transmit power of a remote node against a target. Then select the lowest level that still gets through reliably.
| Option | What it does |
|---|---|
--path SPEC |
Required. A forced path that ends at the target (Repeater,Target, or 3d,f2). The hop before the target is the node that MeshTerm tunes. |
-n, --samples N |
The number of traces for each TX level. The default is 3. |
--step N |
The step of the coarse sweep. The default is 3. |
--min N / --max N |
Set the limits of the sweep. If you omit them, MeshTerm uses the tx_opt_min and tx_opt_max preferences. |
--password TEXT |
The admin password of the tuned node. If you omit it, MeshTerm uses the remembered password, or else asks for it. |
--apply / --no-apply |
Set the winner on the node. It is on by default. |
$ meshterm tx-optimize --path "Yagi-Repeater,Alice" --samples 2 --password admin
tuning_node Yagi-Repeater
target Alice
path a1b2c3d4,d4e5f6a7
optimal_tx_dbm 19
target_snr_db +8.7
reliability 1.00
previous_tx_dbm 20
applied yes
TX_DBM TARGET_SNR_DB SUCCESSES SAMPLES
18 +6.6 2 2
19 +8.7 4 4
20 +9.1 2 2
21 +7.6 2 2
24 +4.0 2 2
27 -9.9 2 2
28 -15.1 1 2
The winner is first, because it is what you asked for. The records for each level follow,
so that you can check the choice against the measurements that it came from. This command
transmits many times, with the wait that the trace_cooldown_s preference sets. It is the
one command that is not a single transmission.
--json puts the levels under levels. It also changes the two bare names to node
objects. This is most important here, because nothing else on the line shows which node
is which.
{"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","self":false},"path":"a1b2c3d4,d4e5f6a7","optimal_tx_dbm":19,"target_snr_db":8.7,"reliability":1.0,"previous_tx_dbm":20,"applied":true,"levels":[{"tx_dbm":18,"target_snr_db":6.6,"successes":2,"samples":2},{"tx_dbm":19,"target_snr_db":8.7,"successes":4,"samples":4},{"tx_dbm":20,"target_snr_db":9.1,"successes":2,"samples":2},{"tx_dbm":21,"target_snr_db":7.6,"successes":2,"samples":2},{"tx_dbm":24,"target_snr_db":4.0,"successes":2,"samples":2},{"tx_dbm":27,"target_snr_db":-9.9,"successes":2,"samples":2},{"tx_dbm":28,"target_snr_db":-15.1,"successes":1,"samples":2}]}
reliability is a fraction in [0, 1], not a formatted "1.00". levels is in
ascending order of tx_dbm, as the plain table is. The star of the menu on the winning
row has no column here, because optimal_tx_dbm names the winner.
If no password is available and MeshTerm cannot ask for one, this is a usage error
(2), and nothing was transmitted. If the node refuses the password, this is a device
error (4), and MeshTerm clears the saved password. The command returns 5 when no
trace reached the target at any level. In this case MeshTerm measured nothing, and the
node stays at its original power.
Other nodes
meshterm repeater-admin
Send one command to a remote repeater or room server over the mesh, and print its reply.
meshterm repeater-admin NODE COMMAND... [--password TEXT]
| Argument / option | What it does |
|---|---|
NODE |
The name of the remote contact. |
COMMAND... |
The text command to send, spelled as the node's own CLI spells it. |
--password TEXT |
The admin password. If you omit it, MeshTerm uses the password that it remembers from an interactive login. |
$ meshterm repeater-admin Yagi-Repeater get name --password admin
> Yagi-Repeater
The command prints the reply as the node sent it: the node's own text, the whole answer,
with the echoed prompt. A node that does not answer is not a failure, because the command
can have arrived. But there is nothing to report, so the command returns 5.
MeshTerm logs in automatically with the remembered password. To store a password, run
the interactive flow one time, or give --password. If the node refuses the login,
MeshTerm clears the saved password, and the command fails with 4. The node answered,
and the answer was no. A password that is absent, and a node name that no contact
matches, are usage errors (2). In both cases nothing was transmitted. This is the line
between these errors and the errors 3 and 4.
--json:
{"node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"command":"get name","reply":"> Yagi-Repeater"}
reply is the node's own text, whole and exactly as received. It is raw and not escaped,
and it has more than one line when the node sent more than one line. MeshTerm does not
parse it. MeshTerm does not know the grammar of the remote node's CLI, and a document that
pretended to know it would invent a structure. reply is null when the node gives no
answer (exit 5).
meshterm regions
Ask a repeater for which regions it relays floods (firmware 1.12 or newer).
meshterm regions NODE
| Argument / option | What it does |
|---|---|
NODE |
The contact name of the repeater. |
$ meshterm regions Yagi-Repeater
node Yagi-Repeater
unscoped yes
regions lakeside, lakeside-north, harbour
The command sends one anonymous request, with no login, and it is one transmission. A
repeater answers the request only when the request arrives direct, from a neighbour or
over a route that the companion learned. The repeater also limits the rate of this
question, so the command does not try again. unscoped says if the repeater also relays
plain floods. regions lists the regions for which it relays scoped floods. MeshTerm
remembers the answer, so the node page and the packet views know it afterwards.
A repeater that answered and named no region relays no floods, and the command returns
5. A repeater that never answered gives a device error (4). These cases are usage
errors (2) and MeshTerm sent nothing: a name that no contact matches, and a contact that
is known to be something other than a repeater.
--json:
{"node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"unscoped":true,"regions":["lakeside","lakeside-north","harbour"]}
regions never holds the wildcard *. That is unscoped, because it names no region.
MeshTerm itself
meshterm preferences
How MeshTerm behaves. This is different from how the radio is configured. The command
reads nothing from the companion, transmits nothing, and works with no device attached.
If you give no subcommand, it runs show.
| Subcommand | What it does |
|---|---|
show |
Each preference, with its value, its built-in default, and what it does. |
get KEY |
The value of one preference, bare. |
set KEY VALUE |
Change one preference. |
reset --yes |
Return each preference to its default. |
$ meshterm preferences show
PREFERENCE VALUE DEFAULT DESCRIPTION
set_clock_on_connect off off Set the device clock from this computer when it connects
trace_cooldown_s 5 5 Wait this long before transmitting again
flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert
weekly_flood_advert off off Flood an advert after a week without one
advert_quiet_s 30 30 Silence to wait for before the weekly advert
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
room_relogin_minutes 30 30 Opening a room this quiet logs in again
tx_opt_min 18 18 Weakest power it will try
tx_opt_max 28 28 Strongest power it will try
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
watch_silence_hours 12 12 Warn when a watched node goes quiet this long
watch_alerts_kept 200 200 How many past alerts to keep
map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all
basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch)
history_days 365 365 Older ones are deleted; 0 keeps everything
chat_history_limit 200 200 Old messages to load when you open a chat
courier_history_kept 100 100 Finished outbox messages to keep
cli_time_format relative relative Whether the command line prints ages or timestamps
fast_render on on Faster screen updates; turn off if it looks wrong (next launch)
full_width auto auto Use the extra column some terminals hide
color_depth auto auto How many colours to send this terminal (next launch)
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
name_overlap_days 7 7 Grey a name two nodes used within this many days
section_spacing_min_rows 24 24 A blank line between sections and between chat authors
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
DESCRIPTION says what each preference does, because the name of a key does not give
its full meaning. Values print in the form that preferences set accepts as input. A
number has no unit suffix, and a boolean is on or off. If VALUE is different from
DEFAULT, this install has an override.
The overrides are in preferences.toml in MeshTerm's state
directory. This file lists only what you changed. If you
delete a line, the default is in effect again.
--json is an array of setting objects that have default and overridden:
$ meshterm preferences get trace_cooldown_s --json
{"key":"trace_cooldown_s","value":5.0,"type":"float","label":null,"redacted":false,"default":5.0,"overridden":false}
overridden gives the answer that the plain face makes you find by a comparison of two
columns. DESCRIPTION has no JSON counterpart. It is text for a person, and a consumer
that wanted it would render a settings screen. A boolean prints on or off on the plain
face and is true or false here. This is the one type for which a round trip through
preferences set needs a mapping. For each other type, .value | tostring goes back in
as it is.
set and reset --yes answer {"changes":…,"applied":[…],"path":…}. path is the
preferences.toml that MeshTerm wrote.
meshterm about, about-author, discord, support
The four written pages, printed as plain text. about says what MeshTerm is and under
which terms it ships. about-author says who wrote it. discord is the community
invitation. support says what keeps MeshTerm going.
$ meshterm discord
Join the Discord
Questions, ideas, bug reports, and mesh talk.
• https://discord.gg/AZwe5Uvb3S
These four pages are the one place where the CLI wraps, at 72 cells. The menu draws a QR code beside each link. The scripted face prints only the link.
--json:
{"page":"discord","title":"Join Discord","version":"0.11.1","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
| Field | Type | Meaning |
|---|---|---|
page |
string | "about" | "about-author" | "discord" | "support". |
title |
string | The page's own title. |
version |
string | The version that the {version} placeholder of the page resolved to. |
text |
string | The whole page as the plain face prints it, raw, with the newlines. |
links |
array of strings | Each URL on the page, in the order of the document. |
links is the one thing on a page of prose that a program can use. It is also the one
thing for which the menu draws a QR code. text is the plain rendering of the page. It is
not the markdown changed into nested JSON. A structured markdown tree would be a second
rendering and not data, and no program would use it.
meshterm diagnostics
All the facts that a bug report starts with, in one block: which MeshTerm, on which host, in which terminal, with which radio, and with how much stored history. It answers the three or four questions that people otherwise ask one at a time in an issue thread. The person who found the bug is usually the person who can least answer them, because MeshTerm resolves half of these facts at boot and shows them on no screen.
This sample is the one exception to About the examples. It is an example report from a real radio over Bluetooth, because the report of the simulator says little. The field names and the layout are what the command prints. The values are examples.
$ meshterm diagnostics
meshterm 0.9.0
install frozen
os Windows 11
os_build 10.0.26100
arch AMD64
python 3.13.1
terminal Windows Terminal
term -
colorterm truecolor
terminal_size 120x30
over_ssh no
platform regular
icons yes
icons_source windows-terminal
font Cascadia Mono PL (windows-terminal)
powerline full (font:windows-terminal)
connected yes
transport ble
port -
device_role companion
device_model Heltec V3
firmware v1.7.1 1c3f9a2
radio_freq_mhz 906.8750
radio_bw_khz 250.00
radio_sf 10
radio_cr 5
tx_power_dbm 20
error -
config_dir C:\Users\you\.meshterm
db_size_kb 41280
log_level DEBUG
log_size_kb 96
runs_failed 3
first_heard 2026-01-14T19:42:10-05:00
last_heard 2026-09-21T11:03:00-04:00
TABLE ROWS
app_state 4
discovered_paths 1287
messages 3316
neighbour_reports 214
observations 418203
path_candidates 96
runs 912
schema_meta 1
trace_hops 233
traces 47
tx_samples 68
PREFERENCE VALUE
log_level DEBUG
history_days 730
The seven groups are the seven sections of the page: build, host, terminal, radio,
storage, stored rows, and changed preferences. The command line shows them as records
that blank lines separate, because that face is a stream for grep. The page and the
saved file show them as markdown headings, because that face is a document to read.
MeshTerm asks the radio, but it does not need the radio. A report about a companion
that does not connect is the most useful report to file. Thus if MeshTerm cannot reach the
radio, the report has connected no and the radio's own words in error, and each other
fact still arrives.
Only overridden preferences are in the list. The defaults are in the source and are the same for all users. The preferences that the reporter changed are the ones that can explain a problem.
The report describes the mesh only in totals. It gives a count of rows for each table, and the span of time that the observations cover. The counts come from the schema and not from a list that a person wrote. Thus a table that someone adds later is in the report on the day that it arrives.
Nothing in the block is private. It has no pairing PIN, no admin password, no channel
secret, no private key, no position, and no name or key of a contact. This is a property
of the feature and not a habit. The block is designed so that a person can paste it in
public without reading it. tests/test_diagnostics.py plants real secrets in each store
that holds one. It fails if one of them, or a field that is only named like one,
reaches either face.
With --json, the five blocks of facts merge into one flat object, and the two listings
keep a key of their own:
{"meshterm":"0.9.0","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"app_state","rows":4},"…"],"preferences":[{"preference":"log_level","value":"DEBUG"},{"preference":"history_days","value":"730"}]}
--out PATH writes the report to a file and does not print it. The answer is then the
path. config export-key --out does the same. A caller who asked for a file wants to know
where the file is, and does not want the contents that went into the file.
$ meshterm diagnostics --out meshterm-diagnostics.md
✓ diagnostics written — attach it to the report.
meshterm-diagnostics.md
The file is markdown, for each face that the run printed, because an issue tracker
renders markdown as it is written. The file needs no fence. Each value is a code span.
Thus the backslashes of a Windows path and the asterisks of a firmware error arrive as
they are, and not as markdown. Redirection still works, and --out does not replace it.
--out gives you the document, and redirection gives you the stream of records.
In the menu, this is the Diagnostics page under This app. It is one of the app's
written pages. It uses the same markdown renderer as the About pages, and it draws the
same document that the file holds. This helps a long block in the way that it needs most.
The ## headings are landmarks. Each one stays at the top row while its own section
scrolls under it, and ^PgUp and ^PgDn step from section to section.
s saves. The footer hint names the key on a desktop. On the PicoCalc, the free F3
slot of the lane names it. The body of the page never names it, because the body belongs
to the report. The save writes meshterm-diagnostics.md into the config directory beside
the log, and it answers in a dialog that names the path. If the write fails, the same
dialog says so, in red, and the page stays readable.
meshterm platform
A smaller command than diagnostics. It shows which UI flavour a run resolves to, and
why. It does not ask the radio anything.
$ meshterm platform
platform regular
platform_source default
flag -
env -
device_tree_model -
icons yes
icons_source no-console
icons is a separate question from the flavour. It is the usual reason why a Windows
session looks plainer than the screenshots. With --json, the keys are the same, and
icons is a boolean:
{"platform":"regular","platform_source":"default","flag":null,"env":null,"device_tree_model":null,"icons":true,"icons_source":"no-console"}
meshterm specimen
Print the specimen of the visual language: each mark, icon, colour scale, and fold on one card.
This is the one command that keeps its colour. It builds its own themed console to do
this. This is deliberate, because the colour is the output. On the PicoCalc console, it
is the acceptance card for the font and the palette. With --platform picocalc-lyra, it
shows a preview of that flavour from a desktop. A monochrome specimen would test nothing.
The command also refuses --json, as a usage error (exit 2). A colour card has no
data face, and a document of it would be false or without use.
meshterm emulate
Open a window that shows MeshTerm as the screen of a handheld draws it: cardputer-zero
or picocalc-lyra.
$ meshterm emulate --fetch-fonts
$ meshterm emulate picocalc-lyra --mock --scale 2
It is not a strict emulator. MeshTerm runs on this machine as itself. The window copies
the limits of the handheld's display: the size of the display in pixels, its grid, its
font, and the colours that it can show. It also copies the keys that drive its F-key
lane. Each global option (--mock, --port, --ble, --db…) goes to the MeshTerm in
the window. The emulator describes the keys and the
font.
| Option | |
|---|---|
DEVICE |
cardputer-zero or picocalc-lyra |
--scale N |
the zoom of the window, in whole pixels (default 3) |
--fetch-fonts |
download the Terminus font in which MeshTerm draws the screens, then stop. You need this one time |
--archive FILE |
install the font from a release archive that you downloaded in some other way |
The command needs an install with pip or pipx, because the one-file downloads do not
include Tk. In the same way as specimen, it refuses --json (exit 2), because a
window is not a document. If the window cannot open, because there is no font yet or no
Tk, the command exits with 1 and prints the reason on stderr.
Recipes
Use --json and jq for structured data. The plain face is for you to look at.
The key of each repeater.
meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
The name and the last-heard time, separated by a tab, for a spreadsheet or column -t:
meshterm contacts --json | jq -r '.[] | [.node.name, .heard_at] | @tsv'
Watch a node and send an alert if it goes quiet. When trace returns 5, the walk
ran and nothing came home.
#!/usr/bin/env bash
target=${1:?usage: watch NODE}
meshterm trace --target "$target" > /dev/null
status=$? # capture it first: `if ! cmd` would have reset $? to 0
if [ "$status" -eq 5 ]; then
echo "$target did not answer" | mail -s "mesh alert" me@example.com
fi
The bottleneck hop of a walk. This is the number that most discussions about a route are about:
meshterm trace-path --path "a1,d4" --json \
| jq -r '.edges | min_by(.snr_db) | "\(.from.hash) -> \(.to.hash) \(.snr_db) dB"'
A route as one line, with our node, as the plain face draws it:
meshterm trace-path --path "a1,d4,a1" --json \
| jq -r '.route | map(.name // .hash) | join(" -> ")'
Capture a time window of traffic and make the totals yourself. The summary block of the plain face has no JSON counterpart. This is deliberate. The records that it comes from are all there.
meshterm monitor --seconds 300 --json > capture.ndjson
jq -s -c 'group_by(.node.hash)[]
| {hash: .[0].node.hash,
name: (map(.node.name) | map(select(. != null)) | first),
packets: length,
best_snr_db: (map(.snr_db) | max)}' capture.ndjson
One value into a variable. get prints the bare value, because you named the key:
sf=$(meshterm config get radio_sf)
[ "$sf" -lt 9 ] && meshterm config set radio_sf 9
A nightly archive of the config. Keep the path that the command wrote:
out=$(meshterm config backup "$HOME/mesh/$(date +%F).toml") && echo "archived $out"
Queue a message for a contact who is offline. Then send it again later:
id=$(meshterm courier queue Alice "call me" --json | jq -r .id)
# ... later ...
meshterm courier send "$id"
Sample a trace with a wait between runs. By design, a trace transmits exactly one time in each run. Set the wait between the runs yourself:
for i in $(seq 5); do
meshterm trace --target Alice --path "3d,f2,3d"
sleep "$(meshterm preferences get trace_cooldown_s)"
done
Let MeshTerm advertise instead of cron. A companion never advertises by itself, so MeshTerm can do it for you. It advertises one time each week, and only when the air is quiet:
meshterm preferences set weekly_flood_advert on # starts the week; sends nothing now
meshterm preferences set advert_quiet_s 60 # wait for a minute of silence first
A running MeshTerm session then floods one advert from a device when the device has gone
a full week without one. A flood advert that you send by hand starts that device's week
again. MeshTerm sends the advert only after it has heard the mesh, and then heard nothing
for the quiet time plus a random 0 to 5 s.
Do not put config advert --flood in a cron slot instead. Each repeater in range
rebroadcasts a flood advert. A repeater that decides that you send bursts can blacklist
you.
Quiet output for cron. --quiet removes all console logging. Only the command's own
output and each error line remain. The PATH of cron is almost empty, so give the
absolute path:
0 * * * * /usr/local/bin/meshterm --quiet --profile yagi contacts --json > /var/log/mesh/contacts.json
What has no command
Some features have no scripted face. This is the correct answer, and it is not a worse version of the feature. Each of these features is a live picture. Its meaning is where things are on a grid, how they move, and what colour they are. None of this stays when MeshTerm changes the picture to records.
| Feature | Why there is no command, and what to use instead |
|---|---|
| Map | A drawing of braille cells in which colour shows which node is which. If MeshTerm removed the colour, as in the rest of the CLI, the map would be unreadable. If it kept the colour, you could not pipe the map to a useful place. meshterm contacts lists the same nodes, with the coordinates. |
| Dashboard | A live overview that MeshTerm paints again each second. meshterm monitor captures the same traffic as records. |
| Live feed | Each packet when it arrives, with the newest first, and a viewer behind each row. meshterm monitor is the scripted tail. |
| Watchtower | A background sentinel for starred nodes. It exists to interrupt a session. Build the same alarm from the exit status of meshterm trace (refer to Recipes). |
| Time machine | Braille charts over a time window that you can switch. The history under them is in the database that --db points at. |
| Mesh walk | An evidence graph that you walk one node at a time. meshterm records and meshterm trace cover the walks that it is built from. |
Each other part of the menu has a command, in the list above, and each of these commands
accepts --json.