---
title: "Sealed notes"
description: "Anyone can send you a note that only your key opens. ONE carries it and cannot read it. The browser, CLI, age tool and SDK paths, the HTTP doors, the formats, and what it does not defend."
type: reference
audience: both
verified: 2026-11-08
canonical: https://one.ie/docs/sealed-notes
---
ONE is the courier. It carries envelopes it cannot open.

Your 24 words give you a **lock** (`age1…`). Hand the lock to anyone. They seal a
note to it on their own machine, and ONE stores the sealed block. Only your key
opens it: in your browser, in the `one` CLI, or in the standard `age` tool. Writing
to you needs no account. Reading needs your key.

```text
 24 words ─► master (32 bytes)
     ├─ wallet keys ............ money
     └─ message-x25519-v1 ...... notes
              │
     ┌────────┴──────────┐
   age1…               AGE-SECRET-KEY-1…
   the lock            your key
   give it out         never leaves your machine

 writer's machine   note + your lock → seal (age)
        │
        ▼  sealed block
 ONE                stores the bytes, cannot read them
        │
        ▼
 your machine       your key opens it
```

The format is [age](https://age-encryption.org/v1), not a format of ONE's. A note
sealed by any age tool opens in ONE, and a note sealed in ONE opens in any age tool.
The honest category is encrypted email, or a drop box: being reachable without
trusting the courier. It is not a chat app. See [What it does not defend](#what-it-does-not-defend).

## Quickstart

Four ways in. They all produce the same armored block and land it in the same place.

### In the browser

1. **Get your lock.** Open [/keys](/keys) with your key open. The card at the top
   reads **Your lock**, with **Copy** and **Share link**.
2. **Publish it once.** Press **Publish my lock**. Your lock is signed by your Sui
   key and filed under your name, so anyone can look it up and check it
   (`GET /api/lock/<your-name>`).
3. **Hand out the link.** **Share link** copies
   `https://one.ie/write#to=<your age1…>&from=<your name>&ws=<your workspace>`. The
   part after `#` never reaches a server.
4. **They write.** The writer opens the link, types the note (and, if they like, a
   name that arrives marked "not verified"), presses **Seal it**, then
   **Send to …**. The note is sealed in their browser before anything is sent. They
   need no key and no account.
5. **You read.** The note lands in your workspace's **Sealed notes** thread. Open it
   in [/messages](/messages) and press **Open with my key**. A dedicated
   `/sealed/inbox` page is landing in this cycle.

A note from someone who has a ONE key and has published their own lock is signed
inside the envelope, and opens with the line **Signed by tony · checked**.

### CLI

The `one` CLI ships the whole loop in `oneie@5.7.3` (`npx oneie …`; the bins are
`one` and `oneie`). Every key command is offline except `publish-lock`, `lock-of`
and `write`. None prompts. Each takes `--json`.

The key is a key file (`one key new --out me.json --yes-i-accept-plaintext-file`) or
the `ONE_WALLET_MNEMONIC` environment variable. The key never comes from stdin; the
note does.

| Command | Does |
| --- | --- |
| `one key lock --key me.json` | Prints your `age1…` lock |
| `one key seal [file] --to <age1…> [--to …] [--self] [--key me.json]` | Seals a note (file or stdin) for one or more locks; `--self` adds your own so you can re-read it |
| `one key open [file] --key me.json` | Opens a sealed block (file or stdin) and prints the note |
| `one key identity --key me.json` | Prints the `AGE-SECRET-KEY-1…` line for the `age` tool. A secret: it warns on stderr |
| `one key publish-lock --key me.json [--slug <name>] [--derivation v1\|v2]` | Signs your lock with your Sui key and publishes it under your name. Needs `one login` |
| `one key publish-lock --key agent.json --agent <id> [--workspace <ws>]` | Publishes an **agent's** lock as `<workspace>/<id>`. Run with that agent's own API key |
| `one key lock-of <name>` · `one key lock-of <ws>/<agent>` | Looks up a published lock and checks the signature here before printing it |
| `one write <name> [file] [--to <age1…>] [--api <url>]` | Seals on this machine and posts the block to `/api/write`. Without `--to`, it seals to `<name>`'s published lock, checked first |
| `one write <ws>/<agent> [file]` | Seals to an agent's checked lock; the note lands in the workspace's Sealed notes |

A real run, with a throwaway key, on 2026-11-08:

```console
$ one key lock --key me.json
age1hycjlyrfy9uf529cgwf9f5zd7tagdwj0a4lw335sllqjndc9le5qfjx7ac

$ echo "meet at the north gate at six" | one key seal --to age1hycjlyrfy9uf529cgwf9f5zd7tagdwj0a4lw335sllqjndc9le5qfjx7ac > note.age
$ cat note.age
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyejFYUk9TYkRvcEpNank3
TEtQSkZkUmZtaThCdU9LSGJNS3ZLQU1HUW40CnNnZU1CMjNqMU1DTHJRK3VPR3NC
SFhYNGhkdGlHYVNldEhSb0pmamx3MjgKLS0tIG9jSmJRQ1JqcHE0VkxaRjJoY2s2
eEJBYnM5M3dPRlozUURzK0Y0S2E2WncKO3/gI+iyb8kH3hsrZgBQIMTpX6eNeGal
6sRXbRxPqaFJypthKO/NLwJdVR2ZF3vR51KJ84jB6ibg3h6bdKo=
-----END AGE ENCRYPTED FILE-----

$ one key open --key me.json note.age
meet at the north gate at six

$ one key lock-of tony
age1ehrauxe37vjx730qy5nxnzlnfqammqgxp33uny6n8ftn6gnxf32swq8x62
checked  signed by Sui address 0xef6d838e4617c18ed4b10e7ebc7862045c81253e116182171f1d788efbe98d45; the signature is checked here; that this Sui address belongs to the name is ONE's word, from the account's registered wallet
```

`one write tony` would seal to that checked lock and post it. A note sealed for
another key is refused by name:

```console
$ one key open --key me.json someone-elses.age --json
{"ok":false,"error":"this note is not for your key — it was sealed to a different lock","code":"no_identity"}
```

Exit codes: `0` done, `1` refused. `publish-lock`, `lock-of` and `write`-by-name use
finer codes: `2` key or name input, `3` not signed in, `4` wrong signer or no Sui
address, `5` bad signature or name, `6` unreachable, `7` not published, `8` a
published lock that does not verify (never used).

### The age tool

Any [age](https://github.com/FiloSottile/age) build works. No ONE code needed on the
machine. Measured with `age` v1.3.1:

```console
$ echo "a second note, from the age tool" | age -a -r age1hycjlyrfy9uf529cgwf9f5zd7tagdwj0a4lw335sllqjndc9le5qfjx7ac > note2.age
$ one key open --key me.json note2.age
a second note, from the age tool

$ one key identity --key me.json > me.key
warning  this line opens every note sealed to your lock — keep it out of logs, chat and shell history
$ age -d -i me.key note.age
meet at the north gate at six
```

`me.key` holds one line, `AGE-SECRET-KEY-1…`. On the web, **Take my key** on
[/keys](/keys) gives the same line. To send with `age` and deliver through ONE, post
the block to [`POST /api/write`](#post-apiwrite).

### SDK and agents

`@oneie/sdk@0.17.5` exports three subpaths for this: `@oneie/sdk/age` (the format),
`@oneie/sdk/message-key` (your lock from your words) and `@oneie/sdk/message-lock`
(looking up and checking a published lock). Import the subpaths, never the bare
barrel. Run on 2026-11-08 with Bun against production:

```ts
import { sealText, openText } from '@oneie/sdk/age'
import { messageKeyFromPhrase } from '@oneie/sdk/message-key'
import { verifyLockRecord } from '@oneie/sdk/message-lock'

// 1. This agent's own lock, from its 24 words.
const me = messageKeyFromPhrase(process.env.ONE_WALLET_MNEMONIC!)

// 2. Look up a person's published lock and check it here, not on trust.
const rec = await (await fetch('https://one.ie/api/lock/tony')).json()
const v = verifyLockRecord('tony', rec)
if (!v.ok) throw new Error(v.detail)

// 3. Seal for tony and for itself, so it can re-read what it sent.
const sealed = sealText('the staging key rotates on Friday', [v.lock, me.recipient])

// 4. Deliver. The plaintext never left this process.
const res = await fetch('https://one.ie/api/write', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ ws: 'tony', sealed, name: 'Deploy bot' }),
})
console.log(await res.json()) // { ok: true, threadId, messageId }

// Opening is the same call in reverse.
console.log(openText(sealed, [me.secret])) // the staging key rotates on Friday
me.secret.fill(0)
```

Steps 1 to 3 and the open printed exactly that on 2026-11-08 (Tony's lock checked,
519-character block). Step 4 was not sent, so no test note landed in a real inbox.
The door's refusals were probed instead; see below.

Other exports: `deriveMessageKey(master)` from 32 bytes, `messageIdentityText(key)`
for the `AGE-SECRET-KEY-1…` line, `encrypt`/`decrypt`/`armor`/`dearmor` on bytes,
`lockMessage`, `agentLockMessage`, `parseLockName`, `verifySuiPersonalMessage`.

## HTTP reference

### POST /api/write

Drops one sealed block into a workspace's **Sealed notes** thread. No auth.

| Field | Type | Rule |
| --- | --- | --- |
| `ws` | string | Workspace name, `^[a-z0-9][a-z0-9-]{0,62}$` (trimmed, lower-cased) |
| `sealed` | string | One armored age block, at most 8,000 bytes |
| `name` | string, optional | The writer's own name. Shown as `Sam (not verified)`; bidi and invisible characters stripped, 60 characters kept. Absent: `Someone with your lock` |

`from` is ignored. It used to name the recipient, so every stranger's note looked
like it came from the inbox's owner.

Success: `200 {"ok":true,"threadId":"…","messageId":"…"}`.

The checks run in this order, and each answers before anything is written:

| Status | Body | When |
| --- | --- | --- |
| 503 | `{"ok":false,"error":"no_db"}` | The store is not bound |
| 429 | `{"error":"rate_limited","retryAfter":n}` + `Retry-After` | More than 10 requests a minute from one address (`CF-Connecting-IP`), per isolate |
| 413 | `{"ok":false,"error":"too_large"}` | Body over 16 KiB, or `sealed` over 8,000 bytes |
| 400 | `{"ok":false,"error":"bad_json"}` | Body is not JSON |
| 400 | `{"ok":false,"error":"bad_workspace"}` | `ws` is not a workspace name |
| 400 | `{"ok":false,"error":"not_sealed"}` | `sealed` is not one armored age block with an X25519 reader, a MAC and a payload |
| 404 | `{"ok":false,"error":"unknown_workspace"}` | No such workspace |
| 429 | `{"error":"rate_limited","retryAfter":n}` | More than 30 notes a minute to one workspace, per isolate |
| 429 | `{"ok":false,"error":"inbox_busy","retryAfter":60}` + `Retry-After: 60` | 60 notes reached this workspace in the last 600 seconds, counted across all isolates |
| 503 | `{"ok":false,"error":"no_door"}` | The append receiver is not available |
| 502 | `{"ok":false,"error":"append_failed"}` (or the receiver's error) | The append failed |

Probed on production, 2026-11-08:

```console
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' -d 'nope'
{"ok":false,"error":"bad_json"}                       # 400
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' -d '{"ws":"Bad Slug!","sealed":"x"}'
{"ok":false,"error":"bad_workspace"}                  # 400
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' -d '{"ws":"zz-none","sealed":"hello"}'
{"ok":false,"error":"not_sealed"}                     # 400
$ curl -s -X POST https://one.ie/api/write -H 'content-type: application/json' --data @real-block-to-zz-none-lanec.json
{"ok":false,"error":"unknown_workspace"}              # 404
```

**No CORS.** `/api/write` sends no `access-control-allow-origin`, and a preflight
`OPTIONS` answers 404. A script on another site cannot post from a visitor's browser.
Link to `/write`, or post from a server, a script or the CLI.

The note lands in the thread named `Sealed notes` (group `sealed`, thread key
`channel:sealed`), tagged `sealed`, through the `thread:append` receiver. A sealed
post wakes no agent: the router skips it.

### GET /api/lock/&lt;name&gt;

The public lookup for a person's published lock. Also
`GET /api/lock/<workspace>/<agent-id>` for an agent's own lock. Both are the
`wallet:lock-get` receiver behind a plain GET.

- **CORS:** `access-control-allow-origin: *`
- **Cache:** `public, max-age=30`
- **Rate:** 60 a minute per address (`rate_limited` 429)
- **Names:** person `^[a-z0-9][a-z0-9-]{0,62}$`; agent id `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`

Published (production, 2026-11-08):

```json
{
  "ok": true,
  "published": true,
  "lock": "age1ehrauxe37vjx730qy5nxnzlnfqammqgxp33uny6n8ftn6gnxf32swq8x62",
  "suiAddress": "0xef6d838e4617c18ed4b10e7ebc7862045c81253e116182171f1d788efbe98d45",
  "signature": "AAVXgYecKNJk5Fdc…",
  "message": "ONE message lock v1\ntony\nage1ehrauxe37vjx730qy5nxnzlnfqammqgxp33uny6n8ftn6gnxf32swq8x62",
  "publishedAt": "2026-10-17T20:29:00.000Z",
  "withheld": "practice_net_only",
  "closed": "read"
}
```

Not published: `{"ok":true,"published":false,"closed":"read"}`. Errors:
`400 bad_slug`, `400 bad_agent`, `503 no_db | no_door | lookup_failed | db_error`.

| Field | Meaning |
| --- | --- |
| `lock` | The `age1…` to seal to |
| `suiAddress` | The Sui address that signed it. It must still be the account's registered address, or the answer is `published:false` |
| `signature` | Sui personal-message signature over `message` |
| `message` | Exactly `ONE message lock v1\n<name>\n<lock>`. For an agent, `<name>` is `<workspace>/<agent-id>` |
| `publishedAt` | ISO time of the last publish |
| `withheld`, `brake`, `card*` | Pay-card fields for private payments. Not part of sealed notes; ignore them here |
| `closed` | The loop close (`read`) |

**Never trust the answer; check it.** Rebuild `message` yourself from the name and
the lock, verify the signature, and use the `suiAddress` it recovers.
`verifyLockRecord(name, record, agent?)` in `@oneie/sdk/message-lock` does exactly
that; `one key lock-of` and `/write` run the same check. What no client can check is
that the Sui address belongs to the name. That link is ONE's word, from the
account's registered wallet.

Publishing is the `wallet:lock-publish` receiver (`{lock, signature}` for a person,
`{lock, signature, agent, workspace?}` for an agent), signed in. The server rebuilds
the message from the session's name; refusals are `bad_lock`, `bad_signature`,
`wrong_signer`, `no_sui_address`, `not_the_agent`.

## Formats

### The sealed block

age v1 (`age-encryption.org/v1`), X25519 readers only, ASCII-armored:

```text
-----BEGIN AGE ENCRYPTED FILE-----
<base64, 64 columns>
-----END AGE ENCRYPTED FILE-----
```

Passphrase and SSH readers are refused as `unsupported`. Open errors are
`no_identity` (sealed for another key), `tampered` (changed after sealing),
`unsupported`, `malformed` (not a whole note).

### The lock and the key

| | Format | Derivation |
| --- | --- | --- |
| Lock | `age1` + 58 bech32 characters | X25519 public key |
| Key | `AGE-SECRET-KEY-1…` | `HKDF-SHA256(master, salt = "", info = "message-x25519-v1")`, 32 bytes |

The master is the 32-byte entropy of the 24 words. The label `message-x25519-v1` is
locked: changing it would make every note already sent unreadable.

### The signed note

A writer with a ONE key and a published lock signs before sealing. The signature
rides inside the sealed block, so ONE never sees who signed. The plaintext is:

```text
ONE signed note v1
slug: tony
address: 0x<64 hex>
signature: <base64 Sui personal-message signature>
to: age1…          one line per reader, sorted, no repeats
to: age1…

<the note, exactly>
```

The bytes signed are rebuilt by the reader, never read from the envelope:

```text
ONE signed note v1
<slug>
<reader lock>      sorted, one per line
<sha256 hex of the note's UTF-8 bytes>
```

The reader locks bind the note to who it was written to. A note re-sealed to someone
else reads "written to a different lock than yours". Every reader of one note sees
the other readers' locks. A note that does not start with `ONE signed note v1` is an
ordinary unsigned note.

What a reader sees:

| Verdict | Line |
| --- | --- |
| checked | Signed by tony · checked |
| mismatch | Signed, but not by the account it names |
| unpublished | Signed, but tony has not published a lock, so the name cannot be checked |
| unverifiable | Signed, but tony could not be looked up just now, so it is not checked |
| other lock | Signed, but written to a different lock than yours. It may have been passed on |
| forged | Says it is signed, but the signature does not hold |
| unsigned | Not signed |

Signing ships in the browser only (`/write`, the `/messages` composers). The CLI
seals and opens; it does not sign, and `one key open` prints a signed note's
envelope as plain text. To check one offline, see
[the how-to](/docs/sealed-notes-how-to#check-a-note-is-really-signed).

### Size caps

| Cap | Value | Where |
| --- | --- | --- |
| One sealed block | 8,000 bytes | `SEALED_MAX_BYTES`, enforced by `/api/write` |
| Request body | 16 KiB | `/api/write` |
| What a person may type | 5,000 bytes (not characters; Thai is 3 bytes a letter, emoji 4) | `NOTE_MAX_BYTES`, the browser composers |
| Readers per note | 128 | `MAX_STANZAS` in the age module; more is refused before any work |

The 8,000-byte block fills before 128 readers do. The composers' budget
(`maxNoteBytes`, deliberately conservative) gives:

| Readers | Unsigned note | Signed note |
| --- | --- | --- |
| 1–2 | 5,000 bytes | 5,000 bytes |
| 5 | 5,000 | 4,603 |
| 10 | 4,754 | 3,763 |
| 20 | 3,754 | 2,083 |
| 40 | 1,754 | 0 |

Text only. No files, no pictures.

## What it does not defend

| Defends | Accepts |
| --- | --- |
| A breach of ONE's database, logs or backups: they hold sealed blocks only | **No forward secrecy.** Whoever gets your 24 words opens every note ever sent to your lock. That is the price of recovery from paper |
| A changed byte: refused as `tampered` | **Metadata is visible.** ONE sees which inbox, when and how big. Checking a signed note asks `/api/lock/<name>` for the name it claims |
| The router and agents reading content: a sealed post wakes no agent | **The page code is ONE's.** The browser seals with JavaScript ONE serves. The escape hatch is the CLI or the `age` tool, which skip the page |
| Plain text posing as a note: `/api/write` refuses anything that is not an age block | **Structure, not secrecy.** The door can prove a block is shaped like age, never that its payload is ciphertext |
| A forged signature, or a note passed on: the reader rebuilds and checks | **The name ↔ address link is ONE's word.** The signature is checked on your machine; that the Sui address belongs to the name comes from ONE's registered-wallet record |
| A flood: 10 a minute per address, 60 per inbox per 10 minutes | **A flood makes an inbox busy for honest writers too.** And the writer is anonymous unless they sign |

### The gaps, plainly

These are not built:

- **Forward secrecy.** A choice, not an oversight: your paper opens every old note.
- **Files and pictures.** Text up to 5,000 bytes.
- **A checkable page.** No published bundle hash proves the `/write` page is the code
  you reviewed. Use the CLI or `age` when that matters.
- **PGP.** No OpenPGP certificate at `/.well-known/openpgpkey` from the same key.
- **Outside review.** The age code was reviewed internally against the C2SP test
  vectors and the real `age` binary in both directions. Nobody outside ONE has
  reviewed it yet.
- **Signing outside the browser.** The CLI and SDK do not build signed notes.
- **An agent's own inbox.** A note to `<ws>/<agent>` lands in the workspace's Sealed
  notes, and no CLI command reads that thread yet. A self-registered agent (its
  workspace is its own id) has no owner row, so `/api/write` answers
  `unknown_workspace` for it.

Task recipes: [Sealed notes: how-to](/docs/sealed-notes-how-to).