# AI editing An API token — or an App you authorized over OAuth — can **write** a note's content, not just read it: replace the whole note, replace or delete one section, insert after one section, or append to the end. A write is recorded in the note's edit history, where it can usually be reverted — see [Revert](#revert) for what takes that away. This page is the reference for that surface. The endpoints also accept an ordinary browser session (either credential works); writing needs the `notes:write` scope, reading needs `notes:read`. See [API tokens](./api-tokens.md) for how a program gets a credential, and the [API contract summary](./api.md) for these endpoints alongside the rest of the API. Every write goes through the same real-time document the browser edits, so a change made through the API appears immediately in every open tab of that note — there is no separate "API copy" of the content. ## Endpoints | Endpoint | Method | Scope | What it does | |---|---|---|---| | `/api/notes/:id/content` | GET | `notes:read` | Read the note as Markdown, with an outline and the fingerprints a write needs. `?section=` returns one section only. Side-effect free — the note itself never changes; a token-authenticated read does make the AI's cursor appear, though (see [Presence](#presence)). | | `/api/notes/:id/edits` | POST | `notes:write` | Apply one of the five operations. `201 {editId, fingerprint, outline, unboundWikilinks}`. | | `/api/notes/:id/edits` | GET | `notes:read` | List this note's recorded edits, newest first, and whether each can still be reverted. | | `/api/notes/:id/edits/:editId/revert` | POST | `notes:write` | Undo one recorded edit. `201 {editId, fingerprint, outline}`. | | `/api/notes` | POST | `notes:write` | Create a note; an optional `content` field writes its initial Markdown in the same request. `201` is an ordinary note object — unlike `POST …/edits`, it does not carry `unboundWikilinks`, even though wikilinks in `content` are bound the same way. | `:id` is always a note **uuid** here — the slug forms (`/n//`, and `/g//` for a group note) are not accepted by these endpoints. A note you cannot read at all answers `404 not_found` (the same body whether it does not exist, is not shared with you, or belongs to a group you aren't in); on the two writing endpoints, a note you can read but only as a viewer answers `403 forbidden`. The normal loop is: `GET …/content` → pick a section from `outline` → `POST …/edits` with that section's `fingerprint` as `if_match`. The `201` already carries the new whole-note `fingerprint` and `outline`, so a program making several edits in a row does not have to re-read between them. Over MCP the loop is different — there is no whole-note read there, and an outline carries no content fingerprints — see [MCP](./mcp.md#the-read-write-loop). ```sh # read curl -H "Authorization: Bearer knb_…" \ "https:///api/notes//content" # write one section back curl -X POST -H "Authorization: Bearer knb_…" -H "Content-Type: application/json" \ -d '{"op":"replace_section","section_id":"","markdown":"# Summary\n\nRewritten.\n","if_match":""}' \ "https:///api/notes//edits" ``` ## Structure and addressing A note is a list of **top-level blocks**. Sections are derived from those blocks and nothing else: - The first section is always `_top`. It holds every block before the first heading, its `level` is `0` and its `heading` is `""`. On a note that starts with a heading, `_top` exists but has **zero blocks**; on a note that has never been written to at all, `_top` is the only section and the note has no blocks. - Every other section starts at a heading block, and its `sectionId` **is that heading block's id**. The section runs to the next heading that opens a section. - A *deeper* heading does not open a new section. In `# A` followed by `## B`, the `## B` heading and everything under it belong to section `A` — there is no section `B`. A section is only opened by a heading at the same level or shallower than the current one. - Nested content (list items' children, blocks inside a block) is part of its top-level parent's block, not a section of its own. So section ids are **block ids, not names**. Any operation that replaces or removes a section's heading gives that section a different id, or removes it — including your own `replace_section`. Do not cache a `section_id` across writes; take the fresh `outline` that every successful write returns. `_top` is the exception: it is a fixed string, and it is always present in the outline. ## Fingerprints and 409 Each outline entry carries a `fingerprint`, and the whole note has one. A fingerprint is 16 hexadecimal characters — a truncated SHA-256 over a canonical serialization of the blocks, with block ids deliberately excluded. It changes when the blocks' content or order changes. Send back the one you read as `if_match`, and the server refuses the write if it no longer matches: `409 fingerprint_mismatch`, with the note's current content in a `current` field of the error body (the same shape `GET …/content` returns), so a program can rebase and retry without a second request. **A fingerprint is a concurrency check, not a security boundary.** It says nothing about who made a change, and anyone who can write the note can produce a matching one. It exists so that a write based on a stale read is refused rather than silently overwriting what somebody typed in the meantime. The server works the write out in a parse worker against the note as it is at that moment — comparing `if_match` there — and then puts the result into the live document, inside one synchronous transaction. If someone changed the note while the write was being worked out, what happens depends on what they changed: - **A part of the note the write doesn't depend on** — anything outside the section its `if_match` names, or anything at all for a write without `if_match` — the write goes in anyway and is merged with their change by the collaborative document: both are kept. The `fingerprint` and `outline` in the reply are of the note as the write left it, without their change; if your next `if_match` doesn't match, read again. - **The section the write's `if_match` names** (any part of the note for a whole-note `if_match` — `replace_all`, `append` — and for a file sent back): the server starts over against the changed note, comparing `if_match` again, so you get `409` with the current content. A section that keeps being touched but ends up the same overtakes the write at most three times; then it answers `503 server_busy` with `Retry-After` and writes nothing. A new block typed right after the last block of the section counts as touching it (it becomes part of the section). One typed at the end of the previous section, right before the section's heading, doesn't: it stays in the previous section — `replace_section` puts the new content after the section's last old block and then removes the old blocks, so the two insertions can't swap places when they are merged. ### A losing concurrent write is not always a 409 Two writes to the same note are serialised (see `server_busy` under [Limits](#limits)), so one of them is always working against the other's result. Which error it gets depends on the operation: | Operation | What the loser gets | |---|---| | `replace_all`, `append` | `409 fingerprint_mismatch` (the whole-note fingerprint moved) | | `insert_after` | `409 fingerprint_mismatch` (the heading survives, the section's blocks changed) | | `replace_section`, `delete_section` on a heading section | **`404 section_not_found`** | | `replace_section`, `delete_section` on `_top` | `409 fingerprint_mismatch` | `replace_section` and `delete_section` replace or remove the section's heading, and the heading's block id *is* the section id — so by the time the second request runs, the id it addressed does not exist any more, and addressing fails before the fingerprint is ever compared. `_top` is the exception only because its id is a fixed string. **Handle both codes.** The recovery is identical: re-read the outline (or the `current` body a 409 already gave you), decide again against what is there now, and retry. A `404 section_not_found` on these endpoints never means "the note is gone" — that is `404 not_found`. ## The five operations | `op` | `section_id` | `markdown` | `if_match` | What it does | |---|---|---|---|---| | `replace_all` | not accepted | required | required — whole note | Replaces the entire note. Every block id changes. | | `replace_section` | required | required | required — that section | Replaces the section **including its heading**. | | `insert_after` | required | required | required — that section | Inserts after the section's last top-level block. | | `append` | not accepted | required | optional — whole note | Adds to the end of the note. | | `delete_section` | required | not accepted | required — that section | Removes the section and its heading. | A request is exactly **one** merge into the live document: all of it lands, or none of it does. There is no per-block animation and no partial application — readers watching the note see the change appear in one step. Empty-shaped notes behave like this: | | `_top` with zero blocks (note starts with a heading) | Vacuum document (no top-level blocks at all) | |---|---|---| | `replace_section` on `_top` | Inserted **before the first heading** | Behaves as `replace_all` | | `insert_after` on `_top` | Inserted **before the first heading** | Behaves as `replace_all` | | `append` | Appended at the end, as usual | Behaves as `replace_all` | | `delete_section` on `_top` | `400 empty_section` | `400 empty_section` | | `replace_all` | Replaces everything, as usual | Writes the content | The top-level block list is **never empty**. If an operation (or a revert) would leave the note with no blocks, the server finishes with a single empty paragraph. That paragraph's id does not count toward what the edit wrote or replaced, so it plays no part in fingerprint comparisons — but if the operation was a `delete_section` that emptied the whole note, this paragraph becomes that edit's **anchor**, and whether it is still there decides whether the deletion can still be reverted (see [Revert](#revert)). ### `replace_section` without a heading dissolves the section `replace_section` replaces the heading too. If the Markdown you send does not start with a heading of the same level, that section stops existing: its `section_id` disappears from the outline and its content is absorbed into the **preceding** section. That is not an error — it is the direct consequence of "the heading is part of the section". If you meant to keep the section, restate its heading as the first line of the Markdown you send, **at the same level** (`# Summary`, not `## Summary` — a deeper heading is absorbed by the section above it, as described in [Structure and addressing](#structure-and-addressing)). The section's id changes anyway, because the heading block is a new block. ## Revert `GET /api/notes/:id/edits` lists what has been written through the API, newest first: ```json { "edits": [ { "id": "…", "op": "replace_section", "sectionId": "…", "heading": "Summary", "byHandle": "alice", "agentLabel": "claude", "createdAt": "…", "revertedAt": null, "revertOf": null, "revertable": true } ] } ``` - `op` is one of the five, `file_sync` for a part of a note changed by a file sent back (see [Sending a file back](#sending-a-file-back)), or `revert` for a row produced by reverting something. `revertOf` points at the row a revert undid. - `heading` is looked up in the note **as it is now**, so it follows renames and becomes `""` once the section is gone. - `byHandle` is the username of whoever made the request. `agentLabel` is set only when the write came through a token or an authorized App; a write made with a session cookie leaves it `null`. It follows the credential's current name while the credential exists — unless you have renamed it in Settings, in which case the name you picked wins (see [Agent display name](#agent-display-name)) — and falls back to the label recorded at the time once it has been revoked. - `revertable` says whether `POST …/:editId/revert` would be accepted right now. Reverting is a normal write: it needs `notes:write`, it produces its own row (which can never itself be reverted), and it appears live in open tabs like any other change. In the **AI edit history** dialog (in the note's `⋮` menu), a row that can't be reverted shows why instead of a **Revert** button: **Already reverted**, that a revert can't itself be reverted, or — for any other row — that the content has changed since or the note was switched to another version. Below the list, the dialog notes that each note keeps up to its 100 most recent AI edits. A revert is refused with `409 already_reverted` (already undone, or the row is itself a revert), `409 stale` (the note has moved on — the body carries the current content in `current`), or `404 not_found` (no such row, or it belongs to another note). **How "stale" is decided** differs by operation, and this is the part worth knowing: - For `replace_all`, `replace_section`, `insert_after` and `append`, the server compares a fingerprint over exactly the blocks that edit wrote. Touch or delete any of them and the edit stops being revertable. - **`delete_section` has no such fingerprint** — the blocks it wrote are the ones it removed. It is judged by an **anchor**: the neighbouring top-level block the section is put back next to. So a `delete_section` stays revertable even when the text *around* the anchor has been rewritten in the meantime; only the anchor block disappearing (or the deletion having already been undone) takes it away. Restoring it can therefore drop a section back into a paragraph that no longer reads the way it did. - **`file_sync` rows come in both kinds.** A row for a part a file rewrote or added is judged like the first four, by a fingerprint over the blocks it wrote; a row for a part a file only removed is judged like `delete_section`, by an anchor. **If the record fails to be written**, the content has already changed. Content is merged first and recorded afterwards, on purpose (a database transaction is never held across the merge), so a failure in between answers `500` with the content already applied and no row recorded. There are two shapes of this, and they differ: - For a **write**, the content is in the note and no row exists — so it cannot be reverted through the API, and a retry is refused by `if_match` rather than duplicating the edit. - For a **revert**, the content is already restored, but no revert row was written and the original row was not marked. **That row becomes non-revertable**, and pressing revert again answers `409 stale`. The two operation families reach that state differently: for the four non-`delete_section` ops the fingerprint check does it automatically (the restore replaced exactly the blocks it compares), while `delete_section` — which has no fingerprint and whose anchor survives a restore — is caught by a separate idempotency rule: an edit whose `before_blocks` are back at the top level counts as already restored. Without that rule a retry would insert the same blocks a second time, leaving two top-level blocks with the same id. Neither shape is recoverable by retrying; both are consequences of merging before recording. **Retention is "at most 100", not "the last 100".** Each note keeps its most recent 100 rows and the trim runs inside the same transaction that inserts a new one. Because a revert row is tied to the row it undid (deleting the original deletes the revert), two things follow: reverting the *oldest* remaining edit pushes that pair out of the window together (101 rows, revert the oldest, 99 remain), and an ordinary write's trim can also take out a revert row that is still well inside the window, if the row being trimmed happens to be that revert's original. A revert row's lifetime is bounded by its original's. ## Sending a file back The MCP tool `open_notes_as_files` (see [MCP](./mcp.md#open_notes_as_files)) lets an assistant download notes as markdown files and send a changed file back with `PUT /api/transfer/notes`. Sending a file back is not one of the five operations, and it takes no `if_match`: the server compares the file with the copy that was downloaded and writes only what the file changed. - **The unit is a top-level block** — a paragraph, a heading, a list item, a table and so on, together with the blocks nested inside it. A run of list items that includes colored items is one unit, because the file has it as one block of HTML. - **Only the blocks the file changed are written.** The blocks it didn't change keep their ids. An added part goes after the nearest block before it, or before the nearest block after it, that the file didn't change. - **A note that has changed since the download refuses a changed file.** Before writing, the server checks that the note is still what the copy was downloaded with: its title, and its blocks — the same blocks with the same ids, each with the same text, formatting, attributes and nested blocks. Links to other notes are compared as they are stored, so renaming a linked note, or the linked note going out of your sight, doesn't count. (A note marked `mergeable: false` has no such list of blocks; for it, which can only be renamed with a file, the note's text as it downloads is compared instead — so there a linked note's new title, or a link whose note went out of your sight, does count.) If anything differs — someone typed in it, `edit_note` changed it, another file was sent back to it — the whole file is refused with `409 base_unavailable` (`reason: "note_changed"`) and nothing is written, not even the title; download the note again and redo the change in the new copy. The check of the text and the writing of the text happen together: if the note changes after the check, before the file's changes are merged in, they aren't merged, and the server checks again. The title is changed only if it is still the one the server checked. A file's text is never put over a change that had reached the server (see [Known limitations](./known-limitations.md#sending-notes-back-as-files-mcp-bulk-transfer) for text still being typed), so sending a file back doesn't save a version first. A file that is the same as the copy downloaded answers `unchanged` however the note has changed. - **A link to another note with no text of its own keeps following that note's title when the file leaves its text as downloaded.** Such a link is written to the file as it read at download time — normally the linked note's title then. If that note has been renamed since, that text in a changed block still counts as no text of its own, so the link goes on showing whatever the note is called, as long as you can still see that note. Link text changed to anything else is handled as for `edit_note` (see [Links to notes](./mcp.md#links-to-notes)), except that what reading over MCP or the API showed you, or what you wrote there, doesn't count: the note's current title, or the URL, follows the note; other text is kept as the link's own text. - **Some notes can't be written back as a file.** When a note's blocks can't be lined up with its markdown at download time, the download's manifest marks it `mergeable: false`, and a file that changes its content is refused with `409 not_mergeable`; change it with `edit_note`. - **Each changed place is one row** in the AI edit history, with `op: "file_sync"`, and the **AI edit history** dialog shows it as `file_sync`. Renaming a note with a file isn't recorded. A file that changes more than 100 places in a note leaves only the 100 latest of its rows, and the reply's `editIds` lists only those. - **A toggle becomes a bullet.** A toggle is written to the file as a bullet item, so when the file changes it, it comes back as a bullet list item. `edit_note` does the same with a toggle read with `read_note_section` and written back. - **Blocks nested under a list item come out of it.** A list item with a paragraph nested under it is written to the file as one item; when the file changes that item, the nested paragraph comes back as a block of its own, after it. - **HTML comments are dropped**, and text after one, on its line and on the lines right below it, becomes plain text that can run on into the text after a later comment; leave a blank line after each comment, with nothing after it on its line, to keep the lines below it as markdown. A block whose text has a comment inside it is replaced by one without the comment, with a new id. A comment the file opens with `