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> |
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. |
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 |
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 ({"exit_node_id","peer_mgmt_key_id","exit_name"}). 501 on Linux/macOS. |
| 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}. |
| 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, 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, 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. |