Files
MeshTerm/docs/cli/README.md
T
Jean-Pierre MartineauandClaude Opus 5.5 5d2648d652 MeshTerm 0.11.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:46:15 -04:00

2158 lines
107 KiB
Markdown

# 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 `5m` instead of
`2026-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 as `null`, 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](#running-it)
- [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state)
- [About the examples](#about-the-examples)
- [The plain face](#the-plain-face)
- [The JSON face](#the-json-face)
- [Exit status](#exit-status)
- [When it goes wrong](#when-it-goes-wrong)
- [Command reference](#command-reference)
- [Finding a device](#finding-a-device)
- [This node](#this-node)
- [The mesh around you](#the-mesh-around-you)
- [Messaging](#messaging)
- [Tracing](#tracing)
- [Other nodes](#other-nodes)
- [MeshTerm itself](#meshterm-itself)
- [Recipes](#recipes)
- [What has no command](#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](../guide/connecting.md#bluetooth). |
| `--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](../guide/configuration.md#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](#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-optimize`
> is 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:
```bash
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 show` always reports the
defaults, and `channels list` always answers `5`. On real hardware, the radio keeps
the values.
- **`PKTS` in `contacts` stays `-`.** 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 `trace` that the device routes invents its hop names.** If you give
`trace --target NAME` with 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.
> **`--mock` records 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
> is `meshterm-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.
```console
$ 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:
```console
$ 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.
```console
$ 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:
```console
$ 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:
```console
$ 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:
```console
$ 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**:
```console
$ 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:
```console
$ 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](#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.
```json
{"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: null` and `type: null`. Its `name` is what the resolver could find. To match
> nodes across commands, join on `hash` **and** the width that the command used to
> address it. Do not join on `key`.
**`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.
```json
{"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:
```bash
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:
```bash
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.
```console
$ 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](../guide/connecting.md) 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:
```console
$ 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:
```console
$ 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:
```console
$ 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:
```console
$ 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:
```console
$ 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.
```console
$ 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`.
```console
$ 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-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`. |
```console
$ 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.
```json
{"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.
```console
$ 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:
```json
{"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. |
```console
$ 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:
```console
$ 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.
```console
$ 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:
```console
$ 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`. |
```console
$ 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.
```json
{"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. |
```console
$ 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**:
```json
{"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:
```console
$ 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.
```console
$ 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`.
```console
$ 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.
```console
$ 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`](#meshterm-rooms). 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:
```console
$ 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.
```console
$ 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 -
```
```console
$ 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:
```console
$ 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:
```console
$ 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:
```json
{"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`:
```json
{"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. |
```console
$ 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:
```console
$ 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:
```json
{"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:
```json
{"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.
```console
$ 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:
```console
$ 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:
```toml
[[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.
```console
$ 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`.
```console
$ 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.
```console
$ 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). |
```console
$ 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`.**
```console
$ 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-path).
```console
$ 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. |
```console
$ 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. |
```console
$ 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. |
```console
$ 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.
```json
{"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. |
```console
$ 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`:**
```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. |
```console
$ 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`:**
```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. |
```console
$ 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](#where-meshterm-keeps-its-state). 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`:
```console
$ 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.
```console
$ 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`:**
```json
{"page":"discord","title":"Join Discord","version":"0.11.2","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](#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.
```console
$ 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:
```json
{"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.
```console
$ 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.
```console
$ 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:
```json
{"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`.
```console
$ 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](../devices/README.md#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.**
```bash
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`:
```bash
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.
```bash
#!/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:
```bash
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:
```bash
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.
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```cron
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](#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`.