Reference

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.

REST surface map β€” six themes around the base URL

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.

Three token-acquisition paths converging on the Bearer header

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.

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>

Conventions

  • All payloads are JSON unless stated otherwise. Content-Type: application/json is 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 (*path routes) are URL-encoded; a leading / is required.
  • Errors always look like {"error":"message"} with a non-2xx status. 507 Insufficient Storage is reserved for vault hard-quota hits and carries vault_id / hard_quota / would_be_size alongside 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.
PATCH /vaults/:vault_id Mutate name / RF / hard quota / role flags.
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/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.
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.

Upload pipeline β€” chunked, encrypted, stored, then asynchronously replicated to peers

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>.

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 Switch front/back camera (Android only).
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 for forward, the peer's for reverse). Best-effort.
  • external_addr (string, read-only) β€” populated when UPnP succeeded. Forward direction reads it from the local upnp.Manager; reverse reads it from the exit's TunnelListenAck.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 ({"exit_node_id","peer_mgmt_key_id","exit_name"}). 501 on Linux/macOS.
GET /vpn {running, stats}. stats is a free-form human string.
DELETE /vpn Stop.

Management keys

Mgmt keys gate operations that the swarm-PSK-alone shouldn't authorise: inbound tunnels, call auto-answer, remote mute, push-gateway delivery.

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}.
PATCH /mgmt-keys/self/:id Rename / change caps / extend expiry.
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).
PATCH /mgmt-keys/peers/:id Rename only β€” 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, push endpoint, metered status.
PUT /profile Update. The hello broadcaster re-emits the changed fields.
POST /profile/push-test Send a no-op POST to the saved push endpoint to verify it works.
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)

SSE stream lifecycle β€” connect, events, heartbeats over time

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, etc. 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", "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 Linux).
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.