Documents: reading and writing
To read a document whose id you know, read-only, such as an organization's AGENTS.md or a page whose link ends in the id, call documents.getDocument({ documentId, scope: { type: "read" } }); without a scope you get only the envelope (title and metadata, no content), and a read never changes anything. To edit one block of a document, change a paragraph, or replace a block, read the document's outline for the block key, then call documents.editDocument with a fresh writeBasis and a replaceBlock, insertBlock, or removeBlock op, as "editDocument: the op grammar" below describes.
Every document operation runs inside run, against the documents namespace. You send JavaScript statements that run in an async context; whatever you return comes back to you, and everything else stays server-side.
const doc = await documents.getDocument({ documentId: "0199c8f2-1b5e-7a41-9d2c-6f0a3b5e1c77" })
return { title: doc.title, version: doc.currentVersion, charCount: doc.charCount }Organization-scoped methods (createDocument, listDocuments, search, listTypes, searchTypes, listMetadataValues, createType) require an explicit organizationId on this deployment. There is no default-organization fallback: omitting it fails input validation. Discover the organizations you belong to with organizations.listMine(), and call orientation.whoami() for your identity, your delegated scopes, and the id of the organization holding Oddlynew's own documentation. Document-scoped methods (getDocument, editDocument, patchDocumentEnvelope, patchSettings, archiveDocument, restoreDocument, deleteDocument) address a document by documentId and take no organizationId.
Reading: getDocument and its scopes
getDocument({ documentId }) reads the current live state. Add version to read an immutable historical snapshot instead (content, settings, and type context are pinned; shell fields such as title and metadata stay current). The scope parameter decides how much content comes back, and it is the single most important cost lever you have.
Only one scope is a bare string: "envelope". Every other scope is an object with a type field.
envelope (the default)
Omit scope, or pass the string "envelope". You get shell facts only, with no prose and no outline: documentId, url, title, typeId, metadata, settings, creatorId, organizationId, currentVersion, versionCount, charCount, blockCount, createdAt, updatedAt, archivedAt, collaborators, and on a live read writeBasis, stateBasis, persistedVersion, and optional background projection diagnostics. charCount counts the document's extracted plain text: the words alone, with no Markdoc tags, block wrappers, or formatting markers. Every content projection is longer than it, read by its headings and list markers, expand by the full Markdoc source, roughly double. Size read windows with maxChars against the projection you will actually fetch, never from charCount.
orientation
{ type: "orientation", outgoingLimit?, backlinkLimit? } returns navigation and provenance without prose: a contextCard, the outline, citation health counts, and bounded neighborhood samples.
const doc = await documents.getDocument({
documentId: "0199c8f2-1b5e-7a41-9d2c-6f0a3b5e1c77",
scope: { type: "orientation", outgoingLimit: 10, backlinkLimit: 3 },
})
return doc.orientation.citationHealthoutgoingLimit and backlinkLimit each accept 0 to 20 and default to 5. Set backlinkLimit to 0 when you do not need an inbound sample; that skips backlink drift hydration while preserving honest pagination metadata. citationHealth.outgoing.total covers every outgoing citation in the returned state, while neighborhood.outgoingCitations and neighborhood.backlinks are only samples: the "is there more" flags are citationHealth.outgoing.hasMore and citationHealth.backlinks.hasMore, and neighborhood.nextBacklinkCursor continues the backlink walk through getBacklinks. Backlink counts (citationHealth.backlinks.sampleByStatus) describe the sample only, not the whole graph. Read orientation.provenance before you reason across fields: it states whether document-state facts came from the live document or a historical version, and it marks backlinks as current persisted graph facts regardless.
Each entry in orientation.neighborhood.outgoingCitations, and each citations[] entry on a content-bearing block, slice, or expand read, carries sourceBlockKey, markKey, targetDocumentId, targetVersion, targetBlockKey, targetDrift, driftBasis, and anchorText. With the enclosing document id, sourceBlockKey plus markKey addresses the exact citation occurrence; targetBlockKey addresses the cited target block. driftBasis says whether the verdict came from target state, a version pin, a live receipt, or insufficient evidence. Combine it with the enclosing live writeBasis or historical version and targetDrift.targetCurrentVersion to audit the decision. Raw checksums and block hashes are deliberately not exposed.
outline
{ type: "outline", rootBlockKey? } returns one entry per block: key, type, preview, depth, parentKey, path, plus style ("h1" to "h6", "normal", "blockquote"), listItem, and level where the block carries them. Nested block children are included; table cells are not, so a table is a single entry. Pass rootBlockKey to outline one subtree.
block
{ type: "block", blockKey } returns exactly one addressable node as Markdoc, plus scope.blockKeys listing every addressable key inside it. Reading a table returns the table key followed by its cell keys in row-major order, which is how you get a citable handle on a single cell.
Addressable nodes are root blocks, nested non-span blocks, and table cells. Table rows and Portable Text spans are structural: they cannot be read, pinned, or edited by key.
section
{ type: "section", blockKey } takes a heading block and returns it plus every following sibling up to the next heading of the same or a shallower level. An h2 section ends at the next h2 or h1; an h1 followed by an h3 keeps the h3 inside. scope.headingLevel and scope.blockKeys come back with it.
Sections are not capped. Anchoring on the only h1 in a document returns the whole document, so read the outline first when you do not know the section size.
slice
{ type: "slice", blockKey, above?, below? } returns a window of sibling blocks around a pivot. above and below each accept 0 to 50 and default to 3. The response carries scope.blockKeys: every addressable key in the window, including nested children of container blocks, so you can prove which neighbors you received and target them in follow-up reads or edits. The window counts top-level blocks and is clamped at the document edges, so fewer top-level neighbors than above plus below means the pivot sits near an edge.
const doc = await documents.getDocument({
documentId: "0199c8f2-1b5e-7a41-9d2c-6f0a3b5e1c77",
scope: { type: "slice", blockKey: "b7f2a1", above: 1, below: 4 },
})
return doc.contentAn unknown pivot is an error, not an empty window. The slice scope reports it as block_not_found with requestedBlockKey and a bounded availableBlockKeys sample, the same contract the block and section scopes use, on public reads as well as authenticated ones. Sharp edge: outline with an unknown rootBlockKey is still silent and returns an empty outline array rather than an error.
expand
{ type: "expand" } returns the full document as Markdoc. That Markdoc is round-trip safe: you can pass it verbatim into createDocument's draft.markdoc or into an insertBlock op. It is also the one read with a hard ceiling. A document whose content exceeds 131072 bytes comes back not as content but as a truncation envelope, { codemodeResultTruncated: true, tool, maxSizeBytes, originalSizeBytes, reason, guidance, preview }, with no content key at all. When that happens, read the outline and page the document by section with { type: "section", blockKey } on each h2 heading key.
read
{ type: "read", maxChars?, cursor? } reads the document in small pieces: server-paginated chunks of readable rendered text rather than Markdoc, each result bounded to a budget you choose. A page that does not complete the document carries scope.nextCursor; pass it back unchanged, and concatenating page content is lossless. maxChars (1,000 to 129,024) caps each page's content length on the server, so every result stays inside your own budget. Repeat the same maxChars beside cursor on every continuation: the cursor carries only the resume position, and each bounded page echoes maxChars in scope. Cuts prefer paragraph boundaries and never split a surrogate pair. Use read to consume a large document in bounded bites when you need its text; use expand when you need round-trip-safe Markdoc.
Error results, not exceptions
getDocument returns structured results in place of the document for several conditions. version_not_found carries requestedVersion, currentVersion, and nearestSurviving.older / .newer. block_not_found carries requestedBlockKey and availableBlockKeys (up to 50). section_anchor_not_heading carries blockType, the style the block declares if any, and up to 50 headingBlockKeys that would work. A retracted or legally removed document returns only a content-free terminal projection with terminalState and a →retracted or →removed marker; no payload is recoverable.
writeBasis: read before you write
Every live write (editDocument, patchSettings, patchDocumentEnvelope) requires a writeBasis. You get one from getDocument called without version, or from the response of your previous write. Historical reads never return one.
The token has the shape <liveRevision>.<hash>. Pass it back unchanged. Do not parse it, increment it, invent one, or substitute the version number.
The rest of a successful write response is a finished receipt: writeConfirmed: true marks the edit as durable live state, and pendingProjections names any background projections (version snapshot, reference graph, ranked search) still absorbing it — wait for one only when your next call reads it.
If the document changed since your read, the write fails with stale_live_revision and a message naming both revisions: "Document changed since your last live read (basis liveRevision 41, current liveRevision 43). Call getDocument again, inspect the latest state, then retry with the returned writeBasis." A token that is not in the minted form, or whose hash does not match the current content at that revision, fails with invalid_write_basis and the message "Invalid document writeBasis. Call getDocument without a version and pass the returned writeBasis unchanged." A basis accidentally taken from a different document falls into whichever of those two it happens to hit: stale_live_revision if that document sits at a different live revision, invalid_write_basis if the revisions match but the content does not.
The retry rule is: read again, look at what actually changed, then retry. Do not blindly replay the same ops against the new basis; another writer may have already done the work or moved the keys you targeted.
Write responses carry writeConfirmed, persistedVersion, a fresh writeBasis, and sometimes background. writeConfirmed: true means the write is journaled in the durable live document and every current live read returns it. persistedVersion names the persisted head, which may still lag the live state until the automatic snapshot lands; chain the next write with writeBasis, never with that number. background is omitted when nothing needs attention. Its pendingProjections (for example version_snapshot, reference_graph, search_projection) are derived work that converges unattended; they never make a confirmed write incomplete, so do not poll and do not repeat the write. Its projectionIssues each carry a state (retrying, blocked, degraded, unavailable), a code, and a nextAction. retrying has nextAction: "none". A blocked version snapshot with code document_size_limit has nextAction: "split_document": the fix is splitting the document.
createDocument
return await documents.createDocument({
organizationId: "019f8b2c-4d71-7c3a-9e88-2a4b6d105f39",
typeId: "019faa93-b19f-7599-a25a-d6a36ef4a49f",
creationId: "019faa94-2c1e-7b3d-9a41-6e2f8c0d5b17",
title: "Retention policy review",
metadata: { status: "draft" },
draft: {
markdoc: "# Retention policy review\n\nFirst pass at the quarterly review.",
},
})organizationId, typeId, and title are required. Use listTypes / searchTypes and getType before guessing a type.
creationId is optional and is an idempotency key only. It must be a UUID version 7; a version 4 UUID is rejected. Mint one with async () => await ids.mintOperationIds({ count: 1 }) in a separate preparation run (the helper must be the first and only provider call), or mint a UUIDv7 in the host. Preserve it outside the sandbox and paste it as a literal so a retry can reuse the exact value. The server mints the document id independently. On a timeout or ambiguous failure, retry with the same creationId: the retry returns the existing document rather than creating a duplicate, with action: "alreadyBootstrapped" instead of "created", and the retry's draft is ignored. Reusing a creationId with a different title, type, metadata, or creator fails with "Existing document creation identity has incompatible input." Never derive the key from content, and never reuse it across documents.
title is trimmed and must be 1 to 512 characters after trimming.
metadata is validated against the type. If the type declares no metadata schema and you pass anything other than null or {}, the call fails with "This document type does not define metadata". If the type does declare one and your object does not fit, the message begins "Initial metadata does not match the document type" and includes the failing issues.
draft.markdoc seeds version 1. Omit draft entirely to use the type's minimal initial draft; pass an empty string for a deliberately blank document. Caller-supplied _key attributes in the Markdoc are ignored: the server always assigns fresh keys, which is exactly why expanded Markdoc from another document can be pasted in without cleanup.
editDocument: the op grammar
One editDocument call is one atomic transaction and one undo step. ops is always an array with at least one entry. For tables, insertRow, replaceRow, removeRow, and setCell change one row or cell without rewriting the table. The five block ops are:
{ op: "replaceBlock", key, markdoc }{ op: "updateFields", key, fields }{ op: "insertBlock", markdoc, parent?, position? }{ op: "removeBlock", key }{ op: "moveBlock", key, parent?, position? }
const doc = await documents.getDocument({
documentId: "0199c8f2-1b5e-7a41-9d2c-6f0a3b5e1c77",
scope: { type: "outline" },
})
const heading = doc.outline.find(entry => entry.style === "h2")
return await documents.editDocument({
documentId: doc.documentId,
writeBasis: doc.writeBasis,
ops: [
{ op: "replaceBlock", key: heading.key, markdoc: "## Retention windows" },
{
op: "insertBlock",
markdoc: "Windows are reviewed every quarter.",
position: { after: heading.key },
},
],
})replaceBlock preserves the target's _key. Outside the table-cell case it must produce exactly one top-level block, and every paragraph and every list item parses to its own top-level block. It also refuses to silently retire descendants: if the replacement Markdoc drops an existing child _key, the batch fails with a message naming up to ten of those keys and telling you to carry them through, remove them explicitly earlier in the batch, or use removeBlock plus insertBlock.
updateFields sets structured top-level attributes only. _type, _key, children, markDefs, rows, and cells are rejected with "updateFields: field 'children' cannot be updated structurally". On a table cell only isHeader is accepted.
insertBlock always generates fresh server-authored keys, and its markdoc must be non-empty. The response's results[i].keys gives you those keys, in input order, which are what you use as slice anchors to verify placement.
removeBlock drops the block and its whole subtree. moveBlock relocates a block, preserving its _key. Neither works on a table cell: cells cannot be removed or moved independently.
position and parent
parent is a destination block _key; omit it or pass null for the document root. A parent that accepts no block children fails with "insertBlock: parent 'k' does not accept block children" (or "moveBlock: ..." for a move). A parent that holds inline text rather than block children says so and names the fix: edit its content with replaceBlock on one of its existing text children, or insert the new block beside the parent instead of under it.
position is "start", "end", { before: key }, or { after: key }, and defaults to "end". A named anchor must still be a sibling at the target location. If it is not, the batch fails rather than degrading to append: "insertBlock: position anchor 'k' is not a sibling at the target location, so 'after' has no defined position. If it was removed earlier in this batch, anchor on a block that still exists or use position 'start'/'end'."
Atomicity
Ops run in order, and the resulting tree is validated as a whole at the end of the batch. If any op fails, the whole batch is discarded and nothing is written; the error names the failing index, for example "editDocument failed at op 2: ...". That index is 0-based. One caveat worth knowing before you debug: failures from the final whole-tree validation (registry validation, duplicate _key, settings validation) are reported against the last op in the array, not against the op that actually broke the tree.
A block inserted earlier in the same batch cannot be targeted by a later op in that batch, because you do not know its key yet. Use a second editDocument call for follow-up edits, or replaceBlock on the existing parent when the change must be atomic.
Successful responses carry a structure echo: blockCountBefore, blockCountAfter, addedKeys, removedKeys, changedKeys, movedKeys (each capped at 20, with keysTruncated), and a regionOutline of the touched region capped at 20 entries. That is usually enough to confirm placement without a follow-up read.
patchDocumentEnvelope
Title and metadata do not live in block content, so they are not editable with editDocument.
const doc = await documents.getDocument({ documentId: "0199c8f2-1b5e-7a41-9d2c-6f0a3b5e1c77" })
return await documents.patchDocumentEnvelope({
documentId: doc.documentId,
writeBasis: doc.writeBasis,
title: "Retention policy review (Q3)",
metadata: { status: "in_review" },
})At least one of title or metadata must be present, otherwise the call fails with "At least one document field must be provided". metadata merges into the stored object: a field you pass replaces the stored value, a field you pass as null is removed, and a field you leave out is kept. A nested object value replaces the stored one whole. Pass metadata: null to clear all metadata. The merged result is validated against the type, including the same "This document type does not define metadata" rejection, but note the mismatch wording differs from create: here it begins "Document metadata does not match the document type". Use patchSettings for template and layout settings, which is a separate method with its own merge and replace semantics.
Citing another document: documentRef
The inline citation annotation is a Markdoc tag, and both attributes are required:
{% docref documentId="0199c8f2-1b5e-7a41-9d2c-6f0a3b5e1c77" blockKey="b7f2a1" %}the retention policy{% /docref %}Take the target blockKey from getDocument on the target with scope: { type: "outline" }; the first outline entry works for a whole-document link. Because a table is one outline entry, cite a cell by reading that table with { type: "block", blockKey } and picking from the returned scope.blockKeys. Version pinning is server-managed: you author documentId and blockKey only.
The target must be an active, non-deleted document that you can read. It may live in any organization, your own or another — cross-organization citation is supported, and the target organization may be private. Every reader of your document sees that the citation exists, with the target's identity, pinned and current version, and drift state; the target's content resolves only for readers who can themselves read the target's organization. A reference that fails the check rejects the write with "documentRef target does not exist, is not readable, or has been deleted." — missing, foreign-unreadable, and access-denied targets share this one message deliberately, so a rejected write never confirms whether a document exists — where the error also names the sourceBlockKey of the block in the document you are writing, so you know which block to fix. A pin whose version or block is gone fails separately with "documentRef pinned version no longer exists or is unavailable in the target document" or "documentRef target version or block does not exist in the target document".
The target may be the document you are writing. Then the docref is a jump to one of its own blocks: it needs a blockKey the document has, or one added earlier in the same batch, is stored without a version pin and is not part of the reference graph. A write that adds or retargets a jump to a missing block, or removes a block a jump still names, fails with block_not_found; a jump that was already broken before the write never blocks it.
If you are importing markdown that links between documents by title, create all documents first, then run a second pass per document with editDocument and resolveInternalLinks: true. That rewrites [[wikilinks]] and relative links like other-note.md into docrefs where the title matches exactly one same-organization document. Ambiguous titles, external links, and anchors are left untouched.
Archive, restore, delete
These three are different acts and are not interchangeable.
archiveDocument({ documentId }) moves a document out of the default retrieval set without changing anything about it. Content, versions, citations, and updatedAt are untouched; it stays readable by id and citable by other documents. It is idempotent and attributed: archiving an already-archived document succeeds and keeps the original archivedAt and archiver. Reach archived work by passing archived: "include" or "only" to listDocuments and search.
restoreDocument({ documentId }) clears the archive state and its attribution completely, leaving a document indistinguishable from one never archived. Search vectors were kept throughout, so nothing is re-embedded; ranked search shows the document again once the index picks up the cleared archive mark, while listDocuments and getDocument see it at once. Restoring an active document succeeds unchanged.
deleteDocument({ documentId }) performs the platform's normal soft delete: the document is marked deleted, removed from normal reads, listings, and search, and moved to the trash bin. The tool itself removes nothing for good.
Deleted documents can be restored for 30 days; after that their content is removed for good. listDocuments with deleted: "only" lists the trash bin, newest deletion first. Each row carries deletedAt, deletedByUserId when the deleter is known, and restorableUntil, the restore deadline. restoreDocument({ documentId, from: "trash" }) brings a row back as the same document: same id, links, version history and comments, and still archived if it was archived before it was deleted. Pass from: "trash" whenever you restore from the bin, so that a retry of a completed restore stays a no-op. Canonical reads and listings see the restore immediately; search follows after the normal indexing delay. Lens layouts and published posts are not brought back; add them again by hand. Comment documents are not listed in the bin.
Archived documents still count toward the organization's document limit and deleted ones do not; Limits and capacity says when to delete rather than archive.
Writing documents faithfully
Getting text into a document is easy. Getting exactly the text you meant, and knowing that you got it, takes a handful of platform behaviors that stay invisible until they bite. They all resolve to one habit: write, verify with a read, and compare the right thing.
The serializer is canonical, not literal
Markdoc storage round-trips your content, not every byte of your markup. The source you send is parsed into a block tree and re-serialized from that tree, so equivalent markup collapses to one canonical spelling. Two cases come up constantly.
Ordered lists are stored as structural list blocks, not as the digits you typed. Source ordinals are not preserved: every item re-serializes as 1., and display numbering is produced at render time. The rendered list counts correctly and the content is unaffected; only the stored markup differs from what you sent.
A bold run containing a code span re-serializes with the bold closed and reopened around the code:
**a `code` b** is stored as **a** **`code`** **b**Both render identically, because the tree behind them is the same: bold text, code, bold text.
The consequence is for verification, not for authoring. Never compare raw bytes or character lengths to decide whether a transfer was faithful: those differences are expected and mean nothing. Compare normalized content instead: fold ordered-list ordinals to a single form, fold the bold-splice back together, then compare. What survives normalization is a real difference.
Double underscores are markdown
A token like __tests__ written bare in prose is not a literal. Markdown reads the leading and trailing __ as bold delimiters, consumes them, and leaves a bolded tests in the document. The underscores are gone, and no error is raised: the write succeeded, it just wrote something else. The same happens to any identifier carrying paired double underscores, such as __init__.py or __dirname.
There are two fixes. Escape each underscore, as in \_\_tests\_\_, which survives the round trip and renders as plain text. Or put the token in a code span, which is the reliable form for file paths and identifiers and is usually what you wanted typographically anyway. Prefer the code span; reach for escapes only when the token has to sit inside running prose you do not want to break up.
Durable saves are debounced, and reads keep the document awake
An accepted write is immediately correct and already durable. The live document holds your edits the moment the call returns. Until the version snapshot is flushed, background.pendingProjections may name version_snapshot. Nothing is wrong.
That flush is debounced on edits; ordinary reads do not reset the save deadline. Verify content with one read, then stop reading; the flush follows on its own.
Read the save fields for what they actually claim. pending and retrying describe the snapshot, not your data. lastSaveError: null together with nextRetryAt: null means no failure has occurred and there is nothing to act on.
Assembling a long document in chunks
A long document goes in as one createDocument for the opening chunk, followed by insertBlock appends for the rest:
await documents.editDocument({
documentId,
writeBasis,
ops: [{ op: "insertBlock", position: "end", markdoc: chunk }],
})Four rules make this reliable. Split only at blank lines, and never inside a code fence: a chunk that ends mid-fence is stored as an unterminated code block and the next chunk opens a new one, so the text lands but the structure does not. Re-read writeBasis before each append, because your own previous append invalidated it as surely as anyone else's write would. Use position: "end" for a plain append, and position: { after: key } when the chunk has to land somewhere other than the tail. Keep chunks well inside the argument-size ceiling rather than at it, so a chunk never has to be re-split after a rejection.
Then verify the finished document in two passes: an outline read, to confirm the headings appear in the expected order and count, and a seam check, reading across each join to confirm the last line before it and the first line after it are each present exactly once. Duplicated or vanished seam lines are the failure mode chunked assembly actually produces, and neither one shows up in a heading count.
creationId is the duplicate-proofing
createDocument accepts a caller-owned creationId. Mint one with ids.mintOperationIds in a separate preparation run (or in the host), preserve it outside the sandbox, paste it as a literal, and reuse the same value on every retry of that create: the same creationId can never create twice. Do not mint it anew inside the mutation run, since a retry would receive a fresh value and could create a second document.
It is duplicate-proofing, not confirmation. A returned success is a lower bound on what reached the product, so cross-check with a read before deciding whether anything still needs to be retried. Errors, receipts, and safe retries covers the replay semantics and what a contradictory replay does.
The habit underneath all five: write once, verify with a single read, and compare normalized content rather than bytes.
charCount counts text, not markup
charCount is the length of the extracted text body, not the length of the Markdoc. Heading markers, list bullets, link syntax, and docref attributes do not count; span text, code block contents, and table cell text do. Top-level blocks are joined with a blank line, table rows with a newline, and cells within a row with " | ", so those separators are included. On a live read charCount and blockCount describe the state that read returned, including edits not yet snapshotted; on a historical read they describe the requested version.