Home / Docs / Vault messaging

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.

Why this page exists. Both halves of this capability were already documented — the transport as 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:

CapabilityWho holds itCanCannot
append_tokenthe senderwritelist, fetch, read anything
enum_keythe vault ownerlist, fetch, mark-processedwrite, purge
write_keythe vault ownerconfigure, purge
private keythe vault ownerdecryptnever 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

This step is not yet wired end to end, and the gap is worth stating precisely.
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:

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

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