HDTP Gateway: the self-hosted node: install, run, operate, and its owner MCP tools
# HDTP Gateway
> The self-hosted HDTP node: one Go binary that runs a personal, permission-gated MCP server.
## The problem [Section titled “The problem”](#the-problem) Two people each have an assistant. Today those assistants cannot talk to each other unless both people chose the same product — and then that product sits in the middle of every word. The fix is not another chat app. It is what email already proved: **everyone runs their own endpoint, and addresses are portable.** HDTP is that idea for agents, and `hdtp-gateway` is the endpoint you run.
```mermaid
graph LR
AA["Alice's agent"] -->|"owner MCP
bearer token"| AN["Alice's node"]
AN -->|"mTLS + sealed envelope"| BN["Bob's node"]
BN -->|"owner MCP"| BA["Bob's agent"]
AN -.->|"vCard: endpoint + key"| BN
```
No server in that picture has to be trusted by both parties. Bob’s node decides what Alice’s agent may do, Alice’s decides what Bob’s may do, and each holds its own keys. ## What makes it different [Section titled “What makes it different”](#what-makes-it-different) * **You are a keypair, not an account.** Your identity is the SHA-256 of your public key. Nothing to sign up for, no username, no provider to be locked out of. * **Every capability is an MCP tool behind a per-contact switchboard.** A close friend can book time. A stranger who redeemed a one-time invite can send one message and discover nothing else — `tools/list` is filtered per caller. * **Sealed end to end.** Payloads are HPKE-sealed and signed, so tunnels and edges carry ciphertext. They learn which key a message is for and when — never who sent it, and never what it says. * **It works from behind CGNAT.** Direct, Tailscale, frp, ngrok, Cloudflare, or your own domain. With no inbound path at all you need one of those tunnels — or a host: there is no store-and-forward relay, because one would see every sender, recipient and timestamp for its trouble (HDTP §9). * **One binary, SQLite by default.** No cluster, no broker, no queue. Postgres when you want it. ## What a contact actually is [Section titled “What a contact actually is”](#what-a-contact-actually-is) A **vCard** — the format your phone already understands, plus two fields. Sharing your agent’s address is sharing a contact.  `X-HDTP-CERT` is one certificate — the **leaf** your own root issued this host — and it carries everything: where to reach you, the key to seal to, the fingerprint of the root that is your identity, and how long it is good for. What a contact pins is that root, never the host’s key, which is what lets you change hosts without changing who you are. `X-HDTP-SEAL` says whether to seal. That is the whole address book.
# Add someone who invited you
> Mint a token for your agent, over the admin socket:
Mint a token for your agent, over the admin socket:
```plaintext
hdtp-gateway token create -owner -label "my agent"
```
Then, from your agent on the owner MCP:
```json
{"name": "add_contact",
"arguments": {"account_id": "…", "invite_url": "https://their.example/i/abc123"}}
```
Your node fetches their card, checks that the key hashes to the fingerprint the card claims, verifies the card’s signature, and only then redeems — pinning them.
# Be reachable
> Then check your work:
| You have | Use | | ----------------------- | -------------------------------------------------------------------------------------------------- | | A public IP | `direct` | | A tailnet | `tailscale`, with Funnel for the public side | | A VPS you already run | `frp`, or the built-in **ingress role** on your own domain | | Neither, but an account | `ngrok`, `cloudflare` | | No inbound path at all | a tunnel from the rows above, or let a provider host the identity under a leaf you issue (HDTP §9) | Then check your work:
```plaintext
hdtp-gateway doctor
```
It derives your deployment mode, probes your endpoint, and reports what an outside caller would actually see.
# Decide what each contact may do
> Permissions are dotted and per-contact — message.text, message.media, calendar.availability, calendar.book, integration. — set from a preset or one by one.
Permissions are dotted and per-contact — `message.text`, `message.media`, `calendar.availability`, `calendar.book`, `integration.` — set from a preset or one by one. Availability answers with at most five policy-filtered slots and never your raw free/busy.
# Expose a tool from another MCP server
> Connect an upstream server (streamable-HTTP, SSE, or a supervised stdio child) and publish only the tools you choose, either passthrough or mapped onto HDTP's own vocabulary through a recipe.
Connect an upstream server (streamable-HTTP, SSE, or a supervised stdio child) and publish **only the tools you choose**, either passthrough or mapped onto HDTP’s own vocabulary through a recipe. Exposing a write-capable tool takes a recorded acknowledgment, and an upstream that changes underneath you is narrowed, never silently widened.
# Invite someone
*Invites* → **Create** gives you a `/i/` link and a QR to send however you like. An invite is server-side state: it can expire, be limited to one use, carry a permission preset, and be revoked. Nothing sensitive rides in the URL itself.
# Leave this node
> When you have moved an identity to another host, tell this one to forget it:
When you have moved an identity to another host, tell this one to forget it:
```plaintext
hdtp-gateway account leave -slug me # shows what it would erase, erases nothing
hdtp-gateway account leave -slug me -yes # erases it
```
It refuses an identity this node serves at its own address for it right now — which is what “delete it at the old host” would name after a move to another address on this same node — unless you add `-force-current`. It erases every record of the identity at once: its contacts, chats, media no other identity here uses, invites, integrations and their OAuth client credentials, tokens scoped to it, its settings, and every leaf key this node held for it. The live node stops answering for it straight away, as for an address it never served. The audit trail is the one thing the erase does not reach at once: it is append-only (by trigger) and hash-chained. HDTP §9 asks a host to “keep nothing beyond what law compels”, so the rows that name the identity by its account id — with its slug and its contacts’ fingerprints as they wrote them — stay in the live trail for `audit_archive_after` (90 days unless you set it: `HDTP_AUDIT_ARCHIVE_AFTER=30d`, or `audit_archive_after` in the config file), long enough to review the leave on the portal’s audit page. Then the hourly sweep moves every one of them to `/audit-archive/--.jsonl` (mode 0600) and writes one `audit_archive` row that names the segment and its hashes, not the identity. (Rows you had already moved to the head archive with `audit archive -through N` stay in that file.) `hdtp-gateway audit verify` (node stopped) checks the table and the archives as one chain and reports a changed or missing archive as broken. The archive is kept. If law requires its rows to go, `hdtp-gateway audit erase-archive -file ` keeps only each row’s seq and hashes, so the chain still verifies, and records that it did (SPEC §3.11, §11.6). On SQLite the leaf keys are destroyed, not only deleted: the node zeroes deleted rows (`secure_delete`) and truncates its write-ahead log after the leave. On Postgres it cannot: a deleted row stays as a dead tuple until VACUUM reuses its space, in the write-ahead log until the segment is recycled, and in every backup — sealed under the node’s keyring, but not destroyed. SPEC §3.9 names this divergence. The address stays **reserved** until the last leaf issued for it expires (HDTP §9): until then no identity can be created under that slug here, and no signing request can name that address. The command prints each address it reserved and until when. It is refused while a move campaign for that identity is running (`account announce -slug me` says when it has finished). There is no undo, and no portal or owner-MCP button: like `import`, it needs shell access on the host.
# Let your own agent run the node
> The owner MCP is a second surface, separate from the public one, with 33 tools on a running node: read the inbox, send to a contact, approve or reject requests, block, unblock and remove contacts, answer a contact waiting at a new address, create, list and revoke invites, set permissions, manage integrations, query the audit chain.
The owner MCP is a second surface, separate from the public one, with **33 tools** on a running node: read the inbox, send to a contact, approve or reject requests, block, unblock and remove contacts, answer a contact waiting at a new address, create, list and revoke invites, set permissions, manage integrations, query the audit chain. It requires named, revocable bearer tokens on every bind, loopback included.
# Manage your passkeys, and get back in if you lose them
> A passkey is the node's only login, on every bind including loopback — there is no local-access shortcut.
A passkey is the node’s only login, on **every** bind including loopback — there is no local-access shortcut. So the way back in matters. All three need the node running; they talk over the admin unix socket, whose permissions are the host’s.
```plaintext
hdtp-gateway passkey list # id, tag, owner
hdtp-gateway passkey remove -id # refuses the last one
hdtp-gateway passkey reset-wizard # a one-time link that re-opens registration
```
`reset-wizard` is the recovery path. It prints a URL valid for 24 hours and usable once:
```plaintext
one-time setup URL (24h, single use):
http://localhost:8080/setup?token=b0527dbe01e36eed2ef51a576b4a2e34
```
Open it and register a new passkey. It **adds** one and removes nothing, so a device you still have keeps working. This is the only thing that re-opens the wizard once a passkey exists — reaching loopback does not, and neither does a leftover first-run token, or any local process could quietly register itself as an owner. That makes **shell access on the host the root of trust for recovery**, which is the honest trade: there is deliberately no online recovery path, no email reset, nobody to ask. Register a second passkey on another device before you need one. > Running in Docker? Prefix it: `docker compose exec hdtp-gateway hdtp-gateway passkey reset-wizard`. And sign in at `http://localhost:`, not `127.0.0.1` — a loopback portal presents itself as `localhost` because an IP is not a valid WebAuthn relying-party ID, so that is the name your passkey is bound to.
# Take your data with you
> Your identity is the root in your wallet.
Your identity is the **root** in your wallet. It is never on this node, so nothing here *is* you. What you can take away is what is yours: your **contacts**, your **chats** and the **files** in them — one identity at a time, in one zip that the cloud and any other HDTP host read too.
```plaintext
hdtp-gateway export -slug alina -out alina.zip
hdtp-gateway import alina.zip -slug alina # shows what it would write, writes nothing
hdtp-gateway import alina.zip -slug alina -yes # writes it
```
Both are offline (stop the node first) and both need host shell access. They work on SQLite and on Postgres alike, because the file is written through the node’s own store rather than copied out of a database. **The file is not encrypted.** Anyone who gets it can read your contact list and all your conversations and files; `export` says so before it writes. It holds no keys, so it cannot be used to speak as you — no leaf’s key, not the node’s master key — and no settings, integration credentials, tokens, passkeys, invites or audit history: those belong to the host that made them. Keep it where you keep private documents, and delete it once it has been imported. **An import checks the whole file before it writes anything,** and refuses it whole at the first fault. Into a slug that is not here, the identity arrives with its root and nothing more — **not served** until your wallet issues this host a leaf; into the identity it belongs to, it merges, and every pin this host already holds stands. Either way it ends with a request for a new leaf that the import makes itself (complete it in your web wallet from the portal, or with the CLI wallet from the request it prints; `account certificate` and `doctor` keep naming it until it is done), and installing that leaf tells the imported contacts where you are now. An export reads its own file back before it reports it, and warns when the file is more than BatonDeck takes back in.
# Your wallet
> Your identity is a root, and the root lives in your wallet, never on this node.
Your identity is a **root**, and the root lives in your **wallet**, never on this node. The node holds a **leaf**: a certificate your root issues to this host, for one address, until one date (HDTP §9, §14.1). The node makes the request, the wallet signs it, the node installs the answer:
```plaintext
hdtp-gateway account csr -slug me > me.csr # the request: this host's key and address
hdtp id issue --vault me.hdtp-vault.json --csr me.csr --chain-out chain.pem
hdtp-gateway account install-leaf -slug me -chain chain.pem
```
The wallet a self-hoster uses is the `hdtp` CLI from hdtp-identity. `hdtp id create` makes the root once, in a vault file under a passphrase; `hdtp id issue` shows what a request names and asks before it signs. `account csr` prints only the request on its standard output (what it is for goes to standard error), and `install-leaf` takes the two certificates `--chain-out` writes, leaf then root. `account csr -slug me -purpose renew` asks for the next leaf before this one runs out, and `-purpose move` for a leaf at a new address. The root never comes to this node, and nothing here can make one: losing the vault and its passphrase is losing that identity. **A web wallet, from the portal.** Once an identity has its first leaf, *Identity → Sign with my web wallet* asks a web wallet (`HDTP_WALLET_URL`, `https://ceremony.hdtp.io` by default) for the next one: a renewal, or a move when the address changes. The page shows what it will ask and changes nothing until you continue; a request already waiting is replaced only if you confirm it. The wallet sends its answer back to this node’s `/wallet/return` in the same browser, and the portal installs it with your session. This is the node’s side of it. The wallet’s `/sign` page is BatonDeck’s and is not live yet, and how browsers treat a public page sending you back to `http://localhost` has not been measured, so until both are, use the `hdtp` CLI. **After a move.** When an install moves the identity to a new address, both the CLI and the portal say so, and say to run `hdtp-gateway account announce -slug me` until no contact is waiting: the previous certificate stays valid until its own date for contacts not yet told. When the identity came from another host, that is also when to delete it there. When this node itself moved to a new address, there is nothing to delete: the previous certificate goes on answering here until it expires.
# Operating a node
> This is the operator's page: what runs where, how the node is reached, how it is backed up, and what to do when something is lost.
This is the operator’s page: what runs where, how the node is reached, how it is backed up, and what to do when something is lost. Design rationale lives in `SPEC.md`; this page only tells you what to run. ## Layout [Section titled “Layout”](#layout) | Path (in `data_dir`, `/data` in the container) | What | | ---------------------------------------------- | ---------------------------------------------------------------------------------- | | `hdtp.db` | the SQLite store: accounts, contacts, messages, integrations, audit chain | | `blobs/` | content-addressed media | | `keyring.key` | the keyring master key (0600). **Every account’s private key is sealed under it.** | | `hdtp.lock` | held while `serve` runs; offline commands refuse to run while it is held | | `admin.sock` | the admin socket the CLI talks to while the node is running | Configuration: env `HDTP_*` > `config.json` > defaults (`hdtp-gateway doctor` prints the resolved result). The deployment **mode is derived** from the tunnel adapter, never declared (SPEC §10.1). ## Reachability [Section titled “Reachability”](#reachability) | You have | Use | Mode | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | a public port / static IP / VPS | `tunnel: direct` (default) | direct | | a Tailscale account | `tunnel: tailscale` — tsnet Funnel, free, ports 443/8443/10000 | direct | | an `frps` you run on any VPS | `tunnel: frp` — raw SNI/tcp passthrough | direct | | a paid ngrok plan | `tunnel: ngrok` — `tls://` endpoint | direct | | a Cloudflare account | `cloudflare` edge adapter (P4-05) — Cloudflare terminates TLS | edge | | a domain of your own | run a second hdtp-gateway in the **ingress role** on a VPS (below) | direct (passthrough) or edge (terminate) | | nothing inbound at all | one of the tunnels above, or a provider hosting the identity under a leaf you issue (HDTP §9). There is no relay role: it went on 2026-09-18, because a store-and-forward gateway sees every sender, recipient and timestamp | — | Direct mode keeps mTLS end to end: callers’ client certificates reach the node. Edge mode cannot — a third party terminates TLS — so the node **forces** `seal=required` and `client_cert=off`, and callers are identified by their sealed-envelope signatures. `hdtp-gateway doctor` reports the derived mode and probes your advertised endpoint; a `wrong_cert` verdict means something between the caller and the node is terminating TLS that should not be. Every carrier still sees metadata (who talks to whom, sizes, timing). Where that is itself sensitive, use `direct` on infrastructure you control (SPEC §13). ### Ingress role (own domain) [Section titled “Ingress role (own domain)”](#ingress-role-own-domain) On the VPS run the ingress; on the node pair with a one-time token: 1. ingress: `hdtp-gateway ingress serve --domain example.com` (P5-05 wires the command; the library is `internal/ingress`) and mint a token from its portal. 2. node: portal → *Settings → Ingress* — paste the token, pick the subdomain and mode: * **passthrough** — the ingress routes on SNI and forwards raw TLS; your node’s own certificate is what callers see (direct mode). * **terminate** — the ingress holds an ACME certificate, terminates the public TLS session, and re-originates a mutually-pinned mTLS leg to your node (edge mode for your node). Both keys are pinned at pairing; nothing else can deliver traffic. 3. The node connects **outbound** (embedded frp client) — no inbound port at home. ## Call budgets [Section titled “Call budgets”](#call-budgets) Every call counts against a budget of the account it is addressed to, sized by how many contacts that account may hold (HDTP §12). The budgets are not the node’s to decide: the **limits sidecar**, `hdtp-limitd`, holds their numbers and their counters and decides each call with hdtp-identity’s `hdtp-limits` crate, the decision the hosted cloud makes (SPEC §5.7). The node asks it over a unix socket, `limits_socket` (`HDTP_LIMITS_SOCKET`, default `/limits.sock`), on one connection it keeps open; nothing but the charge — the account, the caller’s root and tier, its address, the contact cap — crosses it. Its numbers are its configuration file, the rules document of hdtp-identity’s contract, shipped as `deploy/limitd/limits.json` (in the image at `/etc/hdtp-limitd/limits.json`): | Member | Budget | Keyed by | | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------- | | `contact_calls_per_second`, `contact_burst` | a contact | account, contact root | | `identity_capacity_per_second` | every contact together: `limit.contacts` × the contact rate, burst one second of it, at most this | account | | `guest_calls_per_hour` | a guest (a proven root that is not a contact) | account, root, address | | `guest_source_calls_per_hour` | an address alone (nothing proven) | account, address | | `stranger_calls_out_per_hour` | calls OUT to strangers (`request_contact`, `redeem_invite`, the answers to a request) | account | | `integration_calls_per_hour` | one integration’s tools, per contact | account, integration, contact | | `pending_in_cap` | requests waiting on the owner that strangers may write | account | | `guest_total_calls_per_hour` | every caller the open does not prove a contact, together (below) | account | **`guest_total_calls_per_hour` is checked before the envelope is opened** (the owner’s decision of 2026-09-29). Nothing unopened says who sent a sealed call, so the node asks the sidecar first, before it reads a key: while the account’s total holds a call, every sealed call goes on to be opened; once it holds none, only a call from a known address does — one that carried an active or pending_out contact’s call to the account in the last hour, which the sidecar remembers — and everything else is refused `rate_limited`, in the clear, with the total’s `retry_after`. Asking spends nothing. The total is spent after the open by every call that is opened and does not prove an active or pending_out contact: a guest, a blocked or superseded root, a root whose only row is the request it left, a small form naming a leaf nobody pinned, and an `envelope_invalid` or `certificate_renewed` the open found. A proven contact never spends it; a plaintext call opens nothing and never spends it. The known cost: during a flood, a contact calling from an address it has not used in the last hour is refused before the open, as a stranger is, with `retry_after`, until the total refills. The known addresses live in the sidecar’s memory beside the counters, an hour each and a bounded number an account (the oldest going first: `KNOWN_SOURCE_TTL_MS` and `KNOWN_SOURCES_CAP`, cmd/hdtp-limitd/src/lib.rs), so a sidecar restart forgets them too. A known address is an address: every caller arriving from it shares its standing. Behind a carrier that delivers every caller from one address of its own — `frp`, `ngrok` and `tailscale` from the node’s host, a terminate-mode ingress from its own, none of which names the client’s address — one contact’s call makes that one address known, and the check before the open lets every stranger through to be opened (and refused after it). The check does its work where each caller arrives with an address of its own: behind Envoy (below), or the `cloudflare` adapter. (`TestAStrangerFloodDrainsTheTotalThenIsRefusedBeforeTheOpenAndAKnownContactGetsThrough` shows a stranger at the contact’s known address opened and refused.) Calls out to a contact spend that contact’s rate and the account’s aggregate, in buckets of their own. A refusal is a `rate_limited` tool error carrying `retry_after` — the whole seconds until the bucket holds a call again — and an audited `rate_limited` row naming the bucket, not a dropped connection, so the caller’s agent can read it and back off. A call out that is refused never leaves the node. A request past `pending_in_cap` is answered `unavailable`: no wait empties a list only the owner can. **The shipped `identity_capacity_per_second` is measured, and it is below what 500 contacts ask for.** One node on an Apple M2 Max served sealed `send_message` from 500 contacts at up to 280 calls a second in every run, and broke between 300 and 450 a second from run to run (`TestMeasureAccountCapacity`, internal/node; the method and the numbers are in its file), and the shipped figure is the lowest knee with a margin. The figure is the node’s, and every identity on the node shares it. **Changing a number** is editing the file and restarting the sidecar, which reads it once and refuses one it cannot enforce (a rate of zero, a burst under one call, a contact bucket that takes longer than the hour an idle row is kept to refill), saying which member and why. The node needs no restart: `get_card` asks the sidecar for the numbers it advertises on every call. **When the sidecar is down, the node refuses.** Every sealed call, call out, request and integration call is answered `unavailable` until it answers again, which the node notices by itself. `/healthz` answers 503 and names the socket, so the container’s healthcheck fails; `hdtp-gateway doctor` prints `FAIL limits` with the reason, and the `serve` banner says `limits: NOT ANSWERING`. Start the sidecar (`hdtp-limitd -config `; the compose file runs it) and the node serves again with no restart. The counters live in the sidecar’s memory, one set for every node process on the host. A restart of the sidecar refills every bucket, which is the trade-off for not writing to a database on every call — the budget is there to blunt abuse, not to meter usage, and an attacker who can restart your sidecar has already won. Memory is bounded: a bucket idle for an hour is full whatever it budgets, so it is dropped (checked once a minute), and a caller cycling addresses or fingerprints cannot grow the table past who called in the last hour. The one budget setting that is the node’s is how many contacts each identity may hold, which sizes the aggregate: | Setting | Environment | Default | Meaning | | ---------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | | `limit.contacts` | `HDTP_LIMIT_CONTACTS` | 500 | contacts each identity may hold — active contacts plus the requests it sent — and the size of its call budget | It is an ordinary knob: the portal’s Settings page edits it under Security, the config file carries it as `limit_contacts`, and the environment pins it above both (SPEC §12.2), in which case the page shows it locked and says why. It is read per use, so a change takes effect with no restart. Empty, zero or unparseable restores 500 rather than removing the cap. The cap is enforced wherever a contact is added — approving a request, unblocking a contact, accepting somebody’s invite, sending a request, a peer redeeming an auto-accept invite, approving a contact at a new address, and an import — and nothing already held is removed when it is lowered. ## Connection bounds [Section titled “Connection bounds”](#connection-bounds) The public listener holds at most 1,024 connections open (SPEC §5.7); one more is closed before its TLS handshake, and the audit trail gets one `listener_full` row a minute while that continues, with how many there were. Requests have 10 s for their headers, 60 s in all, 75 s for the answer, and an idle connection is kept 120 s. Rate limits per address or for the whole node are not the node’s: put them where the traffic arrives — the edge, or a proxy in front of the node, such as the one below. ## Behind Envoy [Section titled “Behind Envoy”](#behind-envoy) `deploy/envoy/` is the node behind a proxy of its own, the first of the two layers of its rate limits (the second is the sidecar above): `docker compose -f deploy/envoy/compose.yaml up -d` runs Envoy, the node and the limits sidecar, and only Envoy publishes a port. Before it: `make identity-proxy limitd-vendor` (the image build), a certificate for the node’s public name at `deploy/envoy/tls/cert.pem` and `key.pem`, and `HDTP_PUBLIC_URL`, that name, in the environment. What Envoy does (`deploy/envoy/envoy.yaml`, whose numbers are its own and nowhere else): * terminates the caller’s TLS with that certificate, which is what a caller now sees — WebPKI for the node’s name, as behind a terminating edge — and asks for the caller’s certificate chain, accepting any, since there is no authority above the person; * limits each source address per path — the MCP endpoints, the invite landing, everything else, each with a bucket of that address’s own — and answers 429 past it, before the node sees the request; and holds the listener’s connection cap and timeouts; * forwards the caller’s chain in `X-Forwarded-Client-Cert` and the address its socket saw in `X-HDTP-Client-Address`, replacing whatever the caller sent in either. The node reads those two headers only from Envoy’s address, `proxy_address` (`HDTP_PROXY_ADDRESS`, an IP; the compose file gives Envoy a fixed one on its network and the node that one). From anywhere else they are a caller’s own claim and prove nothing, and with no `proxy_address` they are never read. The node still opens every envelope: Envoy sees the MCP requests, never what a sealed one carries. `internal/integrationtest/envoy_test.go` holds the two files to what the node relies on, on every commit, and the harness’s S23 runs them with the image and floods them, with a control. ## The store, when it is large [Section titled “The store, when it is large”](#the-store-when-it-is-large) Nothing here needs setting. It is written down so that what the node does to its database is not a surprise, and so that the one knob that exists is findable. * **SQLite** (the default) is opened in WAL mode with `synchronous=FULL`, write transactions that take their lock at `BEGIN`, and a pool of four connections. Each was measured against a store of a million messages and the reasons are beside the code (`internal/core/store/sqlite.go`). `synchronous=FULL` is the one that costs speed on purpose: a commit here is a message a peer was told was delivered, and it is not given up to a power cut for a faster write. * **Postgres** (`store_engine: postgres`) uses pgx’s pool with its default size, the larger of four and the number of CPUs. The DSN is where it changes: append `pool_max_conns=16` (and `pool_min_conns`, `pool_max_conn_lifetime`) to `postgres_dsn`. * **Every hour** the node removes what has outlived its own window, whatever retention an account has set: idempotency records of sealed calls, which a node would otherwise keep one of for every call it ever took, owner sessions nobody came back to, and change-log rows older than a week. With a retention window set it also removes the messages, threads and media past it. * **Every statement the store can run is checked at build time** to have an index on any table that grows (`TestEveryQueryHasAPlan`, both engines). To see the numbers on your own hardware:
```plaintext
HDTP_SCALE_DB=/tmp/hdtp-scale.db go test ./internal/core/store/ -run '^$' -bench '^BenchmarkScale' -benchtime 20x
```
The first run seeds the file — 10,000 contacts, a million messages, a million audit rows — and takes about a minute. `make scale` runs these benchmarks over a scratch file, and times an import of 40,000 threads against one of 10,000 (`HDTP_EXPORT_SCALE`), three rounds interleaved, failing if the best of three takes more than 6 times as long for 4 times the threads, in reading or in writing (linear is 4); the pre-push hook runs `make scale` last, after every other step. ## More than one process [Section titled “More than one process”](#more-than-one-process) Several `serve` processes can share one store, each with its own `internal_bind` and `public_bind` behind whatever balances between them (SPEC §11.1): * **SQLite:** on one host, all with the same `data_dir`. Not across hosts, and not on a network filesystem: SQLite’s locking does not hold there. One limit of this profile: `Scrub`, the checkpoint that clears the write-ahead log after a leaf key is destroyed, cannot finish while another process is reading the file. It says so — a warning on an install or a signing request, an error on a retirement or a leave — and the next scrub that finishes clears the log; until then the destroyed key’s bytes may remain in it. Postgres has no scrub at all (SPEC §3.9). * **Postgres:** on any hosts, each with a `data_dir` of its own and the same `postgres_dsn`. Give every host the same master key in `HDTP_MASTER_KEY` (a host that generates a `keyring.key` of its own cannot open a key another sealed), and point `blob_dir` (`HDTP_BLOB_DIR`) at storage every host mounts, or media stored through one host is missing on the others. The first `serve` on an idle data dir migrates; the others check that the schema is the one they were built for and refuse to start if it is not. To migrate, stop every process on the data dir (on Postgres, every process) and start the new binary. `migrate`, `export`, `import` and the `audit` commands refuse to run while any `serve` holds the data dir. One process serves the admin socket and `serve` prints `admin: ... is served by another hdtp-gateway process` on the others; the setup URL a first run prints works on the portal of the process that printed it. The outbound retries and the hourly retention pass run on one process at a time: the one holding the work’s lease in the store, renewed every 10 s; a holder that stops lets it go at once, and one that crashes is replaced within 30 s. The call budgets are shared by every process on a host: they are the limits sidecar’s, and every process names the same `limits_socket`. A unix socket does not cross hosts, so on Postgres each host runs a sidecar of its own, and each host grants the whole budget. What each process still keeps to itself, and so what is not yet shared between them: * integrations: every process connects each one itself, so a stdio integration runs a child per process. An OAuth token is one for them all: the store holds it, and an expired one is refreshed by the one process holding that integration’s refresh lease while the others wait for it. ## Export and import [Section titled “Export and import”](#export-and-import)
```plaintext
hdtp-gateway export --config config.json --slug alina --out alina.zip
hdtp-gateway import alina.zip --config config.json --slug alina # review: writes nothing
hdtp-gateway import alina.zip --config config.json --slug alina --yes # writes it
```
Both are **offline** commands: stop the node first (they refuse while `hdtp.lock` is held). **An export is one identity’s contacts, chats and files, and nothing else** (SPEC §3.10, HDTP §9.2): one unencrypted zip of `manifest.json`, `contacts.csv`, `threads.csv`, `messages.jsonl` and `media/`, the same format the cloud and the `hdtp` CLI read and write. `export` says, before it writes, that the file is not encrypted: anyone who gets it can read the contact list and every conversation and file, though it holds no key and cannot be used to speak as anyone. The file is created `0600` and never replaces an existing one. A stranger’s request that was never accepted, and its conversation, stay behind. What is **not** in it, because it is the host’s and not the person’s: every key, saved settings, integration credentials, owners, passkeys, sessions, tokens, invites, the audit chain, the ledger of leaves, and a contact’s preset, trust flag and card. There is no flag that adds any of them. `import` checks the WHOLE file before it writes anything, and refuses it whole at the first fault, naming the member, row or line. Without `--yes` it shows what it would write and stops. Into a slug that is not here it creates the identity keyless — its root and nothing more; into the slug the file belongs to it merges, keeping every pin this host already holds. The rows go in under one transaction, the files after it. An undelivered outbound message arrives as `failed`: delivering it was the old host’s job, under the old host’s leaf. Every import ends the same way: a request for a new leaf, which the import mints itself — `move` for an identity that arrived, `renew` for one that was here — and prints with how to complete it (the portal’s `/identity//wallet` for the web wallet, or the request itself for the CLI wallet). Installing the leaf sends every imported contact this host’s handshake (`account announce` reports it). With no `public_url` set there is no address to ask for, and it names `account csr` instead. This is not a backup of the node, and the node has none: what a host accumulates beyond contacts and chats is rebuilt, not restored. After a lost machine: `import`, the setup wizard for a passkey, reconnect integrations, one certificate per identity. ## Recovery [Section titled “Recovery”](#recovery) | Lost | Consequence | Do | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | the node, with an export per identity | identities are not served until re-certified; settings, integrations, passkeys and the audit history are not in an export | `import` each, `serve`, the setup wizard; then one `account csr` / `install-leaf` per identity. Contacts keep their pins | | `keyring.key` only | sealed leaf keys, saved settings and integration credentials are unreadable. **No identity is lost** — the root is in the wallet. If nothing on the node opens under the master key it was given, `serve` refuses to start and says why; if anything does, it starts and prints `NOT SERVED` for each account that does not | put the key back if it was kept anywhere, and nothing is lost. If it is gone for good: `export -slug` each identity, then `import` each into a fresh data directory — an export never needed the master key, because it never held anything sealed under it. For a single `NOT SERVED` account on a running node, a renewal alone does it: the install retires the key it cannot open and says so | | a leaf simply ran out (nobody renewed it) | the account stops being served within the hour and its key is destroyed; contacts keep their pins, and `doctor` warns before it happens | `account csr --slug me -purpose renew`, the wallet signs, `account install-leaf` | | a card or certificate on file that the identity core no longer reads (its reading got stricter with a release of the identity library: the identity core 0.4.2 refuses a character outside base64url in a card’s certificate that 0.4.1 skipped, and what intake accepted then is still on file) | the core refuses it where it reads it, and the node says so only then: a contact whose card does not read cannot be written to (`node.PeerOf` reads the card first and refuses — a refresh of that contact included), and a call from a contact whose pinned leaf does not parse, in either form, is this node’s unreadable state (`identity_state_unreadable` on the trail, `envelope_invalid` to the caller). The `serve` banner’s `store:` line and `hdtp-gateway check store` read every such field first and name each that does not read — table, row, field, reason — and change nothing; `check store` exits 1 on one, and runs beside a serving node | for a contact’s card: this node cannot ask for one; the contact’s own next `update_contact` writes one that reads, or remove the contact and re-add it from a new card of theirs. For a pin’s leaf: remove the contact and re-add it, since nothing it sends can be decided against that pin. Run `hdtp-gateway check store` before and after a binary that bumps the identity library | | a contact in a state neither a pin nor a request has (the schema admits only `active`, `pending_in`, `pending_out` and `blocked` — both engines — so such a row is a hand-edited store’s or a later binary’s) | the node hands the identity core no pin for it, where the core would refuse the state as unreadable: a call from its holder is decided as a stranger’s — the small form `chain_required`, the chain form a guest’s — and no effect reaches the row. The `serve` banner’s `store:` line counts such rows and a `NO PIN` line names each by account, root and status; `hdtp-gateway check store` prints the same and exits 1 | remove the contact (`remove_contact` ends a relationship in any state) and re-add it from a new card of theirs | | one account’s leaf key (compromised) | the thief speaks as that host until the leaf expires or is outranked | `hdtp-gateway account csr --slug me -purpose renew`, have the wallet sign it, `account install-leaf`: the newer leaf outranks the stolen one with every contact it reaches (HDTP §14.3) | | nothing: the person moved an identity to another host | this node goes on serving it, with its key, until told | `hdtp-gateway account leave -slug me -yes` once the new host has told the contacts (without `-yes` it shows what it would erase): every record and leaf key of the identity erased, its address reserved until its last leaf expires (SPEC.md §3.11) | | the wallet’s root | the identity itself; this node cannot help | the wallet’s own recovery, if it has one (HDTP §9, §14.5) | | the audit chain shows a break | someone altered history | `audit verify` names the first bad row; treat the store as untrusted from there | No telemetry leaves the node, ever; the audit chain is yours alone.
# The owner MCP server
> The node's owner MCP: 33 tools a node's owner gives their own agent, read from the node's source.
The node serves its owner MCP at `/owner/mcp` (`internal/cli/compose.go`). Every call carries a bearer token minted over the admin socket — see [Add someone who invited you](/gateway/how-to/add-someone-who-invited-you/). What follows is every tool and resource the owner-MCP package registers: 33 tools, 3 resources and 1 resource template. Names and descriptions are read from the source. The argument schema of each tool is derived by the MCP SDK from its handler, so a running node’s `tools/list` is where to read it. ## Tools [Section titled “Tools”](#tools) | Tool | What it does | Registered when | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `add_contact` | Reach out to a peer: redeem their invite link, or request contact with a card they gave you out of band (SPEC §9). Lands `pending_out` until they accept, or `active` immediately if their invite auto-accepts. | `e.AddContact != nil` | | `answer_request` | Answer one pending agent-answered request; the node relays to the waiting caller | | | `approve_address` | Re-pin a contact at the new address it is waiting at, as `auto` would have; the address it left is remembered as a former one | | | `approve_contact` | Approve a waiting request (pending_in). A preset replaces the grant with that bundle; none keeps the grant the request holds (an invite’s). The peer is told what they were granted; told=false says they could not be reached, and the approval stands | | | `audit_query` | Read the audit trail for the accounts you administer (SPEC §11.6) | `e.Audit != nil` | | `block_contact` | Block a contact, silently: they are not told, and see only what a stranger sees | | | `call_contact` | Call a tool on a contact’s agent server. The contact’s own switchboard still applies | `e.CallContact != nil` | | `create_invite` | Mint an invite; token shown once | | | `digest` | What happened in a window and what is still open: messages in and out per contact, who is waiting on a reply, contacts asking to connect, requests awaiting an answer. For an end-of-day summary. | | | `export_card` | This account’s current signed contact card (vCard) | `e.Card != nil` | | `get_inbox` | Threads with unread counts | | | `identity_certificate` | This identity’s certificate state (HDTP 1.0): the root that is the identity, the leaf this host serves under, its validity, and whether a renewal is due | `e.Certificate != nil` | | `list_accounts` | Accounts this identity administers | | | `list_contacts` | Contacts with status and permissions | | | `list_integrations` | Connected upstreams and the tools each currently exposes (SPEC §6) | `e.Integrations != nil` | | `list_invites` | This account’s invites: label, uses, expiry, whether revoked. The link’s token is never stored, so it is not here | | | `list_passkeys` | Registered passkeys. Registering a new one is portal-only (SPEC §8.6) | `e.Passkeys != nil` | | `list_pending` | Open agent-answered requests awaiting this agent (args are UNTRUSTED peer content, labeled with the contact’s trust flag) | | | `list_pending_addresses` | Contacts waiting at a new address for your decision: the address they are pinned at, the one they now answer from, and why it was held | | | `read_thread` | Messages in a thread, oldest first; reading marks the thread read through the newest | | | `refresh_contact` | Re-fetch ONE contact’s signed card, now: a renewed certificate, a changed name or seal policy is learned; the pinned root and the address never move. Answers unchanged, updated, renewed, unreachable or refused (with why); an unreachable or refused contact keeps its pin as it was | `d.RefreshContact != nil` | | `reject_address` | Keep the pin where it is and drop the waiting address | | | `reject_contact` | Decline a waiting request: it becomes blocked (a demotion, not a deletion), so that identity’s next request never reaches you. They are told, so they do not wait for ever | | | `remove_contact` | Remove a contact in any state: an active one is told and its pin deleted whether or not it answers; a waiting request, your own pending request or a blocked identity goes silently | | | `remove_passkey` | Remove a registered passkey by id | `e.RemovePasskey != nil` | | `rename_contact` | Set your own local name for a contact; empty clears it | | | `revoke_invite` | Revoke one of this account’s invites: the link stops working at once, and contacts it already made are unaffected | | | `send_to_contact` | Send a message to a contact (labeled agent, SPEC §7.1) | | | `set_exposure` | Republish which of an integration’s tools are exposed to contacts (SPEC §6.5) | `e.SetExposure != nil` | | `set_permissions` | Set a contact’s switchboard: any of the core permissions, an integration. this account serves, or one the contact already holds; any other name is refused | | | `set_trust_flag` | messages_only or may_instruct | | | `unblock_contact` | Undo a block, silently. A contact that was ever active returns as it was (status active); a rejected request or a declined approach was never a contact and is forgotten (status none), so they may ask again | | | `wait_for_updates` | Block until something changes for this account — a message arrives, a contact asks to connect, a request needs answering — then return what moved since your cursor. Call it in a loop with the cursor it returns as since. Omitting since starts from now with no backlog. | | A tool with a condition is registered only when the node is built with that part; the conditions are the `if` statements around its registration. ## Resources [Section titled “Resources”](#resources) | URI | Name | Type | | -------------------- | ------------------------------- | ------------------ | | `hdtp://requests` | contact requests | `application/json` | | `hdtp://inbox` | inbox | `application/json` | | `hdtp://pending` | pending agent-answered requests | `application/json` | | `hdtp://thread/{id}` | thread | `application/json` |
# Quickstart
> Five minutes from nothing to a working node.
Five minutes from nothing to a working node. You need Docker with Compose, Go, Rust (cargo), and SSH access to the private identity module (see CONTRIBUTING.md).
```plaintext
make identity-proxy # fetch the identity module on this machine, for the image build
make limitd-vendor # and the limits sidecar's crates, which include the identity's hdtp-limits
docker compose up -d # the node and its limits sidecar (SPEC §5.7)
docker compose logs hdtp-gateway | grep -A2 "setup"
```
The log prints your portal URL and a **one-time setup token**. Open it — the wizard registers your first **passkey**, which is the node’s only login, on every bind including loopback. Register a second on another device: there is deliberately no online recovery path. If you lose them all, recovery needs shell access on the host — `hdtp-gateway passkey reset-wizard` mints a one-time link that re-opens registration. Then create the identity people will reach, and have your wallet certify this node for it (see [Your wallet](/gateway/how-to/your-wallet/)):
```plaintext
docker compose exec hdtp-gateway hdtp-gateway account create --slug me --name "Your Name"
docker compose exec -T hdtp-gateway hdtp-gateway account csr --slug me > me.csr
hdtp id create --name "Your Name" --vault me.hdtp-vault.json
hdtp id issue --vault me.hdtp-vault.json --csr me.csr --chain-out chain.pem
docker compose cp chain.pem hdtp-gateway:/tmp/chain.pem
docker compose exec hdtp-gateway hdtp-gateway account install-leaf --slug me --chain /tmp/chain.pem
```
Open *Card* in the portal and download `me.vcf`. That is what you hand to people. Until the chain is installed there is no card: the page says the account has no certificate yet. > This quickstart is not prose someone hopes still works. An automated scenario builds these images, drives the portal through a real Chrome, registers a passkey with a virtual authenticator, and pairs a contact. Running it as written is how three bugs in it were found.
# Audit, telemetry and trade-offs
> Append-only and hash-chained, recording refusals as loudly as successes.
## Every call is audited [Section titled “Every call is audited”](#every-call-is-audited) Append-only and hash-chained, recording refusals as loudly as successes. “What did my node actually do” has an answer, and it is not one that can be quietly edited.  ## No telemetry, ever [Section titled “No telemetry, ever”](#no-telemetry-ever) The node contacts nothing except what you configured. No phone-home, no crash reporter, no analytics, and no build flag that turns one on. *** ## Honest trade-offs [Section titled “Honest trade-offs”](#honest-trade-offs) Written down because they do not disappear by going unmentioned: * **Tunnels and edges see metadata** — which node is called, size, timing. Sealed content stays ciphertext to them; the fact of a conversation does not. * **No forward secrecy at the envelope layer.** A compromised leaf key opens envelopes an attacker kept from while it was current — bounded by the leaf’s life, at most 398 days, and shorter if you renew. * **Lose your root, lose that identity.** The root lives in your wallet and nowhere else — no host holds a copy and nobody can mint you another. Losing the host’s *leaf* key is different and recoverable: your wallet issues a new one. * **Card trust is channel trust.** A card handed over a hostile channel is a hostile card. The fingerprint is the thing to check.