REST API reference
All /api/v1/… endpoints with examples.
Every UI surface MeshHold ships — the bundled Web UI, the Android app, the
Windows tray launcher, the meshhold CLI's --api family — is a client of
this REST API. There are no "internal" endpoints; if the Web UI does it, you
can do it too with curl.
The base URL is whatever the daemon binds (api.bind in config.yaml,
default 127.0.0.1:8080):
http://127.0.0.1:8080/api/v1/...
When api.cert_file / api.key_file are set — or when the daemon
auto-generates a self-signed pair on first start — the same prefix moves to
https://. The TLS material is also exposed via /system/status so a
freshly-paired client can pin the fingerprint.
The surface clusters into six themes — storage, mesh control, comms, networking, integration, observability. Every section below maps to one of them; the diagram is a "you are here" key for the rest of this page.
Authentication
All routes live behind a bearer token. The token is minted by the login endpoint and presented on every subsequent call:
Authorization: Bearer <token>
Five endpoints are open without a token (for bootstrap reasons):
/version, /system/status, /auth/login, /auth/logout,
/auth/bootstrap-exchange, and /auth/admin/set-password (which enforces
its own X-MeshHold-Admin-Token check instead). Inbound webhooks at
/hooks/<token> are also exempt — they carry their secret in the path.
POST /auth/login
Exchange the Web UI password for a 24 h bearer.
curl -s http://127.0.0.1:8080/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"password":"hunter2"}'
{"token":"7f3b…","expires_at_ms":1716595200000}
Failure modes: 503 when no password has ever been set, 401 on a wrong
password (with a deliberate ~100 ms delay to slow guessing).
POST /auth/bootstrap-exchange
Trade a one-shot bootstrap ticket — minted by the tray launcher via the
loopback IPC pipe — for a regular 24 h bearer. The Web UI hits this
automatically when it spots a ?bootstrap=… query parameter on first
paint. Tickets are single-use and expire 60 s after issue.
POST /auth/admin/set-password
CLI-only. Replaces the Web UI password without knowing the current one.
The caller proves authority by presenting the daemon's process-lifetime
token from <metadata_dir>/cli-admin.token (mode 0600) in the
X-MeshHold-Admin-Token header. There is no web surface for this.
POST /auth/logout
Invalidates the bearer. 204 No Content either way.
CLI tokens — …/auth/cli-tokens
Long-lived bearer credentials for the meshhold auth CLI. Unlike the
24 h session bearer above, these are persisted on the daemon and survive
restarts, so a co-located CLI can save one to ~/.meshhold/cli.json and
keep using it. They are bearer-gated (the caller must already hold a
valid session bearer — via password login or the admin-token exchange —
before minting one), independent of the Web UI password, and stored only
as their SHA-256.
| Method + path | Does |
|---|---|
POST /auth/cli-tokens |
Mint a token. Body {"label":"…"} (optional). Returns {"id","token","label","created_at_ms"} — the plaintext token (mhk_…) is shown once. |
GET /auth/cli-tokens |
List public info: {"tokens":[{"id","label","created_at_ms"}]}. |
DELETE /auth/cli-tokens/{id} |
Revoke by id. 204, or 404 if unknown. |
Query-string token (?token=…)
A few routes accept the bearer in ?token= instead of the header, for
browser primitives that can't set custom headers:
| Path | Why |
|---|---|
GET /calls/events |
EventSource |
GET /events/stream |
EventSource |
GET /sessions/{sid}/events |
EventSource (AI agents) |
GET /sessions/{sid}/files |
<a download> / <img src> |
GET /audit/export |
<a download> |
GET /diagnostics/reports/{id} |
<a download> |
GET /calls/{id}/media |
WebSocket constructor |
GET /rooms/{id}/stream |
WebSocket constructor |
GET /vaults/{id}/stream/... |
<video src> |
GET /vaults/{id}/cover/... |
<img src> |
GET /vaults/{id}/subtitle/... |
<track src> |
GET /vaults/{id}/thumb/... |
<img src> |
Conventions
- All payloads are JSON unless stated otherwise.
Content-Type: application/jsonis mandatory on writes. - Time values are milliseconds since epoch as
int64, fields suffixed_ms. Durations are Go-style strings ("5s","2m"). - Vault and node identifiers are opaque strings. Tree-shaped paths
(
*pathroutes) are URL-encoded; a leading/is required. - Errors always look like
{"error":"message"}with a non-2xx status.507 Insufficient Storageis reserved for vault hard-quota hits and carriesvault_id/hard_quota/would_be_sizealongside the message.
System
The "what is this daemon" surface. Two of these (/version,
/system/status) bypass auth so a launcher can introspect before logging
in.
| Method | Path | Notes |
|---|---|---|
| GET | /version |
{ "version": "0.7.63" }. Cheap, unauth. |
| GET | /whoami |
{ "node_id": "12D3KooW…" }. Local peer ID. |
| GET | /system/status |
Full health snapshot — see below. Unauth. |
| POST | /system/setup |
First-boot helper — accepts a swarm key + bootstrap list and writes them into config.yaml. |
| GET | /system/share |
swarm_key, bootstrap, connect_url (a meshhold://join/… URL) for onboarding a new device. |
| GET | /system/share-node |
Same idea but without the swarm key — just node identity + reachable hosts. |
| GET | /system/metrics |
24 h ring buffer of per-minute metrics. ?from=<ms>&to=<ms> narrows the window. |
| GET | /system/browse |
Server-side filesystem browser used by the "pick a folder" dialog when adding a storage vault. |
| GET | /system/nat |
Diagnostics for the UPnP IGD subsystem + the /api/ipinfo lookup. Powers the Settings → Router & NAT panel. |
GET /system/status is the canonical health probe; the response includes
running, has_swarm_key, setup_required, peer_id, listen_addrs,
s3_enabled, tls.fingerprint, at_rest.{enabled,provider}, and the
active network's {state,id,name}.
Networks
A "network" is a saved (swarm key, bootstrap list) pair. The daemon can hold many but is only ever active in one.
| Method | Path | Notes |
|---|---|---|
| GET | /networks |
List saved networks plus the currently-active one. |
| POST | /networks |
Add an existing network from a meshhold://join/… URL. |
| POST | /networks/create |
Mint a brand-new swarm key and add as a network. |
| DELETE | /networks/:id |
Forget. The active network can't be deleted while connected. |
| POST | /networks/:id/connect |
Switch the daemon onto this network (P2P stack restarts). |
| POST | /networks/disconnect |
Tear down P2P; daemon stays alive in offline mode. |
| GET | /networks/:id/invite |
Build a fresh meshhold://join/… URL for sharing. |
| GET | /networks/:id/key |
Return the raw swarm key text (3-line IPFS PSK format). |
| POST | /networks/:id/peers |
Append additional bootstrap multiaddrs from a share payload. |
| PUT | /networks/:id/bootstrap |
Replace the bootstrap list with the supplied multiaddrs. |
| PUT | /networks/:id/bootstrap-invite |
Same, but extract the multiaddrs from a meshhold:// URL. |
| POST | /networks/:id/swarm-key |
Rotate the PSK and push the new one to every connected peer. |
Vaults
Vaults are the storage unit — every file lives in exactly one. Trusted vaults hold a key locally; untrusted vaults read encrypted blocks from peers.
| Method | Path | Notes |
|---|---|---|
| GET | /vaults |
List all visible vaults (trusted + untrusted observed in the swarm). |
| GET | /vaults/:vault_id |
Vault metadata: name, key state, RF target, byte count, peer count. |
| GET | /vaults/:vault_id/info |
Heavier view with config-effective fields and replica health. |
| POST | /vaults |
Create a trusted vault. Accepts keep_history, history_retention_mode/value, ignore_globs. |
| PATCH | /vaults/:vault_id |
Mutate name and the per-node history policy (keep_history, history_retention_mode/value) + ignore_globs. |
| DELETE | /vaults/:vault_id |
Untrust + remove from config. Blocks stay until eviction reclaims. |
| POST | /vaults/join |
Adopt a vault from a meshhold://vault/… share URL. |
| GET | /vaults/:vault_id/share-url |
Mint a share URL for the vault (encodes key + reachable bootstrap). |
| GET | /vaults/:vault_id/scan-status |
Filesystem-scanner progress: {active, walked, ingested, errors, …}. |
| POST | /vaults/:vault_id/sync |
Flush the local folder into the catalog (scan) and kick a replication sweep. Returns 204 immediately — does not wait. |
| GET | /vaults/:vault_id/replication-status |
Snapshot for backup gating: {total, replicated, pending, scan_active, done, criterion}. ?node=<peer_id> counts a file done when that node holds it; ?replicas=N when N+ nodes hold it (default: replication_factor). |
| POST | /vaults/:vault_id/lock / /vaults/unlock |
Password-protected vaults — try a password against every locked vault. |
| GET | /vaults/:vault_id/analytics |
Top types, top consumers, history. Drives the per-vault analytics tab. |
| PUT | /vaults/:vault_id/s3-alias |
Set the bucket alias used by the S3 gateway for this vault. |
| POST | /replicate-now |
Kick a one-shot replication cycle. Returns immediately. |
Files
*path segments are URL-encoded and must begin with /.
| Method | Path | Notes |
|---|---|---|
| GET | /vaults/:vault_id/files |
List a vault. ?path=/folder filters by prefix; ?include_deleted=1. |
| GET | /vaults/:vault_id/files/*path |
One file's metadata (block hashes, parent hash, conflicts). |
| GET | /vaults/:vault_id/download/*path |
Stream the body. Sets Content-Disposition: attachment. |
| GET | /vaults/:vault_id/stream/*path |
Same body, no attachment, supports Range: headers for the player. |
| POST | /vaults/:vault_id/upload/*path |
Body is the raw file. Up to 50 GiB per request. Returns the new FileJSON. |
| DELETE | /vaults/:vault_id/files/*path |
Tombstone the file. Blocks are GC'd by the replication cycle. |
| GET | /vaults/:vault_id/info/*path |
File info: replicas (peer ID + display name), full version DAG. |
| GET | /vaults/:vault_id/history/*path |
Version list, newest first. |
| POST | /vaults/:vault_id/restore/*path |
Roll the head pointer to a prior content_hash (body {"content_hash"}). |
| GET | /vaults/:vault_id/version/*path?content_hash=… |
Download a specific historical version. |
| POST | /vaults/:vault_id/history/trim?dry_run=… |
Reclaim this node's out-of-policy version blocks. dry_run=true returns {reclaimable_bytes, versions_affected} without deleting. |
| GET | /vaults/:vault_id/history-breakdown |
On-demand per-file history footprint: {files: [{path, version_count, history_bytes}], total_history_bytes}. Heavier scan, run by request (not in the analytics poll). |
| GET | /vaults/:vault_id/locality/*path |
{local: bool, holders: [peer_id…]} — used by the offline-mode badge. |
| POST | /vaults/:vault_id/cache/*path |
Force-fetch a file from peers so it's resident locally. |
| POST | /vaults/:vault_id/resolve-conflict/*path |
Pick the surviving leaf in a divergent version DAG. |
| POST | /vaults/:vault_id/rename |
Move or rename: body {from, to}. Keeps the blocks, the content hash and the file id. 409 if the destination is taken. |
| POST | /vaults/:vault_id/copy |
Duplicate to another path: body {from, to}. Costs no bytes — content is addressed by hash, so the copy points at the blocks the original already has. It gets its own file id, so metadata and play counts do not become shared. 409 if the destination is taken. |
| POST | /vaults/:vault_id/dirs |
Create a folder: body {path}. Parents are created too. 409 if the name is taken, 403 if the node will not write. |
| DELETE | /vaults/:vault_id/dirs?path=… |
Remove an empty folder. 405 if anything is still under it. |
Folders
A vault's folders are otherwise implied by the paths of the files in them,
which leaves an empty one nowhere to exist. A folder created through these
routes is a catalog entry of its own — no blocks, no size, no content hash —
so it replicates to peers and survives being empty. It appears in
GET /files with is_dir: true; skip those rows when you want files.
Two things it deliberately is not: an object over S3 (that protocol has no
directories, so folder rows are not listed and GET/HEAD on one is
NoSuchKey), and a renameable thing — a folder is the shape of the paths
beneath it, so moving one would be a rewrite of every file below.
Writing a file in pieces
POST /upload needs a Content-Length, which a filesystem does not have: it
learns how long a file is by being told it is finished. So a mount, and
Android's picker, write through a session instead.
| Method | Path | Notes |
|---|---|---|
| POST | /vaults/:vault_id/writes |
Open one: body {path, size?, mode?}. mode: "edit" modifies the stored file instead of replacing it — what appending and writing into the middle need. Returns {handle, ttl_ms, chunk_max}. |
| PUT | /vaults/:vault_id/writes/:handle?offset=N |
Body is the chunk, at an explicit offset. Up to 8 MiB. |
| GET | /vaults/:vault_id/writes/:handle?offset=N&length=M |
Read the file back as the session has it, edits included — what a program that opened the file read-write needs. 422 on a replacing session, which holds no bytes. |
| PATCH | /vaults/:vault_id/writes/:handle |
A final length learned after opening ({size}). Windows announces one between creating a file and writing to it. |
| POST | /vaults/:vault_id/writes/:handle/sync |
fsync: seal what has been written into the block store. The catalog entry naming it is still written on commit. |
| POST | /vaults/:vault_id/writes/:handle/commit |
Finish. The file becomes visible. |
| DELETE | /vaults/:vault_id/writes/:handle |
Throw it away. |
Offsets are explicit rather than implied by request order, because that is what the caller actually has, and because it lets the daemon catch an out-of-order write where it can answer instead of storing the file wrong.
A session that goes untouched is abandoned after node.write_session_ttl
(default 2 minutes) — it holds its path's lock for its whole life, so one that
is never collected would block every later write to that path. The deadline
comes back as ttl_ms when you open one. An expired handle answers 410
Gone, meaning "open a new one", not a generic failure. A refused chunk ends
the session, so a client that ignored the error cannot commit the fragment.
The upload response is synchronous — the client gets a FileJSON as soon
as the bytes are durably on disk and the catalog row is committed.
Replication to other nodes is a separate, asynchronous concern driven by
the broker; POST /replicate-now kicks it out of its sleep window.
# Upload a file to a vault:
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
--data-binary @./photo.jpg \
"http://127.0.0.1:8080/api/v1/vaults/$VID/upload/2026/photo.jpg"
# Download with Range — the player uses this verbatim:
curl -s -H "Authorization: Bearer $TOKEN" -H 'Range: bytes=0-1023' \
"http://127.0.0.1:8080/api/v1/vaults/$VID/stream/movie.mkv" -o head.bin
A vault hard-quota hit returns 507 Insufficient Storage:
{"error":"vault quota exceeded", "vault_id":"…",
"hard_quota":107374182400, "would_be_size":108221145600}
Per-file metadata, audio + video enrichment
| Method | Path | Notes |
|---|---|---|
| GET | /vaults/:vault_id/meta/*path |
Tags, audio fingerprint result, NFO, cover sha. |
| PUT | /vaults/:vault_id/meta/*path |
Manual override of selected meta fields. |
| POST | /vaults/:vault_id/refingerprint/*path |
Re-run AcoustID lookup on one file. |
| POST | /vaults/:vault_id/refresh-folder[/*path] |
Re-scan a folder for added/removed sidecars. |
| POST | /vaults/:vault_id/refingerprint-folder[/*path] |
Re-fingerprint every audio file under a folder. |
| POST | /vaults/:vault_id/audio/rescan |
Fire-and-forget rescan of every audio file in the vault. |
| GET | /vaults/:vault_id/audio/rescan/status |
{active, processed, total} for the rescan above. |
| GET | /vaults/:vault_id/cover/:hash |
Raw cover-art bytes. Cached aggressively. |
| GET | /vaults/:vault_id/video-meta/*path |
TMDB-enriched view: title, year, plot, poster path. |
| PUT | /vaults/:vault_id/video-meta/*path |
Override the auto-resolved TMDB match. |
| POST | /vaults/:vault_id/refingerprint-video/*path |
Re-run filename → TMDB lookup. |
| POST | /vaults/:vault_id/video-play-event/*path |
Push a "watched up to N ms" beat for resume. |
| GET | /vaults/:vault_id/video-tracks/*path |
Audio + subtitle track list (ffprobe + sidecar walk). |
| POST | /vaults/:vault_id/rescan-tracks/*path |
Invalidate the cached track list for this file. |
| GET | /vaults/:vault_id/subtitle/*path?track=…&format=vtt |
WebVTT-converted subtitle stream for the HTML5 <track>. |
Pictures
| Method | Path | Notes |
|---|---|---|
| GET | /vaults/:vault_id/thumb/*path?size=N |
A preview, always JPEG, size capped at 1024 and defaulting to 256. Images are decoded from their own bytes; a track or a film answers from its cached cover or poster. 415 when nothing can be decoded — the caller draws an icon. Accepts ?token= so an <img> can use it. |
| GET | /vaults/:vault_id/photos?path=…&recursive=0&cursor=…&limit=… |
Every picture and clip under path, with its EXIF joined on: one request instead of one per tile. Recursive unless told otherwise. See below. |
GET /photos returns {photos: [...], next_cursor, pending}. Three fields
repay attention:
taken_at_msis absent rather than falling back tomodified_at_ms. For a photograph the file's mtime is usually the day it was copied, so a date grouping built on it looks plausible and is wrong.meta_readseparates "this picture carries no date" from "nobody has read it yet" — the difference between a permanent Undated section and one that empties while you watch.pendingcounts the second kind.has_gpsarrives even whenlat/londo not, so a client can warn that an album carries location data without being handed the location.
local is three-valued: true/false say whether every block is on this
node, and absent means the listing spent its lookup budget and stopped
asking. Absent must not be drawn as "elsewhere".
Library (music)
The library endpoints aggregate the audio fingerprinting results across
every trusted vault into an iTunes-shaped view. Most accept
?fully_local=true to hide tracks the local node doesn't fully hold.
| Method | Path | Notes |
|---|---|---|
| GET | /library/artists |
Distinct artists with track counts. |
| GET | /library/artists/:name |
One artist's albums + songs. |
| GET | /library/albums |
Album grid (cover hash, year, song count). |
| GET | /library/albums/:key |
Album detail with tracklist. |
| GET | /library/songs |
Flat song list. Supports ?sort= and pagination. |
| GET | /library/genres |
Genre histogram. |
| GET | /library/search?q=… |
Full-text across artist/album/title/genre. |
| GET | /library/prefs/:vault_id/:file_id |
Per-track user prefs (rating, skip). |
| PUT | /library/prefs/:vault_id/:file_id |
Update prefs. |
| POST | /play-events |
Append play/skip events; drives play counts. |
| POST | /library/radio |
Build a radio queue from a seed (?only_local=true). |
Network + nodes
| Method | Path | Notes |
|---|---|---|
| GET | /nodes |
Every peer seen via gossip — display name, country, caps, vault list. |
| GET | /nodes/self |
Same shape, just for this daemon. Useful when listing isn't. |
| GET | /nodes/:node_id |
One node's detail. |
| GET | /network/topology |
Gossip-aggregated mesh view (nodes + edges + uptime). |
| POST | /network/speedtest/:peer_id |
Run a latency + throughput probe against a peer. |
| GET | /network/speedtest/:peer_id/history |
Last N probe results. |
| GET | /stats/throughput |
10-minute ring (10 s buckets) of in/out byte rates. |
Rooms (chat)
room_id is the deterministic hash of the chat key + initial members.
| Method | Path | Notes |
|---|---|---|
| GET | /rooms |
All joined rooms with unread counts. |
| GET | /rooms/:room_id/messages?before=&limit= |
Paginated history, newest first. |
| POST | /rooms/:room_id/messages |
Send {"text":"…"}. |
| POST | /rooms/:room_id/messages/file |
Multipart upload of an attachment. |
| POST | /rooms/:room_id/messages/location |
Push lat/lng + label. |
| GET | /rooms/:room_id/files/:file_id/blob |
Download a chat attachment. |
| POST | /rooms/:room_id/read |
Mark up to {ts_ms} as read. |
| GET | /rooms/:room_id/stream |
WebSocket — live message firehose for one room. |
Auto-relay roster (gateway role)
These let an operator manage the rooms this node relays for others.
| Method | Path | Notes |
|---|---|---|
| GET | /relay/rooms |
Rooms this node holds in chatcache. |
| POST | /relay/rooms |
Add a room (body {"room_id"}). |
| DELETE | /relay/rooms/:room_id |
Drop a room from the cache. |
| GET | /relay/rooms/:room_id/stats |
Message count, last activity, byte size. |
Calls
Audio/video calls travel over libp2p but are signalled here. The
/calls/{id}/media WebSocket carries the actual media frames.
| Method | Path | Notes |
|---|---|---|
| GET | /calls |
Ongoing + recent calls. |
| GET | /calls/:call_id |
One call's detail. |
| POST | /calls |
Place a call ({"peer_id","kind":"audio"|"video"}). |
| POST | /calls/:call_id/accept |
Pick up. |
| POST | /calls/:call_id/reject |
Decline. |
| POST | /calls/:call_id/hangup |
End an active call. |
| POST | /calls/:call_id/mute |
{"mic": bool, "cam": bool}. |
| POST | /calls/:call_id/camera |
Drive the peer's camera: {"action":"next\|prev\|specific","device_id"} to switch input, or {"action":"zoom","zoom","pan_x","pan_y"} for absolute digital zoom (≤10×) + crop-window pan. Mgmt-key camera-cap gated. |
| POST | /calls/:call_id/remote-mute |
Tell the other side to mute (mgmt-key gated). |
| POST | /calls/:call_id/stats |
Push WebRTC-style stats from the client. |
| GET | /calls/:call_id/media |
WebSocket — opus/h264 frame transport. |
| GET | /calls/events |
SSE — call lifecycle (ring, accept, end). |
| GET | /calls/auto-answer-log |
Recent auto-answered calls (Pi-camera bridge). |
Tunnels, port forwards, system VPN
These three share the underlying multi-hop tunnel substrate; the surface above is different.
Tunnels (HTTP CONNECT proxy)
| Method | Path | Notes |
|---|---|---|
| GET | /tunnels |
Open circuits, with byte counters. |
| POST | /tunnels |
Open one ({"exit_node_id","peer_mgmt_key_id"}). |
| DELETE | /tunnels/:circuit_id |
Tear one down. |
| GET | /tunnels/proxy-info |
The local HTTP CONNECT proxy address (host:port). |
Port forwards (ssh -L / ssh -R style)
| Method | Path | Notes |
|---|---|---|
| GET | /forwards |
Configured forwards + live state (listening, error, byte cnt). |
| POST | /forwards |
Create. Body mirrors port_forwards[] in config.yaml. |
| PATCH | /forwards/:id |
Edit name / listen / remote / route. Persisted to disk. |
| DELETE | /forwards/:id |
Stop and remove. |
| POST | /forwards/:id/start |
Bring up if autostart is false. |
| POST | /forwards/:id/stop |
Pause without deleting. |
Each entry on GET /forwards returns these fields. New flags
shipped with the UPnP rollout:
open_via_upnp(bool, settable on POST/PATCH) — request a router-side port mapping for the listener (this node's router forforward, the peer's forreverse). Best-effort.external_addr(string, read-only) — populated when UPnP succeeded. Forward direction reads it from the localupnp.Manager; reverse reads it from the exit'sTunnelListenAck.external_addr. Empty when the mapping wasn't requested or didn't take.
System VPN
The fd-bound start path is JNI-only (Android VpnService delivers a TUN
fd). The REST surface here covers stop/stats plus the desktop-Windows
start that uses the helper service internally.
| Method | Path | Notes |
|---|---|---|
| POST | /vpn |
Start the system VPN (Windows/Linux). Body {"exit_node_id","peer_mgmt_key_id","exit_name"}; all optional — empty brings up a mesh-only session. On Android the platform VpnService owns the fd; macOS returns 501. |
| GET | /vpn |
Status JSON: {running, supported, circuit_id, exit_peer, default_route, policy_exits[], bytes_up/down, …}. policy_exits[] carries per-exit health (phase, attempts, bytes) for a multi-exit session. |
| DELETE | /vpn |
Stop. |
Exit routing (split-tunnel & multi-exit)
The routing model behind the system VPN — Exit
routing — is fully
REST-driven (the Exit nodes section is a thin client over these). Changes
persist to the vpn: config block and
apply live to a running session (no reconnect).
| Method | Path | Notes |
|---|---|---|
| GET | /vpn/config |
The editable block: enabled, exit, fail_mode, default_route, auto_local_subnets, disabled_auto_subnets. |
| PUT | /vpn/config |
Update those fields (validated; local_routes + exit_policy preserved). |
| GET | /vpn/routes |
Kept-local routes (config + runtime). |
| POST | /vpn/routes |
Add a runtime kept-local route ({"cidr","note"}). |
| DELETE | /vpn/routes/:id |
Remove a runtime kept-local route. |
| GET | /vpn/routes/effective |
Categorised view: {system[], auto[], custom[]} for the panel. |
| GET | /vpn/policy |
Exit rules (config + runtime), each with prefixes, priority, state. 0.0.0.0/0 allowed (full tunnel). |
| POST | /vpn/policy |
Add a runtime rule ({"prefixes":[…],"exit_node_id","peer_key_id","priority","name","note"}). |
| PATCH | /vpn/policy/:id |
Set a rule's state (permanent/temporary/off) and optional priority. |
| DELETE | /vpn/policy/:id |
Remove a runtime rule. |
| GET | /vpn/qos |
Egress / download QoS policy (enabled, uplink_kbits, downlink_kbits, classes[], rules[]). |
| PUT | /vpn/qos |
Replace the QoS policy (applied live; rates in kilobits/s). See QoS. |
| GET | /vpn/qos/presets |
Built-in QoS presets (class/rule structures to apply, keeping your rates). |
Management keys
Mgmt keys gate operations that the swarm-PSK-alone shouldn't authorise: inbound tunnels, call auto-answer, remote mute.
| Method | Path | Notes |
|---|---|---|
| GET | /mgmt-keys/self |
"My keys" — what I hand to peers. |
| POST | /mgmt-keys/self |
Mint a key with {caps: ["tunnel","camera"], expires_at_ms}. Optional network_id files it under a saved network (default: the active one). A self key's network_id scopes verification: it authorises only while the node is on that network; empty = global. |
| PATCH | /mgmt-keys/self/:id |
Rename / change caps / extend expiry / move network_id (its verification scope). |
| DELETE | /mgmt-keys/self/:id |
Revoke. Active circuits drop. |
| GET | /mgmt-keys/peers |
Keys other peers have given me. |
| POST | /mgmt-keys/peers |
Save a peer-issued key (paste from QR). Optional network_id. |
| PATCH | /mgmt-keys/peers/:id |
Rename, and move network_id — caps come from the issuer. |
| DELETE | /mgmt-keys/peers/:id |
Forget. |
| GET | /mgmt-keys/capabilities |
Catalogue of known capability strings (for the UI dropdown). |
S3 admin
The S3 gateway has its own access-key store, independent of the Web UI bearer.
| Method | Path | Notes |
|---|---|---|
| GET | /s3/keys |
List access-key IDs (secret is returned only at creation). |
| POST | /s3/keys |
Mint {access_key_id, secret_access_key}. |
| DELETE | /s3/keys/:key_id |
Revoke. |
| GET | /s3/permissions |
Grants (key_id, bucket, ops). |
| PUT | /s3/permissions |
Set {key_id, bucket, ops: ["read","write","delete"]}. |
| DELETE | /s3/permissions |
Remove a grant (same body shape minus ops). |
The endpoints stay available even when the S3 listener is off, so an
operator can pre-provision keys before flipping node.s3.enabled.
Profile, contacts, settings
| Method | Path | Notes |
|---|---|---|
| GET | /profile |
Display name, avatar, metered status. |
| PUT | /profile |
Update. The hello broadcaster re-emits the changed fields. |
| GET | /contacts |
Curated favourites list. |
| POST | /contacts / PUT .../:node_id |
Upsert. |
| GET | /contacts/:node_id |
One contact. |
| DELETE | /contacts/:node_id |
Remove. |
| GET | /settings |
Runtime settings the UI can edit (enrichment API keys, etc.). |
| PUT | /settings |
Update. Enrichment workers restart so new API keys take effect. |
Inbound webhooks
Configured in config.yaml (webhooks: [{token, room_id}, …]) — no
runtime CRUD. The endpoint accepts Slack/Mattermost/Discord-shape bodies
and posts to the mapped chat room.
| Method | Path | Notes |
|---|---|---|
| POST | /hooks/:token |
Forwards body into the configured room. Bypasses bearer auth. |
| GET | /hooks/:token |
Probe — returns {ok: true} if the token resolves. |
A missing or unknown token returns 404, not 401 — there's no
information leak about which tokens exist.
Events (SSE)
curl -N -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/v1/events/stream
: connected
event: chat_message
data: {"type":"chat_message","room_id":"…","sender_node_id":"12D…",
"ts_ms":1716549300000,"message_type":"TEXT","content_hash":"…","is_self":false}
: keepalive
20 s : keepalive comments keep reverse proxies from reaping idle
connections. The wire envelope is the same eventEnvelope shape for
every event type — switch on the type field rather than the SSE
event: line if you only care about the JSON.
Audit
| Method | Path | Notes |
|---|---|---|
| GET | /audit?since=&types= |
JSON list of audit events. Backed by a bounded on-disk ring. |
| GET | /audit/export |
Streamed JSON-Lines for archival. Accepts ?token=. |
AI agents
The full "universal AI-agent" surface — Claude Code, OpenCode, Gemini CLI. It's the largest single subsystem in the API; consult the dedicated developer scenario for an end-to-end walkthrough.
Instance lifecycle:
| Method | Path | Notes |
|---|---|---|
| GET | /capabilities |
Which agent binaries are installed on this host. |
| GET | /instances |
All agent instances on this node. |
| POST | /instances |
Create ({"type":"claude"|"opencode"|"gemini", "name":"…"}). |
| GET | /instances/:id |
Detail. |
| PATCH | /instances/:id |
Rename / change defaults. |
| DELETE | /instances/:id |
Stop and remove the per-instance config dir. |
| POST | /instances/:id/refresh-auth |
Re-probe auth status. |
| POST | /instances/:id/auth-login |
Trigger the browser OAuth flow on the daemon host. |
| GET | /instances/:id/auth-methods |
What's available for this agent type. |
| GET | /instances/:id/providers |
Configured LLM providers. |
| PUT | /instances/:id/providers/:provider |
Set an API key. |
| DELETE | /instances/:id/providers/:provider |
Drop the key. |
| POST | /instances/:id/providers/:provider/oauth/authorize |
Start an OAuth provider flow. |
| POST | /instances/:id/providers/:provider/oauth/callback |
Finish it. |
| GET | /instances/:id/models |
Models the configured providers expose. |
| GET | /instances/:id/mcp |
Configured MCP servers. |
| POST | /instances/:id/mcp |
Add one. |
| DELETE | /instances/:id/mcp/:name |
Remove. |
| POST | /instances/:id/mcp/:name/oauth |
Start MCP-server OAuth. |
| DELETE | /instances/:id/mcp/:name/oauth |
Drop the cached MCP token. |
| POST | /instances/:id/workspaces |
Register a workspace folder for Code-mode. |
| DELETE | /instances/:id/workspaces |
Unregister. |
| GET | /instances/:id/share-url |
Get the meshhold://join/agent/… URL. |
| POST | /instances/:id/reset-key |
Rotate access key (force-kicks every joined device). |
| POST | /instances/remote |
Add an instance hosted on another node by share URL. |
| GET | /instances/:id/search?q=… |
Search transcripts across all sessions. |
| POST | /instances/:id/attachments |
Upload a chat attachment. |
Sessions:
| Method | Path | Notes |
|---|---|---|
| GET | /instances/:id/sessions |
List. |
| POST | /instances/:id/sessions |
Create. |
| GET | /sessions/:sid |
Detail. |
| GET | /sessions/:sid/transcript |
Full message history. |
| GET | /sessions/:sid/events |
SSE — live agent stream-json fan-out. Accepts ?token=. |
| GET | /sessions/:sid/files |
List / download workspace output files. Accepts ?token=. |
| POST | /sessions/:sid/messages |
Send a user turn. |
| POST | /sessions/:sid/stop |
Cancel the current run. |
| POST | /sessions/:sid/archive / .../unarchive |
Hide / unhide. |
| POST | /sessions/:sid/resume |
Re-attach to a previously-paused session. |
| POST | /sessions/:sid/fork |
Branch from a transcript point. |
| DELETE | /sessions/:sid |
Drop entirely. |
| PATCH | /sessions/:sid/settings |
Tweak per-session model / system prompt. |
| GET | /sessions/:sid/approvals |
Pending tool-call approvals. |
| POST | /sessions/:sid/approvals/:aid |
{"decision":"allow"|"deny"}. |
| POST | /sessions/:sid/questions/:qid/reply |
Answer an interactive /question. |
| POST | /sessions/:sid/questions/:qid/reject |
Skip. |
POST /_mcp/sessions/:sid/approve is the loopback hook the daemon-spawned
meshhold _mcp-approve helper calls — not a user-facing endpoint.
Diagnostics
For the Profile page's "Report a problem" button.
| Method | Path | Notes |
|---|---|---|
| POST | /diagnostics/reports |
Build a redacted bundle (config + logs + system state). |
| GET | /diagnostics/reports |
List saved reports. |
| GET | /diagnostics/reports/:id |
Download the .zip. Accepts ?token=. |
| DELETE | /diagnostics/reports/:id |
Remove from disk. |
503 when the daemon has no writable reports dir (headless install without
$HOME).
Status codes
| Code | Meaning |
|---|---|
200 |
OK. |
204 |
Success with no body (logout, mutations that don't echo). |
400 |
Bad request — malformed JSON, missing required field, invalid path. |
401 |
Missing / invalid / expired bearer. |
403 |
Vault locked or operation not permitted by the held mgmt-key caps. |
404 |
Unknown vault / node / room / forward / session / hook token. |
409 |
Conflict — vault name collision, room already exists. |
500 |
Internal error. The body's error field has the message; the daemon log has more. |
501 |
Not implemented on this platform (e.g. POST /vpn on macOS, or on Android where the platform VpnService owns the fd). |
503 |
Daemon in limited mode — swarm key missing or P2P stack down. Retry after setup. |
507 |
Vault hard quota exceeded. Body carries vault_id, hard_quota, would_be_size. |