> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vana.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Write API

> How an app writes records into a user's Personal Server under a write grant, without ever holding the user's key.

<Info>
  **Status.**

  * Shipped in `@opendatalabs/personal-server-ts` 1.16.0, on both the Node server and the in-browser PS Lite runtime, and in `@opendatalabs/vana-sdk` 3.23.0.
  * **Writes are free.** The escrow settles two operation types today, `grant` and `data_access`; there is no write or registration fee on either network.
  * **The hosted consent screen issues read grants only.** A write grant is still arranged with the Vana team rather than self-served.
  * **Records are append-only.** A write adds a version; it does not edit one.
</Info>

The Write API stores a record your app produced — a summary, an enrichment, an imported record — in the user's own Personal Server, where the user can see it, grant it to someone else, and delete it.

It is built around one constraint: your app must be able to write a scope **without being able to read it**, and without holding the user's key. Three properties follow from that:

* **The user's Personal Server signs the on-chain registration as the owner.** The app never holds the owner key and never touches the encryption key for the scope.
* **The write grant carries a `write:` prefix and confers no read.** `write:notes.entries` lets an app write that scope and nothing else. Reading it back needs a separate bare entry in the same grant. See [Scope grammar](/protocol-reference/scope-grammar).
* **Every record carries who wrote it.** The app signs the exact bytes it stored, and the Personal Server keeps that signature with the record, so authorship is verifiable from the record alone.

## The flow

```mermaid theme={null}
flowchart LR
    app["Your app<br/>holds its builder key"]:::neutral
    ps["User's Personal Server"]:::sdk
    chain["DP RPC → chain"]:::protocol

    app -->|"① POST /v1/write/session<br/>Web3Signed proof + grantId"| ps
    ps -->|"bearer token (1h)"| app
    app -->|"② POST /v1/data/:scope<br/>Bearer + X-Vana-Write-Signature"| ps
    ps -->|"③ encrypt, upload, register as the owner"| chain

    classDef protocol fill:#DCE4FF,stroke:#4141FC,color:#11104a;
    classDef sdk fill:#FFF3D1,stroke:#E6A700,color:#5c4400;
    classDef neutral fill:#EEF1F5,stroke:#9AA4B2,color:#1f2937;
```

You prove control of your key **once** per session, then write with a bearer token plus a per-write signature. The session is authorization; the signature is attribution.

## Prerequisites

| What                             | How                                                                                                                                               |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| A registered builder identity    | Your app address is registered at the [DP RPC](/protocol-reference/dp-rpc). See [App identity](/build-a-vana-app/app-identity)                    |
| A grant carrying `write:<scope>` | The grant's grantor must be the owner of the Personal Server you are writing to, and its grantee must be your app address                         |
| The user's Personal Server URL   | Returned with the approved access request. See [Grants & permissions](/protocol-reference/grants-permissions#requesting-a-grant-the-connect-flow) |

## Step 1 — Open a write session

```http theme={null}
POST {personalServerUrl}/v1/write/session
Authorization: Web3Signed <base64url(payload)>.<signature>
```

The payload is the standard Personal Server request-signing envelope — `aud`, `method`, `uri`, `bodyHash`, `iat`, `exp` — and it **must carry a `grantId` claim** naming the write grant. It is signed EIP-191 by your builder key. A bearer credential can never open a write session: the handshake exists to prove control of the key.

```json theme={null}
{
  "access_token": "vana_write_9f1c...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "notes.entries coach.*"
}
```

`scope` lists the grant's write patterns with the `write:` prefix stripped — exactly what `POST /v1/data/:scope` will accept under this token.

What the handshake checks: the builder is registered, the grant exists and is not revoked, the grant carries at least one `write:` entry, the grantee is the handshake signer, and the grantor is this server's owner. Per-scope enforcement is not settled here — it runs against the **live** grant on every write, so a grant revoked mid-session fails the next write, not the next handshake.

<Warning>Sessions are held in memory. A Personal Server restart — and in PS Lite, closing the hosting tab — drops every live token, and the app re-handshakes. Treat a `401` on a write as "re-open the session and retry once", which is what the SDK does for you.</Warning>

The handshake proof is single-use: a still-valid proof cannot mint a second token (`WRITE_SESSION_PROOF_REPLAY`). Sign a fresh one per handshake.

## Step 2 — Write the record

```http theme={null}
POST {personalServerUrl}/v1/data/notes.entries
Authorization: Bearer vana_write_9f1c...
Content-Type: application/json
X-Vana-Write-Signature: Web3Signed <base64url(payload)>.<signature>

{"title":"Weekly note","body":"..."}
```

Two credentials, two jobs. The **bearer token** authorizes the write. The **`X-Vana-Write-Signature` proof** attributes it: it is signed by the same builder key over `aud`, `method`, `uri`, `bodyHash`, `grantId` — so a stored record cannot be lifted onto another scope, or relabelled with a different grant, and still verify.

Three rules the proof imposes:

* **The body must be compact JSON** — what `JSON.stringify` emits, no insignificant whitespace. The app signs the bytes that will be *stored*, so a pretty-printed body is refused with `WRITE_BODY_NOT_CANONICAL` rather than stored with an attribution nobody can check.
* **The signed `uri` covers the query string**, canonicalised with parameters sorted by name and then value. Parameter order does not matter; the parameters themselves must match.
* **Repeat requests need a `nonce` claim.** Two identical writes signed in the same second are byte-identical, and the replay guard refuses the second (`WRITE_ATTRIBUTION_REPLAY`). Add a `nonce` — any unique string up to 128 characters, a uuid is the intended shape — and each call is distinct. The nonce is then itself single-use.

On success the Personal Server ingests, encrypts, uploads and registers the record through the normal owner path, and answers `201`:

```json theme={null}
{ "scope": "notes.entries", "collectedAt": "2026-09-04T09:12:44Z", "status": "syncing" }
```

`status: "syncing"` means the record is stored locally and the on-chain registration is in flight. See [Provenance & verifiability](/protocol-reference/provenance) for what lands on chain.

### Writing bytes instead of JSON

Any `Content-Type` other than JSON stores the payload as an unstructured record:

```http theme={null}
POST {personalServerUrl}/v1/data/coach.report
Authorization: Bearer vana_write_9f1c...
Content-Type: application/pdf
X-Filename: report.pdf
X-Vana-Metadata: {"description":"Quarterly report"}
X-Vana-Write-Signature: Web3Signed <base64url(payload)>.<signature>
```

The signed representation is the stored `$binary` record — media type, filename, your metadata, size, content hash, content — not the raw bytes, so switching the same bytes between a JSON and a binary write fails verification. The Node server caps an ingest body at 50 MB.

## What the Personal Server stamps on the record

The server adds two bookkeeping keys inside the record's `data`:

| Key          | What it holds                                                                                           |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| `$writtenBy` | The builder address, the grant id, the signed proof, and the body hash                                  |
| `$lineage`   | The validated source data points, when the write carried [lineage](/protocol-reference/derivative-data) |

A request body that brings its own `$writtenBy` or `$lineage` is rejected with `400 INVALID_BODY`. Both are stripped before the stored bytes are re-hashed, so a record with lineage verifies exactly like one without.

A third reserved key, `$binary`, is not bookkeeping: it **is** the stored form of an unstructured write, holding the media type, filename, your metadata, size, content hash and content. It is served on a read like any other content.

<Note>`$writtenBy` and `$lineage` are **not served on a read under a grant**. A data point id is `keccak256(owner, scope)`, so a grantee holding one could hash known scope names against the owner's address and learn which sources that person has connected. The owner reads them from their own storage; a grantee gets the derivation through the [lineage view](/protocol-reference/derivative-data#reading-lineage) instead, with uncovered nodes redacted.</Note>

## Using the SDK

```typescript theme={null}
import { openWriteSession, writeData } from "@opendatalabs/vana-sdk";

const session = await openWriteSession({
  personalServerUrl,          // from the approved access request
  signer: builderAccount,     // viem LocalAccount, WalletClient, or { signMessage }
  grantId,                    // the grant carrying write:notes.entries
});

const result = await writeData({
  session,
  scope: "notes.entries",
  data: { title: "Weekly note", body: "..." },
});
// result.scope, result.collectedAt, result.status
```

The SDK signs the handshake, canonicalises the body, adds the per-write proof and its nonce, and re-opens the session once on a `401`. `sessionCoversScope(session, scope)` answers whether a scope is writable under the session you hold before you send anything.

## Errors

| Status | `errorCode`                    | Meaning                                                              |
| ------ | ------------------------------ | -------------------------------------------------------------------- |
| 400    | `GRANT_ID_REQUIRED`            | The handshake proof carried no `grantId` claim                       |
| 400    | `INVALID_BODY`                 | The body carries a reserved key (`$writtenBy`, `$lineage`)           |
| 400    | `WRITE_BODY_NOT_CANONICAL`     | The JSON body is not the compact serialization of its own value      |
| 401    | `WRITE_SESSION_PROOF_REQUIRED` | The handshake used a bearer credential instead of a signature        |
| 401    | `WRITE_SESSION_PROOF_REPLAY`   | The handshake proof was already used; sign a fresh one               |
| 401    | `WRITE_ATTRIBUTION_INVALID`    | The proof does not cover this exact request (uri and query included) |
| 401    | `WRITE_ATTRIBUTION_REPLAY`     | The proof, or its nonce, was already used                            |
| 401    | `UNREGISTERED_BUILDER`         | The signing address is not a registered builder                      |
| 403    | `GRANT_OWNER_MISMATCH`         | The grant was issued by someone other than this server's owner       |
| 403    | `SCOPE_MISMATCH`               | The grant carries no `write:` entry, or none covering this scope     |
| 403    | `GRANT_REVOKED`                | The grant was revoked                                                |
| 413    | `CONTENT_TOO_LARGE`            | The ingest body exceeds the server's limit                           |

## Limits to design around

* **Append-only.** A write registers a new version; there is no edit in place. Model corrections as a new version, not a mutation.
* **Sessions do not survive a restart.** Re-handshake on `401`.
* **A write grant is not a read grant.** If your app needs to read what it wrote, the same grant must also carry a bare entry for that scope.
* **The Personal Server must be reachable.** For a browser-hosted PS Lite that means the user has Vana open. A write cannot be queued for an offline server.

## Related

* [Scope grammar](/protocol-reference/scope-grammar) — how `write:` entries are encoded and why they never confer read
* [Derivative data](/protocol-reference/derivative-data) — writing a record that carries lineage, and having the Personal Server compute one for you
* [Grants & permissions](/protocol-reference/grants-permissions) — obtaining the grant this API runs under
* [Personal Servers](/protocol-reference/personal-servers) — where the write lands
* [Payments & fees](/protocol-reference/payments-fees) — what the escrow settles today
