Sending messages between vaults
Two vaults can exchange encrypted messages without sharing a vault key, without the sender having an account, and without the server ever being able to read anything. The mechanism is an append lane: a write-only channel attached to a vault, addressed by the hash of a public key.
sg.append, the crypto as sgit pki — on pages that never referenced each other. An agent asked how to send a message between vaults, found both halves, and could not find the sentence that says they combine. This is that sentence, written out.The shape of it
SENDER SERVER RECIPIENT
────── ────── ─────────
has: your public key stores: ciphertext has: private key
+ hashes of keys + enum_key
encrypt(msg, your_pubkey)
append_token = H(pubkey)
│
│ POST /api/vault/append/write/{vault_id}
│ { append_token, payload }
├────────────────────────▶ gate: H(append_token) ∈ append_anchors?
◀──────────────────────── { "ok": true } ← blind: no id, no count
│
│ POST /append/list (enum_key)
◀──────────────────────────┤
│ POST /append/fetch │
├─────────────────────────▶│ decrypt locally
│ POST /append/mark-processed
◀──────────────────────────┤
What the server can see: that a lane received something, how big it was, and when. What it cannot see: the content, or the recipient’s private key, which never leaves their machine.
Four capabilities, deliberately separated
This is the part worth understanding, because it is why a sender cannot read the lane they write to:
| Capability | Who holds it | Can | Cannot |
|---|---|---|---|
append_token | the sender | write | list, fetch, read anything |
enum_key | the vault owner | list, fetch, mark-processed | write, purge |
write_key | the vault owner | configure, purge | — |
| private key | the vault owner | decrypt | never sent to the server |
The server stores SHA-256 hashes of the first three and checks H(presented) == stored. It never holds a raw capability key, and never holds a private key at all. Compromising the server yields hashes and ciphertext.
Read in the privilege vocabulary the comparison pages use: the sender’s capability is scoped to one operation on one lane, bearer-held, and observable to the owner but not to the sender. A write-only credential that cannot read its own effects is an unusually clean shape.
Setting up a lane — recipient, one-time
Have a keypair and publish the public half (full lifecycle here):
$ sgit pki keygen --label "My Vault Identity" $ sgit pki export sha256:a4615402a0bc23ac > my-identity.json
Then register, against your vault, the hash of the sender’s lane address and the hash of your enumeration key:
$ curl -X POST https://send.sgraph.ai/api/vault/append/configure/$VAULT_ID \
-H "x-sgraph-vault-write-key: $WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{"append_anchors":["<sha256 of the append_token>"],
"enum_key_hash":"<sha256 of your enum_key>"}'
configure patches an existing vault manifest — it does not create a vault. A 403 means the vault ID or the write key is wrong.
Deriving the lane address — read this before writing code
The design is
append_token = H(recipient public key), so a sender can compute your lane address from the public bundle you gave them, with no extra coordination. That is an elegant property and it is the intended model.What ships today on sgit v0.15.0:
sgit pki export emits a JSON bundle containing two PEM blocks, a label and two fingerprints — not a bare public key. No shipped command emits the append token, and hashing the bundle file is not a defined derivation (field order and whitespace would change the answer). The server side of append lanes is code-verified and shipped; the client derivation that turns a public key into a lane address is PROPOSED.What to do meanwhile: treat
append_token as an opaque 64-hex secret you agree out of band — generate one, hand it to your sender, register its SHA-256 as an append_anchor. Everything else on this page works today. When the derivation lands, the token stops needing to be exchanged; nothing else changes.Two format rules that cause avoidable failures:
- The token pattern is
^[0-9a-f]{16,128}$— hex only. A prefixed token liketok_abc…returns 400, not 403, because it fails input validation before it reaches any gate. - CLI fingerprints are printed as
sha256:a4615402a0bc23ac. Thatsha256:prefix is part of the CLI identifier and is not part of a token. Pasting a fingerprint straight in is the most likely way to hit that 400.
Sending
# 1. encrypt to the recipient's public key (RSA-OAEP 4096 + AES-256-GCM, client-side)
$ sgit pki encrypt message.txt --recipient sha256:a4615402a0bc23ac
Encrypted to message.txt.enc
# 2. append it to their lane
$ curl -X POST https://send.sgraph.ai/api/vault/append/write/$THEIR_VAULT_ID \
-H "Content-Type: application/json" \
-d '{"append_token":"'$APPEND_TOKEN'","payload":"'$(base64 -w0 message.txt.enc)'"}'
{"ok": true}
No account is needed to send. The write endpoint requires no access token — the append_token is the whole gate. That is deliberate: somebody can send to your vault without holding a credential on the platform at all.
The response is blind by design: exactly {"ok": true}, with no file ID, no count and no metadata. A sender cannot learn what else is in the lane, or even whether theirs was the first write.
Receiving
Poll cheaply first — a metadata-only listing reads zero payloads:
$ curl -X POST https://send.sgraph.ai/api/vault/append/list/$VAULT_ID \
-H "x-sgraph-vault-enum-key: $ENUM_KEY" \
-H "Content-Type: application/json" \
-d '{"include_content": false}'
Filenames are server-assigned as {epoch_ms}_{24-hex}.enc, so they sort chronologically — which is what makes cursor pagination stable. Page with after_file_id set to the last ID you saw.
$ curl -X POST .../append/fetch/$VAULT_ID -H "x-sgraph-vault-enum-key: $ENUM_KEY" \
-d '{"file_ids":["1755302400000_a3f8….enc"]}'
$ sgit pki decrypt message.txt.enc --fingerprint sha256:a4615402a0bc23ac
$ curl -X POST .../append/mark-processed/$VAULT_ID -H "x-sgraph-vault-enum-key: $ENUM_KEY" \
-d '{"file_ids":["1755302400000_a3f8….enc"]}'
mark-processed is idempotent — a file already moved comes back in missing rather than as an error, so a retried batch is safe.
From inside a vault app
The same transport through the browser bridge, with no HTTP of your own (see sg.append):
await sg.append.configure({ appendAnchors: [anchorHash], enumKeyHash });
await sg.append.write({ appendToken, payload }); // blind
const { entries } = await sg.append.list({ includeContent: false });
const { files } = await sg.append.fetch({ fileIds });
await sg.append.markProcessed({ fileIds });
await sg.append.purge({ folder: 'processed' });
Practical notes
- Limits. 5 MB per message (413) · 1000 pending files per token (507) · 100 file IDs per batch (400) · 3 MB inline-content ceiling when listing with content (413) · list page size 50 by default, 200 max, clamped silently.
- Several senders. Register several
append_anchors. Each sender writes to their own lane, so a listing can be scoped to one of them. - Housekeeping.
purgewithfolder:"processed"and no file IDs clears a whole lane’s processed set in one call. It takes"pending"or"processed"— the older"inbox"value now returns 400. - The API was renamed.
inboxbecameappendin v0.32.7; any/api/vault/inbox/*URL is gone.
What this is not
Not a chat protocol, not a queue with delivery guarantees, and not anonymous — the server sees lane activity and timing even though it cannot read content. It is a generic append-only transport: the same primitive carries logs, signals, control messages and state flows between agents. The client decides what the bytes mean.
See also
- API — append lanes: the six endpoints, gates, limits and status codes
- PKI: keypair lifecycle, verified against the shipped CLI
sg.append: the same transport from a vault app- Security model: where the keypair sits relative to the symmetric vault key