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.

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

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

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_ms is absent rather than falling back to modified_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_read separates "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. pending counts the second kind.
  • has_gps arrives even when lat/lon do 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 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 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)

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