Database
Markdown import & export
A document's body is a tree of atomic block records in the editor dataset. The markdown routes are a lossless bridge over that dataset: 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/markdown render blocks → markdown
PUT /v1/spaces/:spaceId/objects/:objectId/editor/markdown parse markdown → diff → block ops
PATCH /v1/spaces/:spaceId/objects/:objectId/editor/markdown oldText → newText replacements
POST /v1/spaces/:spaceId/objects/:objectId/editor/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 editor_blocks live events fire and other clients update in place.
Export — GET
curl http://127.0.0.1:7001/v1/spaces/$SPACE/objects/$OBJ/editor/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/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:
curl -X PATCH http://127.0.0.1:7001/v1/spaces/$SPACE/objects/$OBJ/editor/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
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. Byte-identical results are a
200no-op.
| 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/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 $SPACE $OBJ editor_blocks --sort nav.pos