# 1charta board API

1charta is a shared whiteboard. A person has it open in a browser tab; you
(an agent) read and change the same board over HTTP. Every change you make
appears in their tab immediately, and everything they do is visible to you.
You can also show up as a participant: a named cursor, messages, pointing
at things, and steering what the person is looking at.

Base URL: `https://1charta.com/api/v1`. All bodies and responses are JSON unless
noted. Every write returns `{"version": n, "applied": [...], "inverse": [...]}`:
`applied` is exactly what happened (full shapes for adds, cascaded
deletes made explicit) and `inverse` is a batch that undoes it.

## Authentication

The person lets you in under a name, in a board's Share panel, under
Programs. What you are given is a secret of your own: `cp_` and 43
base64url characters, shown once. You never send the secret. Send the
`auth` you derive from it, on every request:

    auth = hex(HMAC-SHA256(key = utf8(secret), "charta-auth-v1"))

    Authorization: Bearer <auth>

(or `?key=<auth>` in the query string). The key names one board; there is
no board id in the URLs. A missing or dead key gets `401`.

What you may do here is a **level**: `admin`, `write` or `read`.
`GET /board` answers yours, so you know before you are refused. A level
that can read passes a `GET` and gets `403 read_only` on anything else.
"Who can open this" below says where a level comes from.

Two more keys come out of the same secret, and they are what a **sealed**
board needs — `POST /things/board:<id>/hello` and "A sealed Space" below
say how; the derivations are the same for a board and a Space.

**Sunset 2026-12-31**: a key that looks like `ck_...` is a board key from
before programs had secrets of their own. It is still taken, sent whole as
the bearer, and it still works while anyone has the board open and for ten
minutes after the last tab closes.

## Suggested first moves

1. `GET /board/export.md` is the cheapest look at what is there (an outline
   with every id); `GET /board/graph` adds positions and structure,
   `GET /board` everything.
2. `POST /board/presence {"name": "Bob's Sonnet"}` so the person sees who is
   working: whose agent you are and which model you are, not just "Claude".
   `GET /board/peers` tells you what the person is called.
3. `GET /board/peers` says what the person is looking at and has selected,
   so "this" and "here" need no guessing.
4. Make changes with `POST /board/ops` (give your own `id`s, so you need
   no second call to learn them), or build a whole structure with a template.
5. `POST /board/say` to tell the person what you did; `POST /board/look` to show them.
6. If the person may be editing at the same time, use `base_version` and
   follow `GET /board/events`.

## Read the board

    GET /board                 -> {"id", "title", "version", "level", "shapes": [ ... ]}
    GET /board/shapes/:id      -> {"version", "shape"}
    GET /board/shapes?kind=sticky&q=launch&tag=todo&in=<frame id | x,y,w,h>&ids=a,b
    GET /board/graph           -> {"nodes", "edges", "frames"} (arrows as edges, frames with their children)
    GET /board/log?since=12    -> the recent change log, with who did what and the inverse of each batch
    GET /board/peers           -> who is present (tabs and agents); a tab also carries its `cursor`,
                                  `view` (the board units in its window) and `selection`
    GET /board/export.svg      -> the board as an SVG image (image/svg+xml)
    GET /board/export.md       -> the board as a Markdown outline (frames as sections, arrows as links)

Shapes are listed back to front. `version` increases by one per change
batch; use it to detect changes or to make conditional writes. Filters on
`GET /board/shapes` combine; `q` is one case-insensitive substring
matched against text, arrow labels and tags (not separate words), and
`tag` takes one tag. Whole numbers come back as integers.

## Shapes

Boxes (`sticky`, `rect`, `ellipse`, `diamond`, `text`, `markdown`, `frame`, `image`, `page`, `cell`):

| field | type | notes |
|---|---|---|
| `id` | string | assigned by the server unless you provide one (max 64 chars) |
| `kind` | string | one of the kinds above |
| `x`, `y` | number | top-left corner, in board units (about pixels at 100% zoom) |
| `w`, `h` | number | size; defaults: sticky 180x180, rect 180x100, ellipse 160x110, diamond 160x110, frame 800x500, image 240x180. A `text` shape sizes itself: its height always follows the wrapped lines, and its width follows the longest line (80..1200) until you set `w`, which fixes the width and rewraps. |
| `text` | string | shown inside the shape, wrapped and auto-shrunk to fit; `\n` breaks lines. For frames and pages it is the title, for a cell its code (see Cells). A `markdown` shape draws it as Markdown (headings, lists, task lists, quotes, code, bold, italic, strikethrough, links) at `font_size` and fits its own `h` to it (except a note placed on the board, which keeps its `h`); give it a `w`. |
| `color` | `#rrggbb` | fill (for `text`, the ink color) |
| `stroke` | `#rrggbb` or null | border color (default: a subtle one) |
| `font_size` | number | 6..400, the size the text starts from before shrinking to fit (down to 8, or 12 with `autosize`, past which the box grows instead) |
| `autosize` | boolean | stickies default to `true`: the text takes the largest size that fits (short notes big, long ones small) and `font_size` is ignored; set `false` to fix the size. On `text` it means the box hugs its own text; setting `w` turns it off. |
| `grow` | number | read it, do not set it: how much of `h` is height an autosized box added because its text would not fit at the 12 px readability floor. Sending `h` starts the growth again from there. |
| `align` | string | `left`, `center` (default for most) or `right` |
| `url` | string | images: `http(s)://...`, `data:image/...` or a `/files/...` path from an upload. Pages: the `/files/....html` path of an uploaded page, filled in for you when you send `html` (see Pages) |
| `tags` | [string] | up to 20 short labels; searchable with `tag=` and `q=` |
| `locked` | boolean | locked shapes cannot be moved, edited or deleted until unlocked (send `locked: false` in the same update to unlock) |
| `note`, `subpath` | string or null | `markdown` only: a note of the board's Space placed on the board, by its `ref` (a `doc` in `GET /api/v1/spaces/<id>`'s `files`), and `#Heading` or `#^block` for a part of it. Its `text` is a copy that 1charta tabs keep up to date: change the note, not the copy. The note then lists the board under On boards. `null` clears either. |

On a board in a Space, `[[Note]]`, `[[Note#Heading]]` and `[[Note|words]]` in a `markdown`
shape's `text` link to the Space's notes, which then list the board under Linked from.

Arrows (`kind: "arrow"`) connect shapes or points:

| field | type | notes |
|---|---|---|
| `from`, `to` | shape id or null | the shapes the ends attach to (boxes only, not both ends on one shape). Without a port an end is flexible: it sits on the middle of whichever side gives the shortest line and moves round the shape as things move |
| `from_port`, `to_port` | `top`, `right`, `bottom`, `left` or null | dock an attached end to the middle of a side |
| `from_anchor`, `to_anchor` | `[x, y]` or null | pin an attached end to a point of the shape, as fractions of its box (`[0, 0]` top left, `[1, 1]` bottom right); it keeps that spot when the shape moves or is resized |
| `x1`, `y1`, `x2`, `y2` | number | used for an endpoint without a shape |
| `head` | string | `end` (default), `start`, `both` or `none` (a plain line) |
| `label` | string | text drawn at the middle of the arrow |
| `style` | string | `curved` (default: a Bézier leaving each docked end square to its side), `elbow` (right angles, rounded corners) or `straight` |
| `points` | array | waypoints the line passes through, `[[x, y], ...]` in board units (up to 50): curves run smoothly through them, straight lines and elbows turn at them |
| `dash` | string | `solid` (default), `dashed` or `dotted`; the pattern scales with the width |
| `stroke_width` | number | line width in board units, 0.5 to 24 (default 2); the arrowhead grows with it |
| `color`, `tags`, `locked` | | as above |

Ink (`kind: "ink"`) is a pen stroke, one shape per stroke, drawn as a
smooth constant-width line through its samples:

| field | type | notes |
|---|---|---|
| `points` | array | the samples, `[[dx, dy], ...]` relative to `x`, `y` (1 to 1000 pairs, kept at one decimal) |
| `x`, `y` | number | where the points are measured from when you send them; on the board the shape settles on the top-left corner of its points, so read `x`, `y` back |
| `w`, `h` | number | read them, do not send them: the extent of the points (at least 1). Sending `w` or `h` scales the points to fit. On a sealed board, send them: see Sealed boards |
| `stroke_width` | number | line width in board units, 0.5 to 24 (default 4) |
| `opacity` | number | 0.1 to 1 (default 1). A highlighter is a wide stroke at 0.4 in a light colour, drawn multiplied over what is under it so the words stay readable |
| `color`, `tags`, `locked` | | as above |

Frames are titled containers: any shape whose center lies inside a frame
counts as its child (`GET /board/graph`, `GET /board/shapes?in=<frame id>`)
and a `move` of the frame (or a layout) carries the children along unless
the batch places them itself; a resize or a delete of the frame leaves them
where they are. Add frames first (or send `back`) so they sit behind
their content.

Sticky colors people expect: yellow `#fff3a0`, pink `#fecaca`, orange
`#fed7aa`, lime `#d9f99d`, green `#bbf7d0`, cyan `#a5f3fc`, blue `#bfdbfe`,
violet `#ddd6fe`, rose `#fbcfe8`, gray `#e5e7eb`.

A comfortable grid step for stickies is 200. Put new material where the
person is looking (`view` in `GET /board/peers`) or below the existing
content (templates do that by default), and call `POST /board/look` to
bring them there.

## Change the board

The general endpoint applies a batch of operations atomically, in order:

    POST /board/ops
    {"ops": [ ... ], "base_version": 12}

`base_version` is optional; if present and the board has moved on you get
`409 {"error": ..., "version": current}` and nothing is applied. An
invalid op fails the whole batch with `422 {"error": "op 2: ..."}`.

### Signed writes

On a board where somebody with keys has already written, **every change
carries a signature**, and a plain `{"ops": …}` is refused with
`422 {"error": "unsigned"}`. Send the batch as the bytes you signed
instead:

    POST /board/ops
    {"bytes": "[{\"op\":\"add\",\"kind\":\"sticky\",\"x\":40,\"y\":40}]",
     "sig": "<base64url, 64 bytes>",
     "signer": "<your Ed25519 key, base64url, 32 bytes>",
     "tip": "<the tip of GET /things/board:<id>/grants you last checked>",
     "made_on": 12}

`bytes` is the JSON array of ops **as one string**: the ops are read out of
it and never re-serialised, so the hash every other reader takes of it is
the hash your signature covers. `made_on` is the board `version` your batch
is made on, which is the version before it lands. `signer` is the Ed25519
key you registered with `POST /things/board:<id>/hello`, and it has to be
the `sign_key` of a current entry at `admin` or `write`.

What you sign is this string, exactly, with no whitespace:

    {"v":1,"t":"board:<id>","n":<made_on>,"b":"<signer>","o":"<o>","p":"<tip>"}

where `o` is base64url of the SHA-256 of `bytes`. `sig` is your Ed25519
signature over it, base64url, neither padded.

Two refusals beside `unsigned`: `422 {"error": "bad_sig"}` when the
signature does not cover those bytes at that version, and
`403 {"error": "not_a_writer"}` when the key that signed is not one the log
says may write here.

A signed batch takes no inline `html` and moves no frame's children for
you: both would change the ops away from the bytes everyone else replays.
Send the child moves in the same batch if you move a frame.

A board nobody with keys has written on takes `{"ops": …}` as it always
did, and takes `{"bytes": …}` with no signature in it too.

Operations:

```json
{"op": "add", "kind": "sticky", "x": 40, "y": 40, "text": "Hello", "tags": ["idea"]}
{"op": "add", "shape": {"kind": "arrow", "from": "id1", "to": "id2", "label": "then"}}
{"op": "update", "id": "abc", "changes": {"text": "Renamed", "color": "#bfdbfe"}}
{"op": "update", "id": "abc", "text": "Renamed"}
{"op": "move", "id": "abc", "x": 300, "y": 120}
{"op": "duplicate", "id": "abc", "dx": 220, "dy": 0}
{"op": "lock", "id": "abc"}         {"op": "unlock", "id": "abc"}
{"op": "front", "id": "abc"}        {"op": "back", "id": "abc"}
{"op": "index", "id": "abc", "index": 0}
{"op": "delete", "id": "abc"}
{"op": "title", "title": "Q3 roadmap"}
{"op": "clear"}
```

Deleting a shape also deletes arrows attached to it. `index` puts a shape
at a z position (0 = back).

Images: upload the file as the raw request body, then add an image shape
that points at the returned path (PNG, JPEG, GIF or WebP, up to 10 MB):

    POST /board/files   (Content-Type: image/png, body: the bytes)
    -> 201 {"url": "/files/3b1f...9c.png"}
    {"op": "add", "kind": "image", "x": 0, "y": 0, "w": 320, "h": 200, "url": "/files/3b1f...9c.png"}

## Pages: HTML prototypes

A `page` is a small self-contained web page you write, shown live on the
board as a card and opened full screen with a double-click; several pages
joined by arrows make a click-through prototype. Read
`https://1charta.com/api/v1/docs/pages` before writing one: it covers sending the
HTML inline, the sandbox (no network, no storage, no way out) and routing
between pages.

## Cells: code, data and charts

A `cell` is a box whose `text` is JavaScript: a person runs it in their
own tab, and the board keeps what it returned (`out`) for everyone to
see. You can write a cell's code, and you can put a finished output on
the board yourself, a chart included, without running anything. Read
`https://1charta.com/api/v1/docs/cells` before writing one: it covers `grants`,
`out`, charts and the `"cell"` field of `POST /board/ops`.

REST shortcuts:

    PATCH  /board                   {"title": "Q3 roadmap"}
    POST   /board/shapes            {"kind": "rect", "x": 0, "y": 0, "text": "Box"}
    PATCH  /board/shapes/:id        {"x": 10, "text": "Moved"}
    DELETE /board/shapes/:id
## Sealed boards

A sealed board is sealed in the browser: `GET /board` says `"sealed": true`.
The server stores and relays it without being able to read it, so on a
sealed board you seal what you write and open what you read, with the
board's own key. That key never travels over the wire and nobody pastes it
to you: say hello, and an admin's open tab seals it to you. "Who can open
this" has the call and "A sealed Space" the derivations, which are the same
for a board. You still see and send every position, size, colour, kind, id
and connection in the clear; what is sealed is a shape's words, ink and
file.

- The key: 43 base64url characters, 32 bytes, used directly as an
  AES-256-GCM key.
- A board can be given a new key, and is whenever somebody is taken off it.
  `GET /board` then answers `"generation": n` and `"keys"`, a sealed value
  under the key the board is on **now** holding
  `{"v": 1, "old": {"<generation>": "<43 characters>", ...}}`. Open it with
  your key: what it says are the keys from before, so you can still read
  everything written under them. Nothing is ever re-encrypted. If your key
  does not open `keys`, the board has moved past it — stop and read your
  own entry in the log again: the key it was given is sealed to you there,
  at the new generation. Seal everything you write with the key you hold
  now, never with one out of `old`.
- A sealed value: `"s1."` followed by base64url (no padding) of
  `iv || ciphertext || tag`, with a fresh random 12-byte iv, a 16-byte tag,
  no associated data, and the UTF-8 JSON of the value as plaintext.
- A shape carries `sealed` instead of its content: the JSON object
  `{"text", "tags", "url"}` for a box (`url` on images and pages only;
  a `markdown` shape: `{"text", "tags", "note", "subpath"}`),
  `{"text", "tags", "points"}` for ink, `{"text", "tags", "grants",
  "out", "ask", "runner"}` for a cell (null for a map it has not got) and
  `{"label", "tags"}` for an arrow. Every op that changes any of these sends all of them sealed:
  `{"op": "update", "id": "a", "sealed": "s1...."}`. Sending one of them in
  the clear is refused.
- The server cannot measure sealed text, so it keeps `w`, `h` (and a
  note's `grow`) exactly as you send them: size boxes yourself. Nor can
  it see sealed ink: send a stroke's `x`, `y` as the top-left corner of
  its points and `w`, `h` as their extent, or it is a 1 by 1 box that
  cannot be selected or framed.
- The board title (`{"op": "title", "title": "s1...."}`) and
  `POST /board/say` text are sealed strings.
- A file is sealed as raw `iv || ciphertext || tag` bytes and uploaded with
  `Content-Type: application/octet-stream`; point an image's or a page's
  sealed `url` at the `/files/....bin` path you get back.
- Not available: `q` and `tag` filters, templates, inline `html`, and the
  SVG and Markdown exports. `graph` gives each node and edge `sealed`
  instead of its words.

```js
// Node 20+
const key = await crypto.subtle.importKey("raw", Buffer.from(BOARD_KEY, "base64url"), "AES-GCM", false, ["encrypt", "decrypt"]);
const seal = async (value) => {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, Buffer.from(JSON.stringify(value))));
  return "s1." + Buffer.concat([iv, ct]).toString("base64url");
};
const open = async (sealed) => {
  const bytes = Buffer.from(sealed.slice(3), "base64url");
  return JSON.parse(Buffer.from(await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.subarray(0, 12) }, key, bytes.subarray(12))));
};
// {"op": "add", "kind": "sticky", "x": 40, "y": 40, "w": 180, "h": 180, "sealed": await seal({text: "Plan", tags: []})}
```

## Who can open this

A **thing** is one board or one Space: `board:<id>` or `space:<id>`,
URL-encoded where it stands in a path (`board%3Axyz`). Every thing has one
log of who can open it, and that log is the whole of who can open it:

    GET /things/<thing>/grants
    -> {"tip", "entries": [ ... ], "links": [ ... ], "generation", "keys", "asks"}

An entry names one holder, the key sealed to them, and what they may do.
Its stored field is called `role`; the word for what it says is a
**level** — `admin`, `write` or `read` — and that is what every other
answer here calls it, `GET /board` included.

**You are a program.** Somebody let you in under a name, in the Share panel
of a board or a Space, under Programs, and you were given a secret of your
own: `cp_` and 43 base64url characters, shown once. Say hello with the two
public halves you derive from it, once, and wait: an admin's open tab
answers by sealing this thing's key to you and writing your entry. Until it
does, your entry says `"role": "asked"` with no `envelope`, and you can see
what is here and open none of it. "A sealed Space" below has the three
derivations and how to open the envelope; they are the same for a board.

Whoever is admin writes the rest of the log from their own tab, because
every key in it is sealed to somebody and this server holds none of them:

    POST   /things/<thing>/grants      one entry, signed, on the `tip` you read
    POST   /things/<thing>/keys        {generation, keys, entries}: new keys for everyone
                                       who stays; whoever is left out is simply never sealed
                                       the new one
    POST   /things/<thing>/links       a link that lets one more person in
    DELETE /things/<thing>/links/:id   stops it working
    PUT    /things/<thing>/asks        whether a link introduces instead of letting in

The one call here that is not an admin's is `POST /things/<thing>/ask`:
what somebody with no grant at all does to be let in. It hands over
nothing. With `{"wants": "write"}` it is also what somebody who can read
here does to ask for more: no entry is written at all, only `wants` beside
the row they already hold, and an admin answers it in the same panel.

**The lists are `entries`, `links` and `programs`** — the log, the links
that let one more person in, and the programs let in here.
`GET /things/<thing>/grants` answers the first two; `GET /spaces/:id`
answers all three. **Sunset 2026-12-31**: `GET /spaces/:id` writes the same
lists a second time under the names they had before. `invites` and `agents`
are the same values as `links` and `programs`; `members` is the older,
fuller row and says as well which holders have a passkey behind them.

### The same act on a board and on a Space

Five acts are the same whichever kind of thing they are done to, so they
are one route each. Each takes `write` on the thing itself — except
`hello`, which is the one call a program makes for itself and takes a
program's own auth.

    POST   /things/<thing>/seal      a board: {check, title, shapes}, all sealed in your own
                                     tab — 204, and 204 again for a board already sealed.
                                     A Space: the first of the four calls of "Seal this Space".
    PUT    /things/<thing>/picture   {"svg": "…"}: one picture of a board, in the clear, at an
                                     address anyone who has it can fetch. {url, ref, …}
    DELETE /things/<thing>/picture   takes it down; its address answers 410 from then on. 204
    POST   /things/<thing>/hello     a program's first call: {"public_key", "sign_key"}, the
                                     two public halves of its own secret. 201, or 409
                                     `has_keys` if it already said hello. Taken once.
    DELETE /things/<thing>/programs/:id
                                     revokes a program: its key is refused from its next
                                     call. 204, or 404 `no_program`.

A board with no grants on it is opened by its address, which is the whole
of its secret, so sealing it and its picture take that address and nothing
else. One grant, and every one of them goes through the log like
everything else.

**Sunset 2026-12-31**: `POST /spaces/:id/boards/:board_id/seal`,
`PUT|DELETE /spaces/:id/boards/:board_id/picture` and
`POST /spaces/:id/programs/hello` are the same acts under their old names
and answer exactly as they did.

## Spaces

A Space is a folder of Markdown notes and boards. **A Space made in a
browser is sealed**: 1charta keeps it and cannot read it, it has no links
at all, and the people and programs in it hold its key. You get into one
the way "Who can open this" says: a `cp_` secret of your own, one hello,
and an admin's tab sealing the key to you.

**The easy way is a folder of plain files, and it is the one to take.**
Run `charta` (the sidecar, below) against the Space and it keeps a
directory on this computer and the Space the same, both ways. Then read
and edit that directory with the ordinary tools — Read, Edit, Grep, Glob,
git — and hold no key, seal nothing and make no HTTP call at all. It is
the only way that costs you nothing when a Space is sealed, which from now
on they are. Everything below is for the times a folder will not do.

**The old way was three links. Sunset 2026-12-31.** A Space made through
`POST /spaces` with three auths is not sealed: it opens with its links,
`https://1charta.com/s/<space_id>#k=<key>`, and the first admin's tab that opens it
offers to seal it. Spaces still riding on those links need them, so they
are taken for one more release; build nothing new on them.

A link key never goes to the server either. Send
what it derives instead: `auth = hex(HMAC-SHA256(key: <key>, message:
"charta-auth-v1"))`, the key being the whole `cha_…`, `chw_…` or `chr_…`
string:

    AUTH=$(printf charta-auth-v1 | openssl dgst -sha256 -hmac "$SPACE_KEY" -r | cut -d' ' -f1)

`cha_` (admin) resets the links and deletes the Space, `chw_` writes,
`chr_` reads, and each derives the one below it:
`chw = "chw_" + base32(HMAC-SHA256(cha, "charta/write/v1")[0..20])`, and
`chr` the same from `chw` with `"charta/read/v1"` (Crockford base32, lower
case). Nothing derives upwards.

    GET    /spaces/:id                   the tree: {title, level, sealed, doors, files: [{path, key, kind, ref, …}],
                                         entries, links, programs}
    GET    /spaces/:id/files/<path>      a note: {version, text}
    GET    /spaces/:id/notes?refs=a,b,c  a sealed Space only: up to 100 notes at once, each as /files/<path> answers one
    PUT    /spaces/:id/files/<path>      {"base_version": 3, "text": "…"}: writes a note, {version, text, kept}
    DELETE /spaces/:id/files/<path>
    PUT    /spaces/:id/files/<path>      any other body: an attachment (not .md), last writer wins, up to 10 MB
    PUT    /spaces/:id/files/<path>?wrap=w1.…&item_generation=2   the same, in a sealed Space
    POST   /spaces/:id/rename            {"from": "Old.md", "to": "Notes/New.md"}: the same note, history and all; links are not rewritten
    POST   /spaces/:id/move               {"from", "to": <space id>|null, "name", "key_hash", "wrap"}: where it is found changes, nothing else does
    POST   /spaces/:id/copy               {"from", "to", "name", "key_hash", "check", "shapes", "title"}: a board copied — a new id, a new key, no history and no programs. 201 {ref}, then PATCH its wrap
    GET    /spaces/:id/history/<path>    its kept versions; ?entry=<entry> for the text of one
    GET    /spaces/:id/boards/:board_id/export.svg   a board's picture; ?v=<version>: cached for good; ?frame=<id>: that frame
    GET    /spaces/:id/boards/:board_id/frames       {box, fit, frames: [{id, title, box, fit, text}]}: the picture's size and its frames
    GET    /spaces/:id/boards/:board_id/embed        {svg, page}: addresses that show this one board to anyone, read-only
    POST   /spaces/:id/updates/<path>    {version, updates: [{clientID, changes}]}: CodeMirror changesets; 409 when someone was first
    GET    /spaces/:id/updates/<path>?since=3        the changes after a version, as they were sent
    GET    /spaces/:id/events            Server-Sent Events: `note` {path, version, changes, by}, `tree` {op, path}, `board` {ref, version}
    POST   /spaces/:id/boards            {"path": "Plans/Roadmap.board.svg"}: a new board, {id}
    POST   /spaces/:id/boards            {"path": …, "board_id": …}: moves a free board in
    PATCH  /spaces/:id/boards/:board_id  {"link_access": true}: its own /b/<id> link, on or off
    POST   /spaces/:id/reset             {admin, write, read}: three new auths; every old link dies

    DELETE /spaces/:id
    POST   /spaces                       {title, admin, write, read}: a new Space 1charta can read, {id}; no key needed

All with `Authorization: Bearer <auth>` — of your `cp_` secret, or of a
link key while those last.

### A sealed Space

A Space made in a browser is sealed from the moment it exists, and one
made here is sealed the first time an admin's tab offers to. Sealed means
1charta keeps it and cannot read it: no text, no names, no boards, no
attachments, and no links — the people in it hold the key. Its
attachments are sealed where they are made, exactly as a sealed board's
files are — raw `iv || ciphertext || tag`, `Content-Type:
application/octet-stream` — and the file's own key travels with them,
wrapped under the Space's key: `?wrap=w1.…&item_generation=<n>`. The name
the store gives the file is the hash of the **ciphertext**, so the
`/files/…` address hands out bytes nobody can open and tells nobody which
files a Space holds. A sealed Space refuses an attachment sent without a
wrap. `GET /spaces/:id` answers each file's `wrap` and `item_generation`;
a file with no `wrap` is not sealed and is read as it always was.

**Its names are sealed too.** `GET /spaces/:id` answers `"sealed": true`,
and every file's `path` is then the whole path sealed (an `s1.` value)
under the Space's **name key** — `"name"` inside `keys`, which does not
change when the Space gets new keys — while `key` is what the row is found
by:

    key = "h:" + hex(HMAC-SHA256(name_key, utf8(NFC(casefold(path)))))

Send that `key` wherever a path goes (`/files/h:<64 hex>`, and the same
for `history`, `updates`, `cursor`, `rename`), and send the sealed name
beside it wherever a row is made or renamed — as `"name"` in a JSON body,
or `&name=` in the query of an attachment's `PUT`:

    PUT    /spaces/:id/files/h:<64 hex>  {"base_version": 0, "text": "…", "name": "s1...."}
    POST   /spaces/:id/rename           {"from": "h:<64 hex>", "to": "h:<64 hex>", "name": "s1...."}
    POST   /spaces/:id/boards           {"path": "h:<64 hex>", "name": "s1....", "wrap": "w1....", "item_generation": 2}

Because the hash is keyed, 1charta keeps the one unique index the tree
always had — two spellings of one name still cannot both exist — and
cannot work out which name a hash belongs to. A sealed Space refuses a
plaintext path with 422, and an unsealed one refuses an `h:` name the same
way. `GET /spaces/:id/search` and `GET /spaces/:id/links` answer 410
`sealed`: 1charta keeps no copy of the words in a sealed Space and no
table of the links between them. A 404 on a file that was renamed still
answers `"moved"`, as the sealed name it moved to. The Space's `title` is
sealed under the same key.

**Its notes are sealed too**, and that is the one place where what you do
changes. Each note has a key of its own, kept in its row as `wrap` under
the Space's key, and every changeset, snapshot and kept text is sealed
with it. Sealing takes additional data — `"<space id>|<note ref>|<type>"`
with `type` one of `text`, `text_snapshot`, `text_kept` — so the same
bytes in another note, or under another type, open nothing.

    PUT    /spaces/:id/files/h:<hex>     {"base_version": 0, "text": "", "name": "s1....",
                                          "wrap": "w1....", "item_generation": 2}
                                         makes the note and gives it its key. The reply's
                                         `ref` is what the additional data names, so make
                                         the row first, then send the wrap, then write.
    GET    /spaces/:id/files/h:<hex>     {"sealed": true, "version": 9, "from": 5,
                                          "text": "s1....", "updates": [{"version", "changes", "client"}]}
                                         `text` is the newest snapshot (`""` when there is
                                         none, `from` its version); open it, then apply the
                                         opened `changes` in order. That is the note.
    POST   /spaces/:id/updates/h:<hex>   {"version", "updates": [{"clientID", "changes"}]}
                                         `changes` is a sealed `ChangeSet.toJSON`. 409 with
                                         the current version when someone was first.
    POST   /spaces/:id/snapshot/h:<hex>  {"version", "text": "s1...."} — 204. Write one every
                                         couple of hundred updates, or opening the note gets
                                         slower for everyone and nothing can be pruned.
    POST   /spaces/:id/blobs             the raw sealed bytes as application/octet-stream —
                                         201 {"ref"}, the name `/files/<ref>` serves them
                                         under, which is their own hash. No row and no event:
                                         it is where a copied board's pictures are put, sealed
                                         again under the copy's key.

**There is no whole-text write in a sealed Space**: 1charta cannot merge a
text it cannot read, so `PUT` with a text answers 410 `sealed`. Whoever
writes holds the key, so whoever writes merges — read the note, work out
the change against the text you just read, and send that to `/updates`. If
it comes back 409, read again and do it again on what the note became.
`"base_version": null` still takes a whole sealed text and keeps it in the
history beside the note, for a text that shares no ancestor with it.
A note nobody has opened is never snapshotted, and a changeset nobody can
open is skipped by whoever reads it; `history` and `/updates` are
unchanged in shape, and every value in them is ciphertext.

**How you get the key at all.** A sealed Space is opened with a key
1charta does not have, so none of the above works until somebody has
sealed that key to you. You are a program: your secret is `cp_` and 43
base64url characters, shown once when you were let in, and three things
come out of it and nothing else:

    auth   = hex(HMAC-SHA256(key = utf8(secret), "charta-auth-v1"))
             what you send as `Authorization: Bearer <auth>`, as every
             other key here does. 1charta keeps sha256 of it.
    ecdh   = HKDF-SHA256(ikm = the secret's 32 bytes,
                         salt "charta/program/v1", info "ecdh", 32)
             a P-256 scalar: the private half of what the Space key is
             sealed to.
    sign   = the same with info "sign", an Ed25519 seed. Not used yet.

Derive the two public halves and register them **once**:

    POST   /things/space:<id>/hello   {"public_key": "<the raw 65-byte P-256 point,
                                        standard base64>",
                                       "sign_key": "<the raw 32-byte Ed25519 key,
                                        base64url>"}
                                      201, or 409 `has_keys` if you already said hello.
                                      The keys are taken once and can never be swapped.
                                      A sealed **board** is the same call with
                                      `board:<id>` in its place.

Then wait. An admin's open tab seals the Space key to your public key and
writes you a grant; until it does, `GET /things/space:<id>/grants` shows
your row as `"role": "asked"` with no `envelope`, and you can read the
Space's shapes and sizes and open none of them. Once it has, your entry
carries an `envelope`:

    e1.<the sender's ephemeral P-256 point, base64url>.<an s1. value>

Open it with your `ecdh` key: ECDH against that point, then
HKDF-SHA256(salt "charta/envelope/v1", info = utf8("<thing>@g<generation>\n")
‖ your point ‖ the ephemeral point) for an AES-256-GCM key, and that
opens the `s1.`. Inside is `{"c": "<the Space key, base64url raw 32 B>"}`.
From there `keys` on the Space gives the older generations and the name
key, `wrap` gives each item's own key, and everything above works.

**The easier way is the folder.** `charta` (below) does all of this and
keeps a directory of plain files in sync with the Space; read and edit
those with your ordinary tools and never hold a key at all.

A note is a `.md` path. You always send the whole text and the version
you made it from (`0` for a new note): read, edit, `PUT` with the
`version` you read. If someone wrote meanwhile the server merges, word by
word, and answers with the text as it now stands, so keep working from
the reply, not from what you sent. `"kept": true` means your edit and
theirs touched the same words: both are in the text as well as a merge can
put them, and your whole text is in the history untouched. The text is
stored byte for byte; nothing reformats it. In a table, change only the
cells you mean to and do not re-align the columns; write `\|` for a pipe
and `<br>` for a line break inside a cell; `<!-- w 30 -->` at the end of a
header cell is that column's width in characters, so keep it with its
column. `"by": {"id", "name"}` in the body says who you are.
`"base_version": null` is for a text that shares no
history with the note (a folder meeting a Space): a new note takes it, an
existing one stands and keeps yours in its history. The event stream is
live only: after a reconnect,
`GET /spaces/:id` lists every note and board with its `version`. A
`board` event (at most one per board every half second while it
changes) has no id and counts up no `seq`.

The `svg` address from `/embed` works in any `<img>`, a README included:
the key in it opens that one board as a picture and nothing else, and it
stops working when the Space's links are reset. `changes` in an event and
in `/updates` is CodeMirror's `ChangeSet.toJSON`: numbers keep that many
UTF-16 units, `[3, "new"]` replaces three with "new". An agent is better
off with `PUT`: whole text in, merged text out.

To keep a whole folder in sync, an Obsidian vault say, run the sidecar
instead: `CHARTA_KEY=<your cp_ secret> charta
https://1charta.com/s/<space_id> <directory>`. It keeps the secret outside the
folder, and in a sealed Space it says hello, waits for an admin's tab to
seal the Space key to it, and from then on opens and seals everything
itself — the directory is plain files either way. The Space's own
"Sync a folder" has the whole line, ready to run.

A board made in a Space is plain,
and opens in a browser only through the Space. A path holds none of
`* " \ < > : | ? # ^ [ ]`, no leading dot and no trailing dot or space,
and two spellings that differ only in case are one path.

## Undo

    POST /board/undo                -> undoes your most recent batch still in effect
    POST /board/undo {"version": 13} -> undoes that batch (from anyone), or the whole drag it is a tick of

Each undo is a batch of its own, logged with `undo_of`, so undoing an
undo's version is a redo. A plain undo walks back past your undos and
what they undid, and answers `404` when nothing of yours is left. Or
apply the `inverse` you got back from any write with `POST /board/ops`.

## Arrange

These compute positions on the server and apply them as `move`s. `ids`
defaults to every box on the board; arrows always follow. `layout` takes
the shapes in reading order (rows first, then left to right) rather than
in stacking order, so laying out an already-tidy set changes nothing.

    POST /board/layout      {"mode": "grid" | "row" | "column", "ids": [...], "cols": 3, "gap": 40, "x": 0, "y": 0}
    POST /board/align       {"edge": "left" | "right" | "top" | "bottom" | "center" | "middle", "ids": [...]}
    POST /board/distribute  {"axis": "horizontal" | "vertical", "ids": [...]}

## Templates

Build a whole structure in one call. Each returns the usual write result;
`applied` has every created shape with its id. All accept optional `x`,
`y` (default: below the existing content).

    POST /board/templates/mindmap
    {"root": {"text": "Launch", "children": [{"text": "Marketing", "children": ["Blog", "Ads"]}, "Engineering"]},
     "direction": "right" | "down"}

    POST /board/templates/kanban
    {"columns": [{"title": "To do", "items": ["Write spec", {"text": "Review", "color": "#fecaca"}]},
                 {"title": "Doing", "items": []}, {"title": "Done", "items": ["Kickoff"]}]}

    POST /board/templates/flow
    {"steps": ["Start", {"text": "Valid?", "kind": "diamond"}, {"text": "Ship", "label": "yes"}],
     "direction": "right" | "down"}

    POST /board/templates/stickies
    {"items": ["Idea 1", {"text": "Idea 2", "color": "#bbf7d0", "tags": ["top"]}], "cols": 4, "color": "#fff3a0"}

    POST /board/templates/sections
    {"sections": [{"title": "Strengths", "items": ["..."]}, {"title": "Weaknesses", "items": ["..."]},
                  {"title": "Opportunities", "items": []}, {"title": "Threats", "items": []}], "cols": 2}

## Be present

    POST /board/presence   {"name": "Bob's Sonnet", "color": "#7c3aed"}   -> you appear in the people list
    POST /board/cursor     {"x": 120, "y": 80}                       -> your cursor moves on everyone's canvas
    POST /board/say        {"text": "Grouped the ideas by theme"}    -> a message in every tab
    POST /board/highlight  {"ids": ["abc", "def"], "ms": 2500}       -> those shapes pulse
    POST /board/look       {"ids": ["abc"]} or {"x", "y", "w", "h"} or {} (everything) -> every tab eases over there (fitted, never zoomed past 100%)

Presence lasts while you keep calling the API (any request counts) and
fades 45 seconds after your last one. Your changes are labelled with the
name you gave, so give one that says whose you are and which model you are
— "Bob's Sonnet", "Bob's Opus 5" — because a board often has several agents
on it and "Claude" tells the person nothing about which one moved their
sticky.

## Watch for changes

    GET /board/events?since=12

A Server-Sent Events stream. It first replays batches newer than `since`
(omit it for live changes only), then streams:

    event: ops
    data: {"version": 13, "ops": [...], "inverse": [...], "by": {"type": "tab", "id": "...", "name": "calm otter"}}

`by.type` is `"tab"` for a person and `"agent"` for an API key; an undo
batch carries `undo_of`, and a batch a cell's run wrote carries `cell`,
the cell's id. Versions arrive one by one, a drag tick each;
a `since` older than the history kept first gets
`event: gap` with `{"since", "first"}`. The stream ends with
`event: bye` when the key is out.

## Examples

```sh
curl -H "Authorization: Bearer $KEY" https://1charta.com/api/v1/board

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  https://1charta.com/api/v1/board/ops \
  -d '{"ops": [
        {"op": "add", "id": "plan", "kind": "sticky", "x": 40, "y": 40, "text": "Plan"},
        {"op": "add", "id": "build", "kind": "sticky", "x": 300, "y": 40, "text": "Build"},
        {"op": "add", "kind": "arrow", "from": "plan", "to": "build", "label": "then"}
      ]}'

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  https://1charta.com/api/v1/board/templates/mindmap \
  -d '{"root": {"text": "Launch", "children": ["Marketing", "Engineering", "Support"]}}'

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  https://1charta.com/api/v1/board/look -d '{}'
```
