The chat title reads "#devtest · onqc" rather than "#devtest · scope onqc". `channels scope N` on a channel with no scope prints - (null in --json) and exits 0: sending under the device default is an answer. Only an empty slot still exits 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
89 KiB
The MeshTerm command line
MeshTerm has two front ends over one codebase. The menu — what you get by running
meshterm with no arguments — is a full-screen session meant to be read by a person. The
command line is the other one, and this is its manual: what it prints, what it
returns, and every command it answers to.
It prints two ways, and the difference is who is reading.
- The plain face is for somebody at a prompt who typed a command to find something
out. It is a Unix utility's output — one record per line, no colour, no frames — and it
is allowed to be comfortable: names are bare, times read
5mrather than2026-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: typed values, absent spellednull, timestamps in UTC, keys that do not move. It works on every command.
The promise to be read by a program belongs to --json. The plain face makes no such
promise. If you are splitting output on whitespace to feed a script, stop: --json exists
so you do not have to, and the plain face may 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]
With no command, MeshTerm launches the interactive menu instead.
| Option | What it does |
|---|---|
--version |
Print meshterm <version> and exit 0, before anything else is opened. |
-p, --profile NAME |
Use a named device profile from config.toml. |
--port PORT |
Serial port to connect to (COM5, /dev/ttyACM0), overriding the profile. |
--ble ADDRESS |
Bluetooth address of a companion; selects the BLE transport. |
--ble-pin PIN |
Pairing PIN, if the Bluetooth companion asks for one. |
--tcp HOST[:PORT] |
Network address of a TCP companion; selects the TCP transport. Default port 5000. |
--spi |
The LoRa radio on this machine's own SPI bus; selects the SPI transport. MeshTerm runs the node for the length of the command. With one radio attached, that's the one, profile or not; with none attached, the one SPI profile, else the uConsole AIO's wiring. With two, it refuses (exit 3) and lists them — pick one with -p. See 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 rather than your real history. |
--db PATH |
Use this SQLite database instead of ~/.meshterm/meshterm.db (or meshterm-mock.db, under --mock). It moves the database and nothing else — see Where MeshTerm keeps its state. |
--json |
Print the answer as JSON instead of aligned text. |
--absolute |
Print times as ISO-8601 instants rather than relative ages, for this run. |
-q, --quiet |
Suppress console logging entirely (the log file still records). |
--platform NAME |
Force the UI flavour (regular|picocalc) instead of detecting it. |
A global option may be typed anywhere — before the command or after it. meshterm --json contacts and meshterm contacts --json are the same run, because -o json goes
after the verb in every tool that has one and --json is the first thing anyone reaches
for. A bare -- stops the lifting, the standard way to say the rest is data, and
--help is never lifted: meshterm contacts --help stays the contacts help.
Commands that need a radio open one, do their work, and close it. Commands that only read
stored history (records) or MeshTerm's own state (preferences, platform, specimen,
the written pages) need no device at all and work with nothing attached.
One transmission per invocation. A trace transmits exactly once — repeaters penalise, and can blacklist, nodes that burst traffic. To sample more, run the command again with your own pacing between runs.
tx-optimizeis the one deliberate exception, and it paces itself.
Where MeshTerm keeps its state
Everything MeshTerm remembers lives in one directory. On Linux and macOS that is
~/.meshterm; on Windows it is %USERPROFILE%\.meshterm. $MESHTERM_HOME overrides
it, and it is the only thing that moves the whole of it:
MESHTERM_HOME=/tmp/mesh-scratch meshterm contacts
The directory is read fresh on every run rather than cached, so two radios can be run out of two directories from the same shell.
| File | What it holds |
|---|---|
meshterm.db |
The history: every packet overheard, every trace walked, every message. The one file --db moves. |
meshterm-mock.db |
The same, for the simulator: where a --mock run records when it doesn't name a database itself. Absent until you run one. |
config.toml |
Machine setup — device profiles, where the database lives. Yours to write; MeshTerm only reads it. |
preferences.toml |
Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
courier.json |
The outbox — queued messages, waiting and finished. |
admin.json |
Remembered repeater-admin passwords. |
devices.json |
The remembered default companion, and every device proven to speak the protocol. |
contacts.json, channels.json, settings.json |
Per-device caches of what the radio last told us, so a screen opens without a round-trip. |
adverts.json |
The weekly flood advert's clock: when it was switched on, and when each device's week began. |
mutes.json, watchtower.json, remote.json |
Muted channels; watched nodes and their alerts; per-node remote-admin cache and CLI history. |
regions.json |
The regions known by name — and which repeaters carry each — plus every channel's send scope and each repeater's last answer to "which regions do you carry?". What a scoped packet's region is named against. |
radio/<spidev>/ |
An SPI radio's node, one folder per device node (radio/spidev1.0/): its identity key, settings, channels, and contacts, and its log, node.log. What firmware would keep in flash. |
tilecache/ |
Downloaded basemap tiles. |
meshterm.log |
The log file. |
.lock |
The single-instance lock. |
--db alone does not isolate a run. It moves the database, which is most of the
weight but none of the identity: the contact and channel caches, the outbox, the stored
admin passwords and the remembered device all live beside it in the config directory and
would still be the ones you use every day. To run against a scratch state — a test, a
demo, a second radio — set MESHTERM_HOME, and set --db inside it if you want the
database somewhere else again. The same holds for the database --mock picks for itself:
it keeps the simulator's invented mesh out of your history, not out of the caches.
About the examples
Every sample below was captured from a real run against the built-in simulator
(--mock), in a throwaway MESHTERM_HOME, except the diagnostics sample, which says so
where it appears. None of it is typed out by hand. The simulator
answers as a companion would — it has four contacts (Alice, Yagi-Repeater,
Local-Repeater, Observer-Bot), it adverts, it replies to traces — so the shapes are
real even where the numbers are invented.
Three things the simulator does that a radio does not, all visible in the samples:
- Each invocation opens a fresh radio. A setting written by one command, or a channel
slot filled by one, is not there for the next — so
config showalways reports the defaults andchannels listalways answers5. On real hardware the radio remembers. PKTSincontactsstays-. The overheard tally is keyed by the node id passive monitoring stores (four bytes here) and the contact row is addressed at the device's path-hash width (one byte), so the two do not meet. Against real hardware at a matching width they do.- A device-routed
traceinvents its hop names.trace --target NAMEwith no--pathcomes back through the simulator's router carrying placeholder hops; a forced path (--path "a1,d4") resolves properly. The forced form is what the samples use, and what to build a golden file from.
--mockrecords into its own history, not yours — but it still writes the caches. The simulator is a fake radio, not a fake MeshTerm: what it adverts is written down like anything else. The database it writes ismeshterm-mock.db, so its invented nodes stay out of your mesh walk, dashboard and map; naming a database with--dboverrides that choice. The contact and channel caches, the outbox and the remembered devices are still the ones you use every day, so a demo that should touch nothing at all gets a home of its own:MESHTERM_HOME=... meshterm --mock ….
The plain face
Still a Unix utility's output: one record per line, nothing folds, stdout carries the
answer and nothing else. What has gone is every rule that existed only to make splitting
safe, because splitting is --json's job now.
No colour. Not a hue, not a bold, not a dim — stdout carries no escape sequence at all, whether or not it is a terminal. A colour that survives into a file is noise in it.
No frames. No borders, no boxes, no rules, no titles, no legends. The command you typed is the title.
Alignment is the delimiter, and names are bare. A listing is an uppercase header line
and space-aligned records, the shape ps and df print. Numeric columns right-align.
$ 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 quoted. Quoting would give a script a delimiter and give every person a line full of punctuation. Names are escaped: a node broadcasts its own name and a stranger fills in a message body, so neither may hold a raw control character or end the record it sits in.
A time is an age, because "recently?" is the question a listing is opened to answer:
now, 5m, 3h, never. An absolute instant survives where the instant is the fact —
the device clock, an appointment set with --at, a live capture's own TIME column.
--absolute turns every age back into an instant, 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 is an override of a preference, not a switch beside one: cli_time_format is
relative or absolute, and meshterm preferences set cli_time_format absolute makes it
the default for every run. --absolute sets it for this run and never saves it. (How
MeshTerm behaves is a preference by this project's own rule; a flag that bypassed the
registry would be a behaviour nobody could find.)
A route is drawn and a path is typed. They are different things and they now look different. Two lines out of a trace's facts block:
path a1,d4
route MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)
A path is a spec — the hop list you compose and force, and the one line on either
face that round-trips, so it stays comma-separated hex and nothing creeps into it. Paste
it straight back into --path. A route is the concrete sequence a walk actually took;
it is read, not typed, so it reads like what it is. A hop is Name (hash), or whichever
half is known, never an empty ().
Our own node is a hop like any other, named and hashed — never the menu's star. The star says "you already know who this is", which is true of the person watching and false of whoever opens the file afterwards. The hash is there because the CLI has no colour: on screen a route's hops are told apart by their key-derived hues, and in plain text the hash is what carries that identity, and what joins a route line to the per-hop table under it.
Key/value blocks are a key, the gutter, and the rest of the line as its value — the
shape sysctl -a prints. No header; the value is whatever 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 whose raw number is the fact but whose meaning is a duration carries a gloss
in parentheses. 93784 is what the key promises and what a caller wants; (1d 2h) is
what a person actually asked. Neither has to be parsed 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 — unknown, or not applicable. You learn it once; there
is no em dash, no n/a, no empty cell. never is different: it is a value, the fact
that a node has not been heard yet. And a node type stays the word repeater — a
monochrome glyph would need a legend, and the CLI has no legends.
A live stream pins its lanes. monitor and chat listen cannot measure a column they
have not seen yet, so their headings are fixed in advance. A value wider than its lane
overruns and pushes the row right; nothing is elided, and the one unbounded field goes
last so it can push nothing.
No wrapping, with one exception. A record is a line; wrapping would put half a
record's fields under the wrong headings, so nothing folds and a long line runs off the
right. The exception is the four written pages (about, about-author, discord,
support), which wrap at 72 cells. The no-wrap rule exists to protect a record's
fields; a paragraph has no fields, and unwrapped it is a 600-cell line no terminal can
read.
stdout is the answer; everything else is stderr — 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 a command's closing message ("0
records stored") are reassurance a person needs and a script does not. Sending them to
stderr means meshterm contacts > contacts.txt puts contacts in the file and nothing
else, while the person at the prompt still sees their tick and their count.
The JSON face
--json prints the answer itself, and the report a caller needs is still $?.
No envelope. An array for a listing, an object for a set of facts. So this reads like what it looks like:
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
A command whose plain face prints two blocks (trace, tx-optimize) is one object, each
listing under its own key (edges, levels) and the facts merged at the top level.
One compact line, newline-terminated. UTF-8 with no ASCII escaping, so a name in
Cyrillic stays a name. Keys come in the order the report declares them, never sorted — a
document reads down the page in the order its plain sibling does. jq . re-indents at no
cost; nothing can un-stream a document pretty-printed across 400 lines.
A stream is one document per record, all the same shape. monitor and chat listen
emit newline-delimited JSON as it arrives, flushed. There is no begin or end line and
no discriminator field: every reader would pay for one and only a stream would use it. A
capture survives truncation — you lose the last line, not the file — and a run ended by
Ctrl-C, which is how --seconds 0 is designed to end, still leaves everything it heard.
Absent is null, never an omitted key. Every field a command declares appears in
every document, carrying null when the value is absent, unknown, or does not apply.
null is the counterpart of the plain face's - and, like it, is learned once. An empty
collection is [] or {}. An empty string is "" and is a value, distinct from
null. A whole object is null where its parts are meaningless apart: "position": null, not {"lat": null, "lon": null}. And unknown becomes null — a consumer already
has one spelling for "nothing here".
Values are typed. 9, not "9". false, not "false". yes/no in plain
(acked, applied, success, active) is true/false here. A message body is raw
and unescaped, newlines and all, because a JSON string is not a line.
Timestamps are UTC, RFC 3339, Z, to the second — "2026-09-08T08:30:50Z", twenty
characters, so string comparison is time comparison. A field holding one is named
<something>_at: heard_at, observed_at, created_at, scheduled_at. --absolute
does not touch this. A local offset is a fact about the machine that ran the command,
not about the event, and two hosts asking the same radio the same question must produce
the same document.
A numeric field carries its unit in its key — snr_db, rssi_dbm, uptime_s,
rtt_ms, battery_v — never in its value, and a number carries its own sign (5.1 and
-1.5, never the plain column's +5.1). A key is never truncated: a truncated key
cannot go back into --to or --path, so a short id is a hash and says so.
--json changes the rendering, never the report. Same exit status, same records. An
empty result prints its empty document and still exits 5:
$ meshterm channels list --json
[]
$ echo $?
5
"Prints nothing" is never a legitimate 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 to tell apart from a result, and
--json 2>/dev/null still behaves — see When it goes wrong.
A command with no data face refuses, loudly. meshterm specimen --json is a usage
error (exit 2): its output is the colour, and a document of it would be a lie or an
empty gesture. So is --json with no subcommand — there is no document an interactive
session can emit.
The shapes that repeat
Defined once here; the per-command tables below name them rather than restating them.
node — a mesh participant's identity, and nothing else. All five keys always
present.
{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1","type":"repeater","self":false}
| Field | Type | Meaning |
|---|---|---|
name |
string | null | The name the node advertises. Never quoted, never truncated. |
key |
string | null | The full public key, lowercase hex, 64 characters. null where only a hash was ever heard. |
hash |
string | null | The short derived id, at the width the surrounding surface addressed the node by. Never key cut short. |
type |
string | null | "companion" | "repeater" | "room server" | "sensor" | null. |
self |
boolean | true for our own node. What the menu's star says and the plain face deliberately does not. |
Reception facts — when heard, how strong, where — belong to the row, not to the node:
they differ per surface, and a node object carrying them would mean something different in
each one. So a row embeds node under its own key rather than flattening it, and
jq '.[].node.key' reads the same on contacts, courier and monitor.
Identity is thin where it comes from history. A node MeshTerm only ever overheard has
key: nullandtype: null, andnameis whatever the resolver could find. To correlate across commands, join onhashplus the width it was addressed at, not onkey.
position — {"lat":45.5019,"lon":-73.5674}, decimal degrees as stored (unrounded;
the plain face's five decimals are a column-width concession). The whole object is null
where the node has shared no location.
channel — {"slot":0,"name":"Public","public":true,"hash":"11"}. slot is what
every channels subcommand's INDEX takes; public is the plain face's TYPE column as
a boolean. The secret is never in a channel object — it appears only where the
caller asked for it, under its own key.
setting — one device setting or preference, the same shape show and get both
use.
{"key":"radio_sf","value":8,"type":"int","label":null,"redacted":false}
type is "int" | "float" | "bool" | "str" | "enum". value is the typed
current value — an enum is its number, so .value | tostring is what set takes
back. label is the reader's word for an enum's number, else null. redacted is true
where the value is deliberately withheld, and a redacted row's value is null — a mask
string is not a value. Preference rows add default and overridden.
route and edges — a route is an array of node objects in propagation order,
our own node a hop like any other; null when nothing came home, never []. An edge is
one directed link, {"index":0,"from":{node},"to":{node},"snr_db":6.0}, with the SNR
measured arriving at to. Where the plain face names an edge's ends by hash so the reader
can join the hop table to the route line above it, JSON embeds the whole node at both
ends: the join is structural here, and each edge should be readable on its own.
Exit status
The return value is half the report, on both faces.
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
Failure with no more specific code — an unreadable file, an unknown setting or preference key (config set, preferences set), an unhandled fault. |
2 |
Usage error: an unknown flag, a missing argument, a value the parser rejected, a confirmation not given. |
3 |
No companion device could be selected: none attached, the named one could not be opened, or the choice was ambiguous. Nothing was transmitted. |
4 |
A device was reached but the operation failed — a command error, a timeout, a link lost mid-run. Retrying is reasonable. |
5 |
The command completed and there was nothing to report: an empty list, a target that never answered, a conversation with no messages. This is not an error. |
The table is printed under meshterm --help as well.
Two boundaries need stating. Nothing transmitted is not a device failure: a value
the tool refuses before it opens the radio is a usage error, so 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 are all 2. And a named connection that will not open is 3, not 4: a
--port that is not there means no device was selected, and nothing went out.
5 is the one worth building on. grep conflates "found nothing" with "worked", and a
caller that has to count output lines to tell an empty mesh from a full one is parsing
when it could be branching:
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 split the failures a retry might fix from the ones it never will:
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
A failure is raised, never returned: nothing lands on stdout, one sentence lands on
stderr, and the classification lands in $?. That holds on both faces — --json does not
wrap a failure in a document, because a consumer that can see $? does not need one and a
consumer that cannot would have to tell an error document from a result.
No radio. Nothing was transmitted; the exit is 3 whichever way the device was named.
$ meshterm --port NOSUCHPORT99 info
meshterm: could not open serial port NOSUCHPORT99: could not open port 'NOSUCHPORT99': FileNotFoundError(2, 'The system cannot find the file specified.', None, 2)
$ echo $?
3
With nothing named and more than one candidate attached, the same 3 arrives as a list
and the way out of it:
$ 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 the parser rejects is 2, and it names the choices rather than making you
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 not given is 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
Destructive operations gate behind --yes rather than a prompt, so nothing surprising
happens in a script.
A refusal is also 2 — the caller asked for something 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 is 4, and it 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 is 5 and is not a failure at all. stdout is empty on the plain
face and carries the empty document under --json; anything MeshTerm wants to say about
it — 0 records stored — goes to stderr like every other acknowledgement.
$ meshterm records
$ echo $?
5
$ meshterm records --json
[]
Under --json, none of this changes: same statuses, same stderr, and stdout carries the
document on 0 and 5 and nothing at all on 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 1 with its traceback in the log file. Everything above is
an expected failure, reported once.
Command reference
Each entry gives what the command does, its options, what the plain face prints, and what
--json returns. Where a JSON field is one of the shapes that
repeat, the table names the shape rather than restating it.
Finding a device
meshterm devices
List every attached serial device, every radio on this machine's SPI bus, and every
in-range Bluetooth companion. Opens no radio — it only enumerates what is attached or
advertising. An SPI radio is listed when its /dev/spidev* node exists, exactly as the
device screen lists it: one per SPI profile, plus the uConsole AIO's /dev/spidev1.0 when
no profile covers it. Under --mock it enumerates nothing at
all: the simulator is the one device, listed with --mock as its target, and neither the
serial ports nor the Bluetooth radio are touched — a session that promised no real
hardware keeps the promise here too.
| Option | What it does |
|---|---|
--ble / --no-ble |
Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. Moot under --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 what --port and --ble take, verbatim; an SPI radio's is its device node,
reached with --spi or its profile's -p. MESHCORE is three-valued and stays
that way: yes only once a connection has proved the device speaks the protocol, maybe
for a USB vendor ID that suggests a LoRa board or a bridge chip, no for anything else —
a vendor ID is a hint, and the column would be lying if it rounded one up.
--json is an array of targets in discovery order, carrying five facts 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 | What --port / --ble take, verbatim. |
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 BLE device. |
label |
string | The OS's name for the device, or the confirmed node name where we have one. |
hardware |
string | null | The confirmed hardware model, else the USB vendor label. "" on the --mock row. |
meshcore |
string | "yes" | "maybe" | "no". Not a boolean — rounding a hint up to true would be a lie. |
confidence |
string | "board" | "bridge" | "unknown" — what a "maybe" was derived from. |
serial_number |
string | null | |
stable_id |
string | The identity the device store keys on ("sn:D030…", "port:COM1"). |
confirmed |
boolean | Whether this device is stored as a proven companion. |
remembered |
boolean | Whether it is the remembered default. |
active |
boolean | Whether this invocation is (or would be) using it. |
Under --mock there is no scan. The list is one row for the simulator: target
"--mock", transport "mock", stable_id "mock:simulator".
Returns 5 when nothing is found.
This node
meshterm info
The connected companion's live status: which radio this is, and how it is doing.
$ 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 nothing has to be pulled back out of prose. A reading whose
number is a duration carries a gloss beside it: 93784 (1d 2h). The number is what
the key promises and what a caller wants; the parenthesis is what a person actually asked.
clock_at is the device's clock and clock_drift_s is device minus host, signed. On the
plain face clock_at is one of the instants that survives a relative-age default, because
a clock reading whose whole point is what time the device thinks it is would say nothing
as now.
A reading the firmware does not answer for is absent rather than -: a missing key
says "this device does not report it", where a dash would claim it reported nothing.
--json is one object with the same keys, minus the glosses, and with every key
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 MeshTerm does not know. This is the one place the two faces
disagree about absence on purpose: plain omits a key the firmware never answered for, JSON
writes null, because a variable key set costs every consumer a lookup guard on every
field.
info does not embed a node object — it is our own node in far more detail than the
shape carries, and name / public_key here are its whole identity. What the radio is
set to is config show's answer, not this one. Never returns 5.
meshterm config
View and change every device setting. With no subcommand it runs show.
| Subcommand | What it does |
|---|---|
show |
Print every setting as key value. |
get KEY |
Print one setting's value, bare. |
set KEY VALUE |
Change one setting. |
backup PATH |
Write every setting to a TOML file. |
restore PATH [--dry-run] |
Apply 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 (see also the channels group). |
advert [--flood] |
Broadcast an advertisement. Zero-hop unless --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. Sensitive. |
import-key KEY_HEX --yes |
Import a private key, overwriting this node's identity. |
reboot --yes |
Reboot the device. |
factory-reset --yes |
Erase all data and reset to 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 by the key get and set take, and prints a value they will
take back — an enum's number, not the reader's label; "" for an empty string; -
for something the firmware never reported. So a line read out of show can be typed
straight back in, 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}]}
(Read back with config get against real hardware and it answers 9. The simulator these
examples run against opens a fresh radio per invocation, so a setting written by one
command is not there for the next.)
The pairing PIN stays masked here, because this is the whole-device dump — the thing that
gets redirected into a file and pasted into a bug report. Name it deliberately with
meshterm config get device_pin, which is the one place it is not withheld.
Custom variables are namespaced custom.* in the output, since they have no spec and
config set will not take them (use config custom).
--json. show is an array of setting objects in the same order; get is one.
$ meshterm config get tx_power --json
{"key":"tx_power","value":20,"type":"int","label":null,"redacted":false}
Three shapes are kept apart: flood_scope is "" (an empty value), device_pin is
null with "redacted":true (withheld on purpose — a mask string is not a value), and a
setting the firmware never reported is null with "redacted":false.
Every write emits what it did, where the plain face is silent and only the stderr acknowledgement says so:
$ 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 where the snapshot held no prior value; changes is 0 and
applied is [] where the value already was what was asked for, and the exit stays 0
because the command did what it was asked. backup's path is the file written, as you
named it (the plain face prints it bare, so a caller can keep it). export-key carries a private
key, exactly as the plain face does — that is what the command was asked for, and no extra
gate is introduced here. factory-reset --yes answers {"factory_reset":true}.
The QR code the menu draws beside share has no JSON face and never will: it is a second
rendering of url.
The mesh around you
meshterm contacts
The contacts 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 "recently?" is what this listing
is opened to answer; --absolute turns it into an instant. PKTS is how many packets passive
monitoring has overheard.
HASH is the token --path and --to take, so it never has to be sliced out of KEY
by hand, where getting the width wrong would address a different node. It is exactly the
device's path-hash width. LOCATION is a fact the model always carried and no column
had room for. KEY stays full and last, so it runs off the right harmlessly and is never
elided: a truncated key is not something a caller can hand back.
This lists contacts only. Your own node is not a contact — meshterm info reports it, in
far more detail than a row could hold.
--json is an array of rows, each embedding 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 is at the device's path-hash width. |
heard_at |
timestamp | null | When the contact was last heard. null is the plain face's never. |
packets |
integer | null | Packets passive monitoring has overheard. null where none were, which is distinct from 0. |
position |
object | null | The position shape. |
Returns 5 and [] when the device knows no contacts yet.
meshterm monitor
Capture overheard packets in the foreground for a bounded window, then summarise it. Transmits nothing; it only listens. Recording to history is always on anyway — this 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
Two blocks, one straight after the other: the summary starts at its own NODE header
line, with no blank line before it. The sample shows only the first four stream records.
The stream is a header and then a record per packet as it arrives; its lanes are
pinned in advance, because a capture cannot measure a column it has not seen yet, and TIME is an absolute instant rather than an age — on a
live tail every row would otherwise read now. The summary is the aggregate the
stream cannot give: a count, a median, a best per node, most recently heard first.
--json is the stream and only the stream. One document per packet, every line the
same shape, and 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", "ack". |
snr_db |
number | null | |
rssi_dbm |
number | null | |
position |
object | null | The position shape. |
scope |
object | null | The region a flood was sent into: {"state", "region", "code"}. state is "scoped" (region names it), "unknown" (scoped, but no region known here reproduces code) or "unscoped" (a plain flood). code is the frame's transport code as lowercase hex, kept even when unresolved so two packets can be seen to share a region. null for a direct packet, or a class whose route was never reported — a repeater never region-filters those. The plain SCOPE lane prints the region, unknown, unscoped or -. |
path |
array of strings | null | The relay chain the packet arrived by, one lowercase-hex hash per hop, in propagation order. [] is a direct reception; null means the packet class carries no path at all. This is the one place path is an array — it is a received relay chain rather than a composed spec. |
This is the one place a whole block appears on one face and not the other, and it is deliberate. The per-node summary is a convenience for a person watching a capture end. Every figure in it is computable from the records that scrolled past above it, and emitting it as a second shape of line in the middle of an otherwise homogeneous stream would cost every consumer a discriminator it would never otherwise need. So the aggregate stays on the plain face, and JSON hands you what it was derived 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.
Returns 5 when the window heard nothing.
meshterm records
The record-setting walks every trace has been scored against. Reads the database; 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 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 the scores in a column are comparable; a score
prefixed >= is a lower bound, meaning some hop on that walk has no known position and
the distance is a floor rather than a measurement. Records are kept per hash width, because
the width bounds both a walk's maximum length and its collision odds — boards at different
widths are measuring different games.
$ meshterm records
$ echo $?
5
Records are discovered by the menu's walking, so a database that has only ever been driven from the command line has none — which is what the simulator these examples run against prints, honestly.
--json is an array carrying two facts the columns cannot hold.
| Field | Type | Meaning |
|---|---|---|
category |
string | The discipline id, as --category takes it. |
hash_bytes |
integer | The per-hop hash width the walk was transmitted at — the plain WIDTH, under the contract's word for the concept. |
score |
number | The bare score, unqualified. |
unit |
string | "km" | "nodes" | "dB" | "km²". |
lower_bound |
boolean | JSON only. true where the score is a floor — the plain face's >= prefix, extracted, so a consumer comparing scores need not string-match a qualifier off the front of its own data. |
recorded_at |
timestamp | |
version |
string | The MeshTerm version that discovered it. |
path |
string | null | JSON only. The transmitted spec, comma-separated hex — what a caller re-walks with. It has no column because a route line already runs off the right. null where the record stored no spec. |
route |
array | The route shape, hop-aligned with path. |
Returns 5 and [] when no records match.
Messaging
meshterm chat
meshterm chat on its own is not interactive — it prints this group's help and exits 2.
The live transcript is a menu screen; from the command line, pick a subcommand.
| Subcommand | What it does |
|---|---|
send TEXT --to NAME |
Send a direct message. |
send TEXT --channel N [--scope REGION] |
Broadcast on a channel slot, under the channel's scope — or under REGION for this one message, * for unscoped. |
history [--to NAME | --channel N] [--limit N] |
Print a conversation's stored transcript. Default limit 50. |
list |
Every channel and contact with its unread count and last message. |
listen [-s SECONDS] [--debug] |
Tail inbound messages live. -s 0 (the default) runs until Ctrl-C. |
Exactly one of --to and --channel is required on send and history.
$ meshterm chat send "on my way" --to Alice
acked yes
A direct message prints its acked state, which is the one thing the send does not
already tell you — the radio accepted it either way, 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 last and is the rest
of the line — a message body is the one field that can hold absolutely anything, so
nothing may follow it.
$ 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 -
$ 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 pins its lanes and stamps each row with an instant, the same shape monitor's
stream takes and for the same reason. --debug routes the message-pull logging to stderr,
for working out whether messages are being pulled 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" | "channel" and says which of node / channel is populated.
A channel send also carries scope, in the same shape monitor gives a received packet's:
{"state":"scoped","region":"harbour","code":null} for a region, "unscoped" for a plain
flood, and null where it isn't known (a direct message, or a channel with no scope of its
own whose device default couldn't be read). code is null on a send: 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 to give
— the radio floods it under the device default like anything else — so --scope with
--to is a usage error (exit 2), as is a name the firmware would refuse. A radio that
can't send under the scope (firmware older than 1.10, or * before 1.16 with a default
scope set) refuses before anything is transmitted, and that is a failure (exit 1),
never a quiet unscoped send. Quote the * so the shell leaves it alone:
$ 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: there is no acknowledgement to have, and that
is exactly what one absence token is for — it is also why the plain face prints nothing
there, having no way to say it.
history is an array, oldest first:
{"created_at":"2026-09-08T08:27:37Z","direction":"out","node":{"name":"Alice","key":null,"hash":"d4e5f6a7","type":null,"self":false},"channel":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. |
snr_db |
number | null | Reception SNR; null on an outbound message. |
text |
string | The body, raw and unescaped. |
acked |
boolean | null | Delivery state of an outbound direct message; null inbound and on a channel. |
list is an array of conversations, channels first, each carrying kind, channel,
node, unread, last_message_at and last_text. listen is a stream of the same row
shape as history, with received_at in place of created_at:
{"received_at":"2026-09-08T08:30:47Z","node":{"name":null,"key":null,"hash":"b2c3d4e5","type":null,"self":false},"channel":null,"snr_db":4.9,"text":"hello from Local-Repeater #1","direction":"in","acked":null}
An inbound message carries only the sender's key prefix, so its node is thinner than the
same node in contacts — name is whatever the resolver could find and key is null.
Join on hash.
history, list and listen return 5 when there is nothing to report.
meshterm channels
| Subcommand | What it does |
|---|---|
list |
The configured channel slots. |
add INDEX NAME [--secret HEX] |
Add a channel. A leading # makes it public (the firmware derives the key from the name); otherwise it is private, with a random key unless you give one. |
join INDEX NAME SECRET |
Join a channel from its name and 32-hex-character key. |
import INDEX URL |
Import a meshcore://channel/add link. |
share INDEX |
Print a slot's share link. |
clear INDEX --yes |
Clear a slot, removing the channel from the device. |
scope INDEX [REGION] [--clear] |
Show or set the region the channel's messages are flooded into. |
list prints SLOT NAME TYPE HASH SCOPE, one record per configured slot, and returns 5 when
the device has none. (The simulator these examples run against has none, so there is no
captured listing to show; each --mock invocation opens a fresh radio, and a slot written
by one run is not there for the next.)
$ meshterm channels add 1 brigade
meshcore://channel/add?name=brigade&secret=2c70ca416304d520b60a28d9db35d6b3
add and share print the meshcore:// URI alone, because the caller asked to be given
something to pass on. join and import print nothing: those two were handed the key,
and reading it back to them is not an answer. (The menu draws a QR code beside the link;
redirected into a file that is a block of block characters wrapped around the one thing
that is actually the answer.)
clear is gated behind --yes, because a private channel's key is lost with the slot
unless it is saved elsewhere.
scope is MeshTerm's to keep, not the radio's: the firmware has no per-channel scope, so
MeshTerm sets the companion's scope around each message it sends on the channel. Setting
one therefore writes nothing to the device, and it follows the channel to any slot it moves
to. With no REGION, scope prints the channel's region alone, the way config get prints
one value — and a channel with none prints - (null in --json) and exits 0: it sends
under the device default, which is an answer. Setting (scope 1 harbour) or clearing
(scope 1 --clear) prints nothing plain. Reading an empty slot exits
5, like share; writing to one is a usage error (exit 2), since there is no channel to
scope. SCOPE in list is - for a channel that sends under the device default.
--json. list is an array whose rows are flat — this is the one listing whose
record is the shared shape rather than carrying one, so a row is
{"slot":0,"name":"Public","type":"public","hash":"11","scope":null}, with type the plain column's
word rather than the channel shape's public boolean. Nesting a channel key under
every row would make jq '.[].channel.name' out of a listing whose every column is
already the channel.
All four writing subcommands emit the same document, each knowing the key 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 one already empty, are exit 5 with the nulls
and "cleared":false — nothing to report, not a failure.
meshterm courier
A store-and-forward outbox: queue a message for a contact that is not reachable right now, and it goes out the moment the contact is next heard — or at a time you name.
| Subcommand | What it does |
|---|---|
queue CONTACT TEXT… [--at HH:MM] |
Queue a message. --at holds it until the next occurrence of that local time. |
list |
The outbox: waiting entries then finished ones. |
send ID |
Force one delivery attempt now, skipping the "wait until heard" check. |
cancel ID |
Remove a waiting entry. |
clear |
Drop every finished (delivered / given-up) entry. |
$ 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, which is the one fact you have to keep: it is what
send and cancel take. An entry with no SCHEDULED time goes as soon as the contact is
heard.
SCHEDULED and FINISHED stay absolute even without --absolute. An appointment is
one of the places the instant is the fact: "18:30" is what you asked for and what will
happen, where "in 14h" is a restatement that goes stale while you read it.
The sending is done by a running interactive session's courier; a queued message sits in
the outbox until one 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":…}, where outcome is one
of delivered, no ack, gave up, unknown contact, busy, gone — kept verbatim,
spaces and all, so the two faces say the same word.
list returns 5 on an empty outbox; cancel and clear return 5 when there was
nothing to act on. A contact the device does not know, a contact with no key to address,
and an --at that is not a clock time are all usage errors (2): the argument is
wrong, nothing was queued, and nothing went out over the air.
Tracing
meshterm trace
Walk the path to a target once and report what came back.
| Option | What it does |
|---|---|
-t, --target NAME |
Required. Target node name or key prefix. |
--path SPEC |
Force a route: comma-separated contact names and/or hex hashes, mixed freely (3d,f2,3d). Omit it and the device routes. |
--target is required, so meshterm trace --path … alone is a usage error — the
target-free form 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
Two blocks. The first is what the walk did, with the drawn route among the facts and the
typed path beside it — path reads auto when the device routed, and every other value
there is the comma-separated hex you can paste straight back into --path. The second is a
record per hop, naming its two ends by hash rather than repeating the names: the route
line above is where the names are, and the hash is what joins the two blocks.
A trace that never came home is not a failure of the command: the radio transmitted and
the walk ran. It reports success no and returns 5.
meshterm trace-path
The other half of tracing: no target, just a route you compose by hand — out and back whichever way you choose. It only has to end within earshot of this node.
| 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. Walks record under a sentinel so they stay
out of the target picker's history.
--json is one object for both commands, the facts at the top level and the hop table
under edges.
| Field | Type | Meaning |
|---|---|---|
target |
string | null | The label 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 plain face's auto). |
success |
boolean | Whether a reply came home. |
hops |
integer | null | |
min_snr_db |
number | null | The bottleneck hop's SNR. |
rtt_ms |
number | null | |
tx_dbm |
integer | null | TX power in effect when the trace ran, if known. No plain column — the facts block has no room. |
hash_bytes |
integer | null | The per-hop hash width the walk addressed nodes at. Present when a path was forced. |
route |
array | null | The route shape. null when nothing came home. |
edges |
array | The edge shape, one per hop, in walk order. [] 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 a remote node's transmit power against a target and pick the lowest level that still gets through reliably.
| Option | What it does |
|---|---|
--path SPEC |
Required. Forced path ending at the target (Repeater,Target, or 3d,f2). The hop before the target is the node being tuned. |
-n, --samples N |
Traces per TX level. Default 3. |
--step N |
Coarse sweep step. Default 3. |
--min N / --max N |
Bound the sweep (else the tx_opt_min / tx_opt_max preferences). |
--password TEXT |
Admin password for the tuned node; else the remembered one, else prompted. |
--apply / --no-apply |
Set the winner on the node. 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 comes first because it is what you asked for; the per-level records follow so
the choice can be checked against the measurements it was made from. This transmits many
times (paced by the trace_cooldown_s preference) — it is the one command that is not a
single transmission.
--json puts the levels under levels and upgrades the two bare names to node
objects, which matters most here: the two nodes are told apart by nothing else on the line.
{"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 ordered by
tx_dbm ascending, as the plain table is; the menu's star on the winning row has no column
here, because optimal_tx_dbm names it.
With no password available and nowhere to ask for one, this is a usage error (2) —
nothing was transmitted. A password that is refused is a device error (4), and the saved
one is cleared. Returns 5 when no trace reached the target at any level: nothing was
measured, and the node was left 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 remote contact's name. |
COMMAND... |
The text command to send, as the node's own CLI spells it. |
--password TEXT |
Admin password; else the one remembered from an interactive login. |
$ meshterm repeater-admin Yagi-Repeater get name --password admin
> Yagi-Repeater
The reply is printed as the node sent it — its own text, and the whole answer, echoed
prompt and all. A node that does not answer is not a failure (the command may well have
landed), but there is nothing to report, so it returns 5.
Logging in happens automatically from the remembered password; run the interactive flow
once to store one, or pass --password. A refused login clears the saved password and
fails with 4 — the node answered, and answered no. An absent password, and a node
name no contact matches, are usage errors (2): nothing was transmitted in either case,
which is the line 3 and 4 are drawn on.
--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 verbatim — raw, unescaped, multi-line where the
node sent multiple lines. It is not parsed: MeshTerm does not know the remote node's
CLI grammar, and a document that pretended to would be inventing structure. reply is
null on the exit-5 no-answer path.
meshterm regions
Ask a repeater which regions it relays floods for (firmware 1.12 or newer).
meshterm regions NODE
| Argument / option | What it does |
|---|---|
NODE |
The repeater's contact name. |
$ meshterm regions Yagi-Repeater
node Yagi-Repeater
unscoped yes
regions lakeside, lakeside-north, harbour
One anonymous request — no login — and one transmission. A repeater answers it only when it
arrives direct: from a neighbour, or over a route the companion has learned. It also
rate-limits the question, so nothing here retries it. unscoped says whether it relays plain
floods too; regions lists the regions it relays scoped floods for. The answer is
remembered, so the node page and the packet views know it afterwards.
A repeater that answered and named nothing at all relays no floods, and returns 5. One that
never answered is a device error (4). A name no contact matches, and a contact known to be
something other than a repeater, are usage errors (2): nothing was sent.
--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, since it names no region.
MeshTerm itself
meshterm preferences
How MeshTerm behaves, as opposed to how the radio is configured. Reads nothing from the
companion, transmits nothing, and works with no device attached. With no subcommand it
runs show.
| Subcommand | What it does |
|---|---|
show |
Every preference, its value, its built-in default, and what it does. |
get KEY |
One preference's value, bare. |
set KEY VALUE |
Change one preference. |
reset --yes |
Return every 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
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
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
DESCRIPTION says what each preference does, because a key's name is not its
meaning. Values print in the form preferences set accepts back — no unit suffix on a
number, on/off for a boolean. Where VALUE differs from DEFAULT, this install has an
override.
Overrides live in preferences.toml in MeshTerm's state
directory, which lists only what you have changed; delete
a line and the default takes over again.
--json is an array of setting objects carrying 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 is what the plain face makes the reader derive by comparing two columns.
DESCRIPTION has no JSON counterpart — it is prose for a person, and a consumer that
wanted it would be rendering a settings screen. A boolean prints on/off plain and is
true/false here, which is the one type whose round-trip through preferences set needs
a mapping; every other .value | tostring goes straight back in.
set and reset --yes answer {"changes":…,"applied":[…],"path":…}, path being the
preferences.toml that was written.
meshterm about, about-author, discord, support
The four written pages, printed as plain text. about is what MeshTerm is and the terms it
ships under; about-author is who wrote it; discord is the community invite; support
is what keeps it going.
$ meshterm discord
Join the Discord
Questions, ideas, bug reports, and mesh talk.
• https://discord.gg/AZwe5Uvb3S
These four are the one place the CLI wraps, at 72 cells. The menu draws a QR code beside each link; the scripted face prints the link alone.
--json:
{"page":"discord","title":"Join Discord","version":"0.9.0","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 the page's {version} placeholder resolved to. |
text |
string | The whole page as the plain face prints it, newlines and all, raw. |
links |
array of strings | Every URL the page carries, in document order. |
links is the one machine-actionable thing on a prose page, and the one thing the menu
bothers to draw a QR of. text is the page's plain rendering, not a fold of the markdown
into nested JSON — a structured markdown tree would be a second rendering rather than data,
and nothing would consume it.
meshterm diagnostics
Everything a bug report opens with, in one block: which MeshTerm, on which host, in which terminal, talking to which radio, over how much stored history. It is the answer to the three or four questions that otherwise get asked one at a time in an issue thread, and the person who hit the bug is usually the one least able to answer them — half of it is resolved at boot and shown on no screen.
This sample is the one exception to About the examples: it is an illustrative report from a real radio over Bluetooth, because the simulator's report says little. The field names and 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 page's seven sections — build, host, terminal, radio, storage, stored rows, changed preferences. The command line shows them as blank-line-separated records, because that face is a stream to grep; the page and the saved file show them as markdown headings, because that one is a document to read.
The radio is asked but never required. A report about a companion that will not connect
is exactly the report most worth filing, so a failure to reach it becomes connected no
with the radio's own words in error, and every other fact still arrives.
Only overridden preferences are listed. The defaults are in the source and identical for everyone; the two the reporter changed are the two that can explain anything.
The mesh is described in aggregate and only in aggregate — a row count per table, and the span of time the observations cover. The counts come off the schema rather than a list written down somewhere, so a table added later starts being reported the day it lands.
Nothing in the block is private. No pairing PIN, no admin password, no channel secret,
no private key, no position, and no contact's name or key. That is a property of the
feature and not a habit: the block is designed to be pasted in public by somebody who has
not read it, and tests/test_diagnostics.py plants real secrets in every store that holds
one and fails if any of them — or a field merely named like one — reaches either face.
Under --json, the five fact blocks 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 it to a file instead of printing it, and the answer becomes the
path — the same trade config export-key --out makes, since a caller who asked for a file
wants to be told where it is rather than handed the contents they just redirected into it.
$ meshterm diagnostics --out meshterm-diagnostics.md
✓ diagnostics written — attach it to the report.
meshterm-diagnostics.md
The file is markdown, whichever face the run was printing, because an issue tracker
renders markdown as written — no fence, no apology. Every value is a code span, so a
Windows path's backslashes and a firmware error's asterisks arrive as themselves rather
than as markdown. Redirection still works and is not replaced; --out is what gives you
the document rather than the record stream.
In the menu this is the Diagnostics page under This app, and it is one of the app's
written pages — the same markdown renderer behind the About pages, drawing the same
document the file holds. That buys what a long block most needs: its ## headings are
landmarks, so each pins to the top row while its own section scrolls under it and
^PgUp/^PgDn step section by section.
s saves, advertised in the footer hint on a desktop and on the lane's free F3 slot on
the PicoCalc — never in the page's body, which belongs to the report. It writes
meshterm-diagnostics.md into the config directory beside the log, and answers in a popup
naming the path. A write that fails says so in the same popup, in red, and leaves the page
readable.
meshterm platform
The narrow sibling of diagnostics: which UI flavour a given invocation resolves to, and
why, without asking 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, and the usual reason a Windows session
looks plainer than the screenshots. Under --json the same keys, with icons 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 visual-language specimen — every mark, icon, colour scale and fold on one card.
This is the one command that keeps its colour, and it builds its own themed console to
do it. That is deliberate: the colour is the output. It is the font-and-palette
acceptance card on the PicoCalc console, and with --platform picocalc it previews that
flavour from a desktop. A monochrome specimen would test nothing.
It is also the one command that refuses --json, as a usage error (exit 2). There is
no data face behind a colour card, and a document of it would be a lie or an empty gesture.
Recipes
Structure goes through --json and jq; the plain face is for looking at.
Every repeater's key.
meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
Name and last-heard, tab-separated, for a spreadsheet or column -t:
meshterm contacts --json | jq -r '.[] | [.node.name, .heard_at] | @tsv'
Watch a node and shout if it goes quiet. trace returning 5 means 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, which is the number a route argument is usually 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, our own node included, the way the plain face draws it:
meshterm trace-path --path "a1,d4,a1" --json \
| jq -r '.route | map(.name // .hash) | join(" -> ")'
Capture a window of traffic and aggregate it yourself. The plain face's summary block has no JSON counterpart on purpose; the records it was computed 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
Nightly config archive, keeping the path the command actually wrote:
out=$(meshterm config backup "$HOME/mesh/$(date +%F).toml") && echo "archived $out"
Queue for someone who is offline, then chase it later:
id=$(meshterm courier queue Alice "call me" --json | jq -r .id)
# ... later ...
meshterm courier send "$id"
Sample a trace politely. A trace transmits exactly once per invocation by design; pace the repeats 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, rather than cron. A companion never advertises by itself, so MeshTerm can do it for you — once a 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 once it has gone a full
week without one — a flood advert you send by hand restarts that device's week — and only
after it has heard the mesh and then heard nothing for the quiet spell plus a random 0–5 s.
Do not put config advert --flood in a cron slot instead: every repeater in range
rebroadcasts a flood advert, and a repeater that decides you are bursting can blacklist
you.
Cron-friendly quiet. --quiet suppresses console logging entirely, leaving only the
command's own output and any error line. Cron's PATH is nearly empty, so give it 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, and that is the honest answer rather than a degraded one. Each of these is a live picture — its meaning is where things sit on a grid, how they move, and what colour they are — and none of that survives being turned into records.
| Feature | Why not, and what to use instead |
|---|---|
| Map | A drawing of braille cells whose nodes are told apart by colour. Stripped of colour to match the rest of the CLI it would be unreadable; left coloured it could not be piped anywhere useful. meshterm contacts lists the same nodes, coordinates and all. |
| Dashboard | A live overview that repaints every second. meshterm monitor captures the same traffic as records. |
| Live feed | Every packet as it arrives, newest first, with a viewer behind each row. meshterm monitor is the scripted tail. |
| Watchtower | A background sentinel over starred nodes; it exists to interrupt a session. Build the same alarm from meshterm trace's exit status (see Recipes). |
| Time machine | Braille charts over a switchable window. The underlying history is in the database --db points at. |
| Mesh walk | An evidence graph walked one node at a time. meshterm records and meshterm trace cover the walks it is built from. |
Everything else in the menu has a command, listed above — and every one of them answers to
--json.