CLI reference
Every meshhold subcommand and flag.
The meshhold binary is both the node daemon and a thin client for its REST
surface. The same binary ships on every platform — Linux package, Windows
installer, macOS tarball — and every subcommand documented here is available
on all of them.
meshhold [--version] <command> [args] [flags]
A bare meshhold (or meshhold --help) prints the top-level subcommand
list. meshhold <command> --help always works and is the authoritative
source if anything below drifts from the binary.
The commands split into three families by how they reach the daemon:
| Family | Mode | Talks to | Daemon must be… |
|---|---|---|---|
| local | offline | nothing | irrelevant |
| direct | offline | BadgerDB (file lock) | stopped |
| API | online | REST /api/v1/… |
running |
The header of every subcommand section below states which family it belongs
to. The "direct" family inherits the same constraint already documented on
set-password — they take an exclusive lock on the
metadata store, so they only work when the node daemon isn't holding it.
Global flags
These apply to every API-using subcommand (the --api family — auth,
vault, file, node, profile, s3-key, s3-perm, forwards,
agent, status, replicate-now, settings, chat, vpn, audit,
logs, bug-report, send / recv / transfers, plus the online
networks invite / networks add / networks add-peer):
| Flag | Default | Notes |
|---|---|---|
--api |
http://127.0.0.1:8080 |
REST API base URL. Override when calling a remote daemon over a tunnel. |
--password |
$MESHHOLD_PASSWORD |
Web UI password used for the implicit login. Reading from env keeps it off argv. |
How a credential is chosen. When neither --password nor
$MESHHOLD_PASSWORD is set, the CLI uses a saved token from
auth login (~/.meshhold/cli.json), and only then falls back
to the daemon's local admin token (<metadata_dir>/cli-admin.token,
readable by root / the daemon user — and on a Windows service install, by
administrators only). Running auth login once is what lets a
non-root, non-administrator user drive these commands without read
access to the daemon's config.yaml and admin-token files.
Several commands are auto: they prefer the running daemon's REST
API and fall back to opening the metadata store directly only when the
daemon is stopped, so they work in both states without a flag. These
are set-password, 2fa, mgmt-keys (self keys), networks list /
networks set-swarm-key, and blocks gc. They accept --config / -c
for the offline fallback path. (telemetry is local: it only
rewrites config.yaml and never touches a running daemon.)
daemon
Family: local — this is the daemon.
Runs the node. Reads ~/.meshhold/config.yaml (Linux) or
%LOCALAPPDATA%\MeshHold\config.yaml (Windows) and brings up the libp2p
host, REST listener, replication scheduler, and any optional surfaces
(S3, push, port-forwards, agent).
| Flag | Default | Notes |
|---|---|---|
--config, -c |
OS default | Path to config.yaml. |
--log-level |
info |
debug / info / warn / error. |
--log-format |
console |
console for humans, json for log aggregators. |
meshhold daemon --log-level=debug --log-format=json
keygen
Family: local. No daemon, no config.
Generates a fresh 32-byte swarm key in the libp2p PSK
(/key/swarm/psk/1.0.0/) format. Every node that wants to join the same
network must share this key — treat it as sensitive.
| Flag | Default | Notes |
|---|---|---|
--output, -o |
stdout | Write to file instead. File is created mode 0600. |
meshhold keygen -o ~/.meshhold/swarm.key
keygen-reality
Family: local.
Vestigial. The reality transport authenticates with a marker derived from the swarm key, not an X25519 server key, and nothing in the daemon loads the private half this command writes. You do not need to run it to use REALITY, and losing the file costs you nothing. It is kept for now because removing a shipped command breaks scripts;
meshhold config lintflagsnode.obfs.reality.private_key_fileif you set it.
Generates an X25519 keypair. The private half is written to disk; the public half goes to stdout in base64url form.
| Flag | Default | Notes |
|---|---|---|
--output, -o |
~/.meshhold/reality.key |
Private-key path. Mode 0600. |
--force |
false |
Overwrite an existing private-key file. |
The command refuses to clobber an existing file unless --force is given.
check-reality-dest
Family: local.
Probes a candidate REALITY destination (<host:port>) for the four
properties the transport relies on: TCP reachability, TLS 1.3 negotiation,
X25519 key share, and an ALPN response. Designed to run before you flip
node.obfs.reality.enabled = true.
meshhold check-reality-dest www.microsoft.com:443
Exits non-zero when the destination is not REALITY-compatible, so you can wire it into a first-run script.
| Flag | Default | Notes |
|---|---|---|
--timeout |
10s |
Combined TCP + TLS-handshake budget per probe. |
Sample output:
TCP www.microsoft.com:443 OK
TLS 1.3 OK
X25519 OK
ALPN "h2"
ServerCert CN=www.microsoft.com (issuer: CN=Microsoft RSA TLS CA 02)
set-password
Family: auto — talks to the daemon over loopback when one is running,
falls back to direct BadgerDB writes when it isn't. There is no --offline
flag; the CLI picks the right path automatically.
Sets the Argon2id hash the Web UI / REST API check against. Reads the new password from, in order:
$MESHHOLD_PASSWORDenv var- piped stdin (single line, non-interactive)
- interactive TTY prompt with confirmation
| Flag | Default | Notes |
|---|---|---|
--config, -c |
OS default | Path to config.yaml. |
MESHHOLD_PASSWORD='swordfish' meshhold set-password
A node upgraded from a build that hashed the password with bcrypt cannot
check its old hash at all, so its login answers 409 until this command
has been run once. See
Login brute-force protection
for what that looks like from the browser.
auth
Family: API. Controls how the CLI authenticates to a local (or
tunnelled) daemon. Run auth login once and every other --api
subcommand authenticates with the saved token — no password on the
command line, and no read access to the daemon's root-owned
config.yaml / cli-admin.token required. This is the supported way to
drive the CLI as a non-root user.
The token is a long-lived bearer (mhk_…) held in BadgerDB on the
daemon and saved to ~/.meshhold/cli.json (mode 0600) on the client,
encrypted under your own account's keystore — DPAPI on Windows, Secret
Service or a key file in your home directory on Linux. Copied to another
machine or another account the file is inert; where the platform offers no
keystore it stays plaintext, as before. An older plaintext cli.json keeps
working and is rewritten encrypted on your next auth login.
It survives daemon restarts and upgrades, is independent of the Web UI
password (changing the password does not revoke it — same model as
S3 tokens), and has no expiry: revoke it explicitly with auth logout
or auth revoke. The daemon stores only the token's SHA-256, so a
database leak yields no usable credential.
| Subcommand | What it does |
|---|---|
auth login |
Prompt for the password once, mint a token, save it to ~/.meshhold/cli.json. |
auth logout |
Revoke the saved token on the daemon and delete the local file. |
auth list |
List active CLI tokens; this device is marked with *. |
auth revoke <id> |
Revoke a token by id (a unique id prefix from auth list is enough). |
auth login reads the password from $MESHHOLD_PASSWORD, piped stdin,
or an interactive prompt. If 2FA is enabled, pass --totp <code> or
enter it when prompted.
| Flag | Default | Notes |
|---|---|---|
--api |
http://127.0.0.1:8080 |
Daemon the token is minted against and saved for. |
--label |
hostname | Free-form label shown by auth list. |
--totp |
— | TOTP code, when 2FA is enabled. |
# Log in once on a server, as an ordinary (non-root) user:
meshhold auth login
# From then on every --api command authenticates with the saved token,
# e.g. `meshhold node …`, `meshhold vault …`, `meshhold mgmt-keys …`.
# Revoke a lost device's token from any logged-in machine:
meshhold auth list
meshhold auth revoke fe774279
vault
Family: API.
meshhold vault list
meshhold vault get <vault_id>
meshhold vault keyid [<key>] # also: --key-file <path>
meshhold vault create
meshhold vault s3-alias <vault_id> <alias> # pass "" to clear
meshhold vault share <vault_id>
meshhold vault link new <vault_id> <path> # flags below
meshhold vault link ls <vault_id> # --all
meshhold vault link rm <vault_id> <link_id>
meshhold vault join <invite-or-key> # flags below
meshhold vault rename <vault_id> <new-name>
meshhold vault ignore <vault_id> # view; --set <glob> (repeat) / --clear
meshhold vault sync <vault_id> # --wait --node <id> / --replicas N
meshhold vault rm <vault_id> # --force to confirm
| Subcommand | Purpose |
|---|---|
list |
Table of vaults: ID, NAME, TRUSTED, RF, STORAGE_PATH. |
get |
Per-vault detail including replication factor, full-sync flag, storage path. |
keyid |
Derive the canonical 16-byte hex vault_id from a key (Argon2id + HKDF). Runs locally — the key never leaves the CLI process. |
create |
Mint a fresh random 32-byte key and print key + derived vault_id. |
s3-alias |
Set or clear the alias under which the vault is exposed by the S3 endpoint. Aliases follow DNS-bucket rules (3–63 chars, lowercase, etc.). |
share |
Print the meshhold://join invite another device scans / pastes to join this vault. Also covers chat rooms (rooms are vaults of type chat). Only works for vaults this node holds the key for. |
link |
Public share links — a folder handed to somebody with no account, over HTTPS. Not share: that hands another device the vault key, this hands one folder to whoever holds a URL. See Public Links. |
join |
Add a vault from an invite (or a bare key). When the invite carries a swarm key, the node joins that network first so replication can reach the vault's peers. |
rename |
Change a vault's display name. Runtime-added vaults only — config.yaml vaults are read-only via the API. |
ignore |
View (no flags) or replace this node's gitignore-style ignore globs: --set <glob> (repeat per pattern), --clear to remove all. Stacks on built-in defaults + a .holdignore file. Runtime-added vaults only. |
sync |
Flush local changes into the catalog and kick replication now. --wait blocks until copies land (--node <peer_id> = that backup holds every file; --replicas N = N+ holders; default = replication_factor), with --timeout/--interval. Exits non-zero on timeout — usable as a backup gate. |
rm |
Forget a runtime-added vault (its key, storage path, metadata). Gated behind --force. Blocks are reaped lazily by blocks gc; peers keep their copies. |
vault link flags (on new):
| Flag | Default | Notes |
|---|---|---|
--ttl |
the node's default | 24h, 7d, or never. "Never" has to be typed — a permanent public link should not happen by leaving a flag off. |
--name |
the folder's name | Title the recipient sees. A directory name is often a sentence nobody wrote for an audience. |
--note |
— | Private reminder for you. Never sent with the link and never in the page. |
--password |
— | Encrypts the key inside the link, so the URL alone is useless. Send it separately; it cannot be recovered or changed later, only replaced by a new link. |
--drop |
off | Make a link that receives files instead of handing them out. Files land in Inbox/. |
--plain |
off | Serve one file unencrypted, so curl and <img src> work — at the price of the node serving it being able to read that file. |
--max-bytes |
a multiple of the content | Total the link may move, e.g. 10GB, or unlimited. A read link is a bill against your connection; a drop link is a bill against your disk. |
--no-publish |
off (so publishing is on when a gateway is configured) | Do not send the content to the gateway. The link then works only while this node is awake — which is what every link did before publishing existed. Worth it for something too large to be worth uploading. |
vault link new says on stderr whether the link needs this node awake:
either how much is being sent to the gateway, or why nothing is — a
gateway with no room, or none configured. It is invisible until the day
it matters, which is why it is said at the moment the link is made.
The URL is printed once, because it exists once: the daemon keeps the
token's hash and the link's key wrapped under the vault key, so nothing
can reproduce it afterwards. The part after the # is that key — it
never reaches the server, which is what stops the serving node reading
what it serves, and it is what some chat clients strip.
Needs node.share.enabled and node.share.base_url in the daemon's
config before a link can actually be answered; new says so on stderr
when they are missing.
keyid accepts the key as a positional arg, --key-file <path> (use -
for stdin), or piped stdin.
join flags (used mainly on the bare-key path; invite values are filled
in automatically and overridden by any flag you pass):
| Flag | Default | Notes |
|---|---|---|
--name |
— | Local display name (overrides the invite's vault_name). |
--type |
from invite, else storage |
storage or chat. |
--storage-path |
empty | Back the vault with this folder. Empty = observer / streaming mode — blocks are decrypted on demand, never written to local disk. |
--rf |
0 |
Replication factor. 0 = daemon default (3 storage / 2 chat). |
# Onboard a vault key shared with you out-of-band:
meshhold vault keyid --key-file ./shared.key
# 5e3b...c4
# Generate one from scratch:
meshhold vault create
# key: 4c1e...f0
# vault_id: 9a44...20
# Share a vault, then join it from another headless node:
meshhold vault share 9a44...20
# meshhold://join?swarm_key=…&bootstrap=…&vault_key=…
meshhold vault join 'meshhold://join?swarm_key=…&vault_key=…' --storage-path /srv/vault
file
Family: API.
meshhold file ls <vault_id>
meshhold file get <vault_id> <path>
meshhold file upload <vault_id> <path> <local_file> # local_file '-' = stdin
meshhold file download <vault_id> <path> <local_file> # local_file '-' = stdout
meshhold file rm <vault_id> <path>
meshhold file mv <vault_id> <old_path> <new_path>
meshhold file mkdir <vault_id> <path>
meshhold file rmdir <vault_id> <path>
| Subcommand | Purpose |
|---|---|
ls |
Table of files in the vault — size, modified-at (RFC 3339), deleted flag, path. A folder reads <dir> with a trailing slash. |
get |
File metadata: file_id, content + parent hash, modified-by, block count. |
upload |
Stream a local file (or stdin) into the vault. |
download |
Stream a vault file to a local file (or stdout). |
rm |
Soft-delete: marks the file as deleted; blocks linger until evicted. |
mv |
Move or rename. The blocks, the content hash and the file id all stay, which matters because artwork, video metadata and play counts are filed under the id. Refuses a destination that is taken. |
mkdir |
Create a folder, and the folders above it. Refuses a name already in use. |
rmdir |
Remove an empty folder. One with anything still under it is refused — emptying a folder is a different operation, and you should have to say which you mean. |
A vault's folders are otherwise implied by the paths of the files in them, so
mkdir is the only way to make one before there is anything to put in it. The
folder is a catalog entry like any other: it replicates to peers, survives
being empty, and shows up on every mount and in the Android picker.
Both upload and download accept - as the local-file argument for
stdin / stdout. Combined, they pipe across vaults:
meshhold file download v1 docs/spec.md - \
| meshhold file upload v2 docs/spec.md -
node
Family: API.
meshhold node list
meshhold node self
meshhold node share [node_id]
meshhold node connect <invite> # --network <id>
| Subcommand | Purpose |
|---|---|
list |
Peer table — node ID, self/peer flag, reliable flag, last-seen, direct addrs. |
self |
Just the local node's libp2p peer ID (handy in scripts). |
share |
Print a node-share meshhold://join invite — this node's peer ID + reachable addresses, but no swarm key. With a node_id argument, builds a "connect through this node" invite for a remote peer the daemon currently knows addresses for. |
connect |
Graft a node-share invite's addresses onto a saved network (and dial the peer immediately when that network is active). Defaults to the active network; --network <id> targets a specific one. Convenience wrapper over networks add-peer. |
# On node A — share yourself:
meshhold node share
# meshhold://join?peer_id=12D3KooW…&host=…&plain_port=…
# On node B (already on the same network) — start talking to A directly:
meshhold node connect 'meshhold://join?peer_id=12D3KooW…&host=…&plain_port=…'
profile
Family: API.
Reads and edits this node's user profile — the values the daemon persists in its metadata store and advertises to peers over the hello / topology beat. There is no offline path: a profile change has to flow through the running daemon so peers pick it up, so the daemon must be running.
meshhold profile show [--json]
meshhold profile set-name <display-name>
meshhold profile set-country <code>
meshhold profile set-allow-calls <true|false>
| Subcommand | Purpose |
|---|---|
show |
Print the profile: node ID, display name, country code, and the allow_calls flag. --json for machine-readable output. |
set-name |
Set the human-readable name this node advertises to peers — a runtime override that takes precedence over config node.name. Applies live (no restart), broadcast on the next hello beat. Without a name a headless node shows only its opaque peer ID in everyone's Network page. |
set-country |
Set the declared country (ISO 3166-1 alpha-2, e.g. RS). Pass "" to clear it and fall back to the auto-detected country. |
set-allow-calls |
Master switch for "this node accepts audio/video calls". When false, every peer's Network page hides the call buttons for this node — so a headless Linux/VPS host doesn't grow ringing-but-unanswerable buttons in everyone's UI. |
allow_calls defaults are platform-dependent until you set them
explicitly: true on Android / Windows / macOS (devices with a human at
the keyboard), false on Linux and other headless runtimes. That's why
calls to a Linux node are off by default — set-allow-calls true is the
way to turn them on from the command line (the Profile page in the Web UI
has the same toggle). An explicit value sticks across upgrades.
# Allow calls to this Linux node (e.g. a desktop with a mic + camera):
meshhold profile set-allow-calls true
# allow_calls set to true
meshhold profile show
# node_id: 12D3KooW…
# display_name: garage-pi
# country_code: RS
# allow_calls: true
Note that this flag only governs whether peers offer the call button and whether this node accepts incoming calls. Actually answering still needs working audio/video I/O — on a truly headless server with no mic, camera, or speakers the button will appear but there's nothing to answer with.
s3-key
Family: API.
meshhold s3-key add [--label <text>]
meshhold s3-key list
meshhold s3-key delete <access_key_id>
| Subcommand | Purpose |
|---|---|
add |
Mint a new (access_key_id, secret_access_key) pair. Secret is shown only once. --label accepts a free-form name like backup-laptop. |
list |
Table of keys — without secrets. |
delete |
Drop a key; cascades to its grants. |
s3-perm
Family: API.
meshhold s3-perm grant <access_key_id> <vault_id> <perms>
meshhold s3-perm revoke <access_key_id> <vault_id>
meshhold s3-perm list [--key <id>] [--vault <id>]
<perms> accepts read, write, read+write, plus the shorthands r,
w, rw, read,write. To remove a grant entirely use revoke, not
grant <... > none.
mgmt-keys
Family: auto — the self-key subcommands talk to the running daemon
over REST and fall back to the metadata store when it's stopped (the
peer subcommands are REST-only — see below).
Manages this node's self management keys — named credentials another
device presents when it wants to invoke a capability on this node
(currently tunnel and camera). See the
mesh-VPN scenario for how these get consumed.
Keys are filed under a saved network: the Web UI's Network → Keys tab
shows only the active network's keys (plus untagged keys, which appear
everywhere). A new key is filed under the active network by default; pass
--network <id> to file it elsewhere. The tag also scopes
verification: this node only honours a self key while it is connected to
that key's network — a key filed under network A won't authorise a
tunnel / mesh / camera request while the node is on network B. Untagged
(global) keys are honoured on every network. The swarm key is still the
outer gate; the network scope is defence-in-depth for a node that serves
several networks. To make a key work everywhere, file it under All
networks (global) (Web UI) or --network "".
meshhold mgmt-keys list [--json]
meshhold mgmt-keys add --name <text> [--caps <list>] [--expires-in <dur> | --never-expires]
[--allow <proto:port> ...] [--key <secret>] [--network <id>]
meshhold mgmt-keys show <id> [--json]
meshhold mgmt-keys share <id> [--node-id <peer>]
meshhold mgmt-keys rm <id>
share prints the unified meshhold://join?mgmt_key=… invite (the form
another device's scanner recognises as a management key). Because the
command runs offline it can't discover this node's own peer ID — pass
--node-id <peer> to embed it so the receiver can auto-bind the key to the
right node. Without it the invite still works; the receiver picks the node
manually.
| Flag | Default | Notes |
|---|---|---|
--config, -c |
OS default | Config path. |
--name |
— | Required on add. Display label. |
--caps |
tunnel,camera |
Comma-separated capability list. Known values: tunnel, camera, meshlan (the legacy slug mesh-route is still accepted). meshlan is mutually exclusive with tunnel/camera. |
--expires-in |
30d |
Validity period from now. Accepts Go duration syntax plus the d suffix (30d, 12h30m). |
--never-expires |
false |
Mint a non-expiring key. |
--allow |
— | Open a local port to holders of the key: proto:port or proto:lo-hi (tcp:22, tcp:8000-8100). Repeatable. Used by meshlan keys for the per-node port ACL (default-deny). |
--key |
— | Import an existing 32-byte secret (base64/hex) instead of generating one — how a node joins a shared meshlan network key. |
--network |
active network | File the key under this saved-network id. Defaults to the active network when the daemon is up; a stopped daemon has no active network, so pass it explicitly to file the key offline. Scopes the Keys-tab filter and verification — the key authorises only while the node is on that network (pass "" for a global key honoured everywhere). |
--json |
false |
Machine-readable output on list / show. |
<id> accepts any unambiguous prefix of the full key ID — paste the
short 8-char form list prints.
meshhold mgmt-keys add --name="My phone" --caps=tunnel,camera --expires-in=30d
meshhold mgmt-keys add --name="Friend exit" --caps=tunnel --never-expires
# Stand up a mesh-LAN network (see the Flat Mesh LAN scenario):
meshhold mgmt-keys add --name="Home net" --caps=meshlan --never-expires \
--allow tcp:22 --allow tcp:445
# …then JOIN it on another node with the printed secret:
meshhold mgmt-keys add --name="Home net" --caps=meshlan --never-expires \
--key <secret>
mgmt-keys peer
Family: API (daemon must be running).
The subcommands above manage self keys — the credentials this node
hands out. mgmt-keys peer manages the mirror image: the credentials
other nodes have granted us, which forwards add --peer-key
and vpn up --key reference by ID. Storing one from the CLI is what
makes a fully headless port-forward / VPN setup possible.
meshhold mgmt-keys peer list [--json]
meshhold mgmt-keys peer add <invite-or-key> --name <text> [--node-id <peer>] [--network <id>]
meshhold mgmt-keys peer rename <id> <new-name>
meshhold mgmt-keys peer rm <id>
--network <id> files the stored credential under a saved network for
the Web UI's per-network Keys filter (defaults to the active network).
add accepts either the full meshhold://join?mgmt_key=… invite the
other node's mgmt-keys share (or its Web UI) produced — it parses the
issuing node_id and any password-gate parameters out of the URL — or
a bare key, in which case pass --node-id to bind it to the issuer.
<id> accepts any unambiguous prefix of the ID shown by peer list.
# On node A (the exit), mint and share a tunnel key:
meshhold mgmt-keys add --name="for my laptop" --caps=tunnel --never-expires
meshhold mgmt-keys share <id>
# meshhold://join?mgmt_key=…&node_id=12D3KooW…
# On node B (the client), store it, then use it in a forward:
meshhold mgmt-keys peer add 'meshhold://join?mgmt_key=…&node_id=12D3KooW…' --name "A exit"
meshhold mgmt-keys peer list # copy the short id
networks
Family: mixed. list / set-swarm-key are auto — they prefer
the running daemon's /api/v1/networks and fall back to opening
networks.json (and the metadata Badger) directly when it's stopped,
so they work in either state. invite / add / add-peer are API
(daemon must be running). Each per-command --help states which.
Manages the saved-networks roster the daemon persists at
<metadata_dir>/networks.json.
meshhold networks list [--json] # direct
meshhold networks set-swarm-key <id> (--key <psk> | --regenerate) # direct
meshhold networks invite <network_id> [--source own|upstream] # API
meshhold networks add <invite> # API
meshhold networks add-peer <network_id> <invite> # API
meshhold networks connect <network_id> # API
meshhold networks disconnect # API
meshhold networks rm <network_id> # API
| Subcommand | Family | Purpose |
|---|---|---|
list |
direct | List saved networks; active network is marked with *. |
set-swarm-key |
direct | Rotate a network's PSK. Either supply the new key (--key) or have one generated (--regenerate, printed to stdout). After rotation, restart the daemon and re-pair every other node under the new key — gossip-driven rotation is intentionally not implemented. |
invite |
API | Print the shareable meshhold://join URL for a saved network (the swarm key is embedded, so it's rendered daemon-side). --source own advertises this node's own addresses; --source upstream (default) re-emits the bootstrap roster originally received. |
add |
API | Join the swarm carried by a meshhold://join invite; auto-connects when this node is currently offline. Accepts network, vault, and room invites (only the swarm portion is consumed — use vault join to also add the vault). |
add-peer |
API | Append a node-share invite's addresses to a saved network (see also node connect). |
connect |
API | Switch the active network to a saved one and restart P2P against it. The choice is persisted (networks.json) and restored on the next daemon start. |
disconnect |
API | Tear down P2P and clear the active network (offline mode); saved networks are kept — reconnect with networks connect <id>. |
rm |
API | Forget a saved network locally; if it's the active one the daemon disconnects first. Does not affect other nodes or the network itself. |
key-receive
Family: API (daemon must be running).
Show a QR code that an already-configured device (the phone app) scans to push a key to this node — the headless counterpart to the web UI's "receive a key" button. Handy when bringing a node up over SSH, where you can't paste a swarm key. On the other device you open the Scan tab, scan the code, and pick which key to send; it's delivered over the network, encrypted to a one-time key embedded in the QR. On a LAN nothing leaves the local network, and only the key you pick is ever sent.
meshhold key-receive [--type network|vault|chat|mgmt|contact]
[--port <n>] [--host <addr>]… [--apply] [--qr]
| Flag | Purpose |
|---|---|
--type |
Which key to request (default network). Steers the picker on the scanning device. |
--apply |
Apply the received key immediately — network joins the swarm, vault/chat join the vault. Default on; --apply=false only prints the invite. mgmt and contact are always print-only. |
--port |
Pin the relay listener's TCP port (0 = OS-chosen, reachable only on the LAN). Set a fixed port — and open it in the firewall — for a node reachable only over the internet. |
--host |
Advertise this address in the QR instead of the auto-detected LAN IPs (repeatable). Use on a NAT'd VPS to point the QR at the public address. |
--qr |
Render the QR in the terminal (default on); --qr=false prints just the meshhold://give URL. |
On the same LAN the defaults just work. For a VPS, open a port and pass
--port (and --host <public-ip> behind NAT). To pin the port for the web
UI's button too, set node.key_relay_port in the config.
# On the VPS console (over SSH): receive a swarm key by scanning with your phone.
# Open TCP 39000 in the firewall first.
meshhold key-receive --type network --port 39000
# …scan the QR, pick the network on your phone, and this node joins it.
forwards
Family: API.
Manages TCP/UDP port forwards that ride the libp2p tunnel mesh (inherits
multi-hop routing + libp2p Circuit Relay v2). Direction mirrors
ssh -L / ssh -R.
meshhold forwards list [--json]
meshhold forwards add --name <n> (--forward | --reverse) --proto <tcp|udp> \
--listen <addr> --remote <host:port> \
--peer-node <peer_id> --peer-key <key_id> \
[--id <stable-id>] [--no-autostart]
meshhold forwards rm <id>
meshhold forwards start <id>
meshhold forwards stop <id>
| Flag | Default | Notes |
|---|---|---|
--name |
— | Required on add. Human-readable label. |
--forward |
— | ssh -L style: listen locally, peer dials the remote. |
--reverse |
— | ssh -R style: peer binds the listener, we dial back here. |
--proto |
tcp |
tcp or udp. |
--listen |
— | Required. Bind address (e.g. :16261, 127.0.0.1:1194). |
--remote |
— | Required. Dial destination (host:port). |
--peer-node |
— | Required. Counter-party libp2p peer ID. |
--peer-key |
— | Required. Name of the peer mgmt key stored locally. |
--id |
pf-<name> |
Stable handle. Defaults to a slug of --name. |
--no-autostart |
false |
Register but don't start. |
Exactly one of --forward / --reverse must be set.
Two canonical shapes:
# Expose a local Project Zomboid server through a public VPS:
meshhold forwards add \
--name pz-via-vps --reverse --proto udp \
--peer-node 12D3KooW…VPS --peer-key vps-key-id \
--listen :16261 --remote 192.168.1.50:16261
# Connect through a domestic peer to a remote OpenVPN:
meshhold forwards add \
--name openvpn --forward --proto udp \
--peer-node 12D3KooW…HOME --peer-key home-key-id \
--listen 127.0.0.1:1194 --remote 10.0.0.5:1194
agent
Family: API.
Drives the local daemon's universal-AI-agent surface — list / create / delete agent instances, share them with other devices, manage Code-mode workspaces, and trigger the OAuth sign-in.
meshhold agent list [--json]
meshhold agent show <id> [--json]
meshhold agent create [--name <text>] [--type claude|opencode|gemini]
meshhold agent delete <id>
meshhold agent share <id>
meshhold agent join <invite>
meshhold agent reset-key <id> --force
meshhold agent login <id>
meshhold agent workspace add <instance-id> <path> [--name <text>]
meshhold agent workspace remove <instance-id> <path>
| Subcommand | Purpose |
|---|---|
list / show |
Inspect instances and their workspaces / auth status. |
create |
Mint a new instance on the local node. --name defaults to Claude N for the next free slot. |
delete |
Stop live sessions, drop the per-instance config dir. |
share |
Print the meshhold://join/… URL another device should scan or paste. |
join |
Add a remote agent instance from a meshhold://join invite produced by share on the host node. |
reset-key |
Rotate the access key and kick every device that joined with the old one. --force is mandatory — there's no interactive confirm. |
login |
Trigger claude auth login on the daemon host. Opens a browser tab on that machine; the command blocks until OAuth finishes (≤ 10 min). |
workspace add |
Register a directory as a Code-mode workspace. --name defaults to the basename of the path. |
workspace remove |
Unregister a workspace by path. |
status
Family: API.
One-shot health summary of the running daemon — the first command to
reach for on a headless box. Folds /system/status, /system/nat, and
/version into a single round-trip: version, node ID, network state,
enabled surfaces (S3 / media / enrich), TLS, at-rest encryption, public
IP, and listen addresses. --json for the raw objects.
meshhold status
# version: 0.7.208
# node_id: 12D3KooW…
# running: true
# network: connected (home)
# surfaces: s3=false media=true enrich=true
# tls: on (self-signed)
# at_rest: on (linux-secret-service)
replicate-now
Family: API.
Forces one immediate replication cycle instead of waiting for the periodic tick — handy right after joining a vault or adding a peer. The daemon pulls any missing blocks and re-announces what it holds. Requires the P2P stack to be up; an offline node returns an error.
meshhold replicate-now
# replication cycle complete
settings
Family: API.
Reads and edits the runtime toggles the Web UI's Features panel owns —
these live in the metadata store, not config.yaml, so this is the
only headless way to flip them. With no flags it prints the current
state; each flag actually passed is sent as a partial update (the rest
are left untouched).
meshhold settings # show
meshhold settings --enrich=true # start the metadata enricher
meshhold settings --node-reliable=false # mark this node unreliable
meshhold settings --tmdb-key=<key> # "" clears it
meshhold settings --blocks-quota=53687091200 # 50 GiB cap; 0 = uncapped
| Flag | Notes |
|---|---|
--node-reliable |
Whether this node is a reliable replication target. |
--auto-answer |
Auto-answer incoming calls (camera / mic pickup). |
--media |
Enable the media library + Player surface. |
--enrich |
Run the metadata-enricher workers (started/stopped live). |
--tmdb-key |
TMDB API key for video enrichment. "" clears it. |
--acoustid-key |
AcoustID API key for music enrichment. "" clears it. |
--blocks-quota |
Block-store cap in bytes. 0 = uncapped. |
--json |
Machine-readable output. |
chat
Family: API (daemon must be running and connected to a network).
Use chat rooms from the terminal. A room is addressed by its full id,
an unambiguous id prefix, or its display name. Rooms are vaults of type
chat — create / join one with vault join … --type chat and share
one with vault share.
meshhold chat list
meshhold chat read <room> [--limit <n>] [--mark-read]
meshhold chat send <room> <text>
read renders media / location messages as compact placeholders and
can --mark-read up to the newest line shown.
meshhold chat list
meshhold chat send "Family" "running late, back by 7"
meshhold chat read Family --mark-read
vpn
Family: API.
Bring the system VPN up from the command line. With no exit it's the
master toggle — the mesh overlay plus whatever exits are configured, on one
interface (the way to turn a mesh-only overlay on from a headless box). With
--exit/--key it routes the default through that exit, authorised with a
peer mgmt key this node holds (add one with mgmt-keys peer add).
meshhold vpn up # mesh + configured exits
meshhold vpn up --exit <peer_id> --key <peer_key_id> [--name <label>]
meshhold vpn down
meshhold vpn status
status always works (reports not-running / not-supported cleanly);
down is idempotent. The system VPN needs a daemon-owned TUN, so up
works on Windows (via the helper service) and Linux; on Android the
fd arrives from the platform VpnService, so up returns a clear error there.
Exit routing — vpn routes, vpn config, vpn policy
The full split-tunnel / multi-exit model (see
Exit routing) is
scriptable headlessly. All three talk to the daemon and persist to the
vpn: config block.
Kept-local routes — destinations that stay off the exit (LAN, exceptions):
meshhold vpn routes list
meshhold vpn routes add <cidr> [--note <label>]
meshhold vpn routes rm <id>
list shows system (read-only), auto-detected host subnets, and custom
routes; config-managed rows are tagged [config] and can't be removed here.
Exit selection, fail behaviour, default route — config show prints the
current block; config set changes only the flags you pass:
meshhold vpn config show
meshhold vpn config set [--enabled] [--exit <peer_id>] [--key <peer_key_id>]
[--name <label>] [--fail-mode drop|direct]
[--default-route exit|direct|drop]
[--auto-local-subnets]
--fail-mode direct is fail-open (send tunneled traffic out the host when
the exit is down; Windows/Android, else drops). --default-route direct
makes a selective VPN (only policy destinations are tunnelled).
--enabled arms boot auto-start.
Exits (multi-exit policy) — route destination prefixes through exit nodes.
A 0.0.0.0/0 rule is a full tunnel. For a destination the daemon tries the
matching exits highest-priority first and fails over to the next live one
(prefix length only breaks ties at equal priority), so two 0.0.0.0/0 rules at
different priorities are a hot-standby pair:
meshhold vpn policy list
meshhold vpn policy add <cidr> [<cidr>…] --exit <peer_id> --key <peer_key_id> \
[--priority <n>] [--name <label>] [--note <text>]
meshhold vpn policy state <id> <permanent|temporary|off> [--priority <n>]
meshhold vpn policy rm <id>
Egress / download QoS — prioritise and rate-shape tunnel traffic so a bulk transfer can't starve interactive flows. Rates are in kilobits/s; priority only takes effect once a rate is set (the shaper must be the bottleneck, not your ISP). See QoS:
meshhold vpn qos show # policy + live per-class telemetry
meshhold vpn qos preset [name] # list built-in presets, or apply one
meshhold vpn qos set --enabled --uplink-kbits <n> --downlink-kbits <n>
meshhold vpn qos set --from-file <policy.json> # full classes + rules
adblock
Family: API.
Manage the ad blocker — the domain blocklists merged into one filter and applied to the in-app tunnel browser and/or the system VPN. On the VPN the in-mesh resolver (the system DNS while the VPN is up) NXDOMAINs blocked names for every app on the device. Mirrors the Settings → Ad blocker panel; edits persist as a runtime override and the daemon rebuilds the filter live.
meshhold adblock # status: config + per-source domain counts
meshhold adblock status [--json]
meshhold adblock js <on|off> # in-page JS cosmetic hiding (browser only)
meshhold adblock apply browser <on|off>
meshhold adblock apply tunnels <on|off>
meshhold adblock sources # list sources with their index
meshhold adblock add <url> [--name <label>] [--disabled]
meshhold adblock enable <index> # index from `adblock sources`
meshhold adblock disable <index>
meshhold adblock remove <index>
meshhold adblock refresh # re-download every enabled source now
Sources are hosts-format URLs (0.0.0.0 host or one domain per line);
the built-in offline list shows as a non-removable row. Only enabled
sources are downloaded and applied — disabled ones cost nothing. status
shows each enabled source's live state (downloading / ok / error)
and parsed domain count.
meshlan
Family: API.
Inspect the mesh-LAN overlay — the routable virtual network where every member reaches another member's local services by a stable virtual IP.
meshhold meshlan status [--json]
Shows this node's virtual IPs and the peers reachable by IP, or reports
that this node holds no meshlan key, or that the overlay is idle (the TUN
couldn't come up). Membership and which local ports are open are controlled by
Mesh LAN (meshlan) management keys, not this command — see
mgmt-keys.
audit
Family: API.
Reads the daemon's security / activity audit log — logins, vault unlocks, key changes, swarm-key rotations, peers appearing / leaving.
meshhold audit list [--limit <n>] [--severity info|warn|critical] [--json]
meshhold audit export [file] # full JSON-Lines dump ('-' or omit = stdout)
logs
Family: API.
A snapshot of the daemon's in-memory log ring (not a live tail). Since the CLI logs to stderr with no file to tail, this is how you see what a running daemon has been doing on a headless install.
meshhold logs # last 1000 lines
meshhold logs --level=warn # warnings and errors only
meshhold logs --limit=200
meshhold logs --export node.log # dump the whole ring to a file ('-' = stdout)
bug-report
Family: API.
Builds a diagnostics bundle (daemon status, network snapshot, redacted config, audit tail, log ring) and optionally uploads it — the headless counterpart of the Profile-page "Report a problem" button.
meshhold bug-report send [--description <text>] # build + upload in one shot
meshhold bug-report build [--description <text>] # build on disk only
meshhold bug-report list # bundles saved on the host
send needs the operator to have configured a submit URL
(node.diagnostics.submit_url); otherwise use build and hand the
file over out-of-band.
2fa
Family: auto — prefers the running daemon's admin-token endpoint (no restart needed) and falls back to BadgerDB when it's stopped.
Enable / disable / inspect the optional TOTP second factor for the Web UI login. Compatible with Google Authenticator, Aegis, FreeOTP, etc.
meshhold 2fa setup # print the enrolment QR + recovery codes
meshhold 2fa disable
meshhold 2fa status
send / recv / transfers
Family: API.
Direct node-to-node file transfer — files stream peer-to-peer over libp2p and are never stored in a vault or chat.
meshhold send <file> [<file>...] [--to <node_id>] # interactive picker if --to omitted
meshhold recv [--once] # accept inbound transfers from a TTY
meshhold transfers # list recent + in-flight transfers
meshhold receive-policy [ask|accept-trusted|accept-from|off] # show or set consent policy
meshhold incoming-folder [path] # show or set where received files land
send reads the file paths on the daemon's host (the common case: CLI
and daemon co-located). recv is the headless answer to "the receive
policy is ask but there's no Web UI open to click Accept".
blocks
Family: auto (REST when the daemon is running, direct BadgerDB walk when it's stopped).
Ciphertext-store maintenance.
meshhold blocks gc [--dry-run]
gc deletes orphan blocks (no catalog entry references them) and
redundant blocks (regenerable on demand from a file-mode source), then
reports the bytes freed. --dry-run reports without touching disk.
telemetry
Family: local — rewrites config.yaml only; takes effect at the
next daemon start.
Inspect or toggle the once-a-day anonymous usage beat.
meshhold telemetry status
meshhold telemetry disable
meshhold telemetry enable
See privacy and telemetry for the exact fields the beat carries.
config
Family: local — reads config.yaml and nothing else.
meshhold config lint [--strict]
Reports three kinds of finding:
ERROR |
the daemon will not start with this — a bad value, or a *_file that cannot be read |
WARNING |
it parses and then nothing reads it (an unrecognised key, usually a typo), or a secret is in the file when it could be in a file or the environment |
NOTE |
the key is obsolete: still accepted, no longer meaningful |
The warnings matter because config loading is deliberately permissive about
keys it does not recognise — failing on one would break every node carrying
a key from a different build. So blocks_max_byte: 12345 parses, applies
nothing, and says nothing. This is how you find it.
WARNING node.blocks_max_byte
unknown key — it parses, and then nothing reads it.
WARNING swarm_key
secret written into config.yaml … Use MESHHOLD_SWARM_KEY or
swarm_key_file instead
Exits non-zero when there is an error, so it can gate a deploy. --strict
fails on warnings too.
at-rest
Family: local — edits config.yaml and the on-disk state directly.
The daemon must be stopped.
Inspect or change at-rest encryption of the metadata store, the libp2p
identity (node.key), and the saved networks (networks.json — this is
where the swarm key lives).
meshhold at-rest status
meshhold at-rest enable
meshhold at-rest disable --yes
status reports what config.yaml asks for and what the disk actually
holds, separately, plus which key provider is in play:
at-rest encryption: enabled (config: /etc/meshhold/config.yaml)
metadata_dir: /var/lib/meshhold/meta
database: encrypted
node.key: encrypted
networks.json: encrypted
key provider: linux-host-file
host binding: machine-id
Those two disagreeing is worth knowing about: a node whose disk is
encrypted while config says it is not will refuse to start, rather
than write plaintext over encrypted state. status names that case and
both ways out.
enable sets at_rest_encryption.enabled: true after checking that a
key source actually exists on this host. The migration itself runs at the
next daemon start.
disable decrypts everything back to plaintext and clears the flag. It
requires --yes because it lowers the protection on the node. Nothing is
deleted until the plaintext state has been reopened and verified to hold
what the encrypted state held.
Reach for disable when the keystore is gone or changing — a reinstalled
OS, a wiped keyring, a re-imaged VM, or a move to passphrase_file — and
the alternative is a node that will not start. It needs the master key to
work, so it cannot rescue a node whose key is genuinely lost; see
at_rest_encryption.
Hidden subcommands
_mcp-approve is reserved for the daemon's own use — it spawns this
process under claude --permission-prompt-tool to bridge MCP approvals
back over loopback REST. The leading underscore is the convention for
internal commands hidden from --help; you should never need to invoke
it directly.
Environment variables
A small set of env vars are read by the CLI. The secret-carrying ones take
precedence over both the *_file and inline forms in config.yaml — see
keeping secrets out of this file.
| Variable | Read by | Purpose |
|---|---|---|
MESHHOLD_PASSWORD |
set-password, auth login, every --api subcommand |
Web UI password; keeps the secret off argv in scripts. |
MESHHOLD_SWARM_KEY |
daemon |
The swarm key. Beats swarm_key_file and inline swarm_key. On Git Bash / MSYS use the hex form — a value starting with / gets path-translated. |
MESHHOLD_ACOUSTID_API_KEY |
daemon |
The AcoustID key. Beats acoustid_api_key_file and the inline value. |
MESHHOLD_TMDB_API_KEY |
daemon |
The TMDB key. Beats tmdb_api_key_file and the inline value. |
MESHHOLD_DAEMON_URL |
_mcp-approve (internal) |
Loopback REST URL handed in by the daemon. |
MESHHOLD_BEARER_TOKEN |
_mcp-approve (internal) |
Loopback-scoped bearer. |
MESHHOLD_SESSION_ID |
_mcp-approve (internal) |
Owning agent session ID. |
Exit codes
All commands follow the standard cobra convention:
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
Any error — invalid flags, missing arguments, daemon unreachable, REST 4xx/5xx, BadgerDB lock conflict, etc. The error message is written to stderr prefixed with error:. |