Reference

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 lint flags node.obfs.reality.private_key_file if 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:

  1. $MESHHOLD_PASSWORD env var
  2. piped stdin (single line, non-interactive)
  3. 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 routeconfig 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:.