Markdown import & export
A document's body is a tree of atomic block records in an editor storage collection — editor_blocks for the shared body, <typeId>_<key> for a part with its own editor; the routes name it as :collection, the examples use editor_blocks. The markdown routes are a lossless bridge over that storage collection: GET renders the blocks to markdown, PUT parses markdown and diffs it against the current blocks, PATCH applies quoted-text replacements server-side, and append adds a fragment at the tail without reading the document. They exist for "Export as .md" / "Import .md" flows, LLM tooling, and any caller that doesn't want to walk the block tree.
GET /v1/spaces/:spaceId/objects/:objectId/editor/editor_blocks/markdown render blocks → markdown
PUT /v1/spaces/:spaceId/objects/:objectId/editor/editor_blocks/markdown parse markdown → diff → block ops
PATCH /v1/spaces/:spaceId/objects/:objectId/editor/editor_blocks/markdown oldText → newText replacements
POST /v1/spaces/:spaceId/objects/:objectId/editor/editor_blocks/markdown/append append at tail, no read or diff
All four write through the same block write path a per-block edit would, so the same live events fire on that storage collection and other clients update in place. The object's type must declare the part that owns it (page declares editor_blocks) — a write otherwise is 400 dataset.not_declared — and a :collection the space does not serve is 404 dataset.not_found.
Export — GET
curl http://127.0.0.1:7001/v1/spaces/$SPACE/objects/$OBJ/editor/editor_blocks/markdown
# → {"content": "# Title\n\nFirst paragraph…"}
Returns {"content": "<markdown>"}: every top-level block rendered to its canonical markdown and joined with a blank line. Block text holds inline markdown only; block-level structure (headings, list items, checkboxes) comes from the block's type and style.
Import — PUT
curl -X PUT http://127.0.0.1:7001/v1/spaces/$SPACE/objects/$OBJ/editor/editor_blocks/markdown \
-H 'Content-Type: application/json' \
-d "$(jq -n --rawfile md doc.md '{content: $md}')"
# → {"inserted": ["blk_…"], "updated": [], "deleted": ["blk_…"], "unchanged": 12}
The server parses the markdown, diffs against the current block tree by type + position + text, and emits per-block create / update / delete ops. Unchanged blocks keep their ids. Re-PUTting what GET returned writes nothing — unchanged equals the block count.
Targeted edits — PATCH
For callers that know the text they want changed but not the block ids. Each oldText must already be in the document:
curl -X PATCH http://127.0.0.1:7001/v1/spaces/$SPACE/objects/$OBJ/editor/editor_blocks/markdown -d '{
"edits": [
{ "oldText": "- [ ] Children of Time", "newText": "- [x] Children of Time" },
{ "oldText": "typo", "newText": "fixed", "replaceAll": true }
] }'
any editor edit $SPACE $OBJ --old '- [ ] Children of Time' --new '- [x] Children of Time'
any editor edit $SPACE $OBJ --edits @edits.json
any editor edit $SPACE $OBJ --collection "${TYPE}_summary" --old 'draft' --new 'final'
The server renders the current canonical markdown (the exact bytes GET returns), resolves every edit against it, splices, and feeds the result through PUT's diff. A checkbox tick therefore lands as a single $set style.checked on one block; ids and untouched blocks stay stable; the reply is PUT's shape.
Matching rules:
- Every
oldTextmatches against the original document, independently of the other edits; matched regions must not overlap. - Without
replaceAllthe match must be unique.newTextmay be empty (deletes the text). Deleting a whole block takes one blank-line separator with it, so neighbours end up adjacent. - Exact match first; on zero hits a whole-line fuzzy fallback folds unicode punctuation to ASCII (curly quotes, dashes, NBSP; NFKC) and ignores trailing whitespace. A mid-line fragment is never fuzzy-matched — re-
GETand quote exactly. - All-or-nothing: any failing edit rejects the whole request and nothing is written. A request whose result is byte-identical to the current document is a
200no-op, but repeating an edit that already applied is not: theoldTextis gone, so the retry answers400 markdown.no_match. After an uncertain retry,GETthe document and check fornewTextbefore treating the edit as lost.
| Error | Meaning / recovery |
|---|---|
markdown.no_match |
oldText not in the current rendering — GET and quote the exact text (details.editIndex) |
markdown.ambiguous_match |
more than one occurrence without replaceAll — add context or set replaceAll (details.editIndex, occurrences) |
markdown.overlapping_edits |
two edits matched intersecting text — merge them (details.editIndices) |
Why it matters.
PATCHreplaces the client-sideGET → string-replace → PUTread-modify-write. Because the match runs against the current state on the server, a stale quote fails loudly instead of silently reverting someone else's concurrent edit elsewhere in the document — and the caller ships O(edit) bytes, not O(document).
Append — POST …/append
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects/$OBJ/editor/editor_blocks/markdown/append \
-d '{"content": "## Turn 12\n\nAgent replied with…"}'
# → {"inserted": ["blk_…", "blk_…"], "updated": [], "deleted": [], "unchanged": 0}
Parses the fragment, looks up only the tail position (one indexed query, never the existing block bodies), and creates the new blocks in a single change. Cost is O(appended content) regardless of document size — a run of N appends is O(N), where N PUTs would be O(N²). It is purely additive: no update or delete, it inserts no leading separator, and it happily creates a block identical to an existing one. Blank content is a 200 no-op. Use it for grow-by-append pages such as logs.
Empty paragraphs
An empty paragraph is a real block — a paragraph record with text: "" — and blank lines are how the markdown routes carry it. PUT and GET apply the same rule, so they are exact inverses:
| Markdown | Blocks |
|---|---|
alpha\n\nbeta |
two blocks — one blank line is the separator |
alpha\n\n\nbeta |
two blocks with one empty paragraph between — every blank line beyond the separator is an empty paragraph |
\n\nalpha |
at either edge there is no separator, so every blank line is an empty paragraph |
alpha\n |
one block — a single trailing newline is a terminator, identical to alpha |
alpha\n\n |
one block plus a trailing empty paragraph |
Note. This encoding is any's, not CommonMark's: every other markdown renderer collapses blank runs. Content that round-trips through an external tool or a paste loses its empty paragraphs unless that tool emits and parses blank runs the same way.
appendis the exception — blank lines wrapping a fragment are framing and dropped; empty paragraphs between the fragment's own blocks are kept.
Reading blocks directly
The markdown routes are a transform, not a read path. Block records themselves are read through the per-object query surface with dataset: "editor_blocks", sorted by nav.pos, and watched through subscribe:
any query-subscribe $SPACE $OBJ --dataset editor_blocks --sort nav.pos --limit 500