LibraryDocuments Namespacev17 · Updated Report

Documents Namespace

Integration Identityintegration
Namespace
documents

Read, write, organize, and discuss the documents an Oddlynew organization keeps: the durable knowledge plane, its document types, its metadata query plane, its citation graph, and its comment threads.

Integration Capability24
AccessMethodSide EffectDescription
readsearchNo

This is the search method. Returns an envelope { results, limitApplied, truncated, ... }, never a bare array, and an empty results array is the only miss signal; a nonempty response never carries a confidence verdict. The query text and time may be recorded in the target organization's private activity log.

readlistDocumentsNo

Deterministic catalog listing by raw facts: type, creator, title, size, dates. Returns { documents, hasMore, nextCursor, documentUrlBase, ... }, not an array. limit caps at 50 and defaults to 20, and the type filter is typeIds, an array; there is no typeId parameter here.

readgetDocumentNo

Read one document by documentId. It takes no organizationId: the document's own organization is resolved from its id. scope decides how much content is returned and is the single largest cost lever in this namespace; the default is metadata only.

readgetBacklinksNo

Paginated inbound citations from the current visible persisted graph, one row per citing occurrence, so the same source block citing one target twice yields two rows. Returns { entries, hasMore, nextCursor, graphBasis }. There is no historical mode: these rows never describe a pinned version snapshot.

readlistMetadataValuesNo

Enumerate the values one declared text metadata attribute actually carries, with a document count each. Text-kind attributes only: a number, datetime, or boolean field fails with the kinds it is declared under. Returns { field, values, truncated, declaringTypeIds }.

readlistTypesNo

List the document types you can create in one organization, platform types included, with the plain Markdown type first. Server owned types such as Comment and Deleted document are left out unless you pass includeServerOwned: true. Returns { types, hasMore, nextCursor }. Every row carries familyLatestTypeId, so drift from the family head is detectable without a second call.

readsearchTypesNo

Keyword search over type name, description, and tags, with the same { types, hasMore, nextCursor } envelope as listTypes.

readgetTypeNo

Read one type by exact id and get everything needed to author against it: block schemas, markdocAuthoringGuide, root content rules, settings and metadata schemas, minimalDraft, and family revisions. searchAttributes is where filterability is discovered, each field with its kind and its legal metadataFilters operators. This method takes typeId and also requires organizationId, including for a platform type.

writecreateTypeYes

Create a type only when nothing from listTypes or searchTypes fits. Pass sourceTypeId to revise or fork an existing type: that is the revision path, there is no updateType. Every custom block must be reachable from a root content rule or from a reachable block's children.

writearchiveTypeYes

Retire one superseded organization-owned type revision. It leaves listTypes and searchTypes and createDocument rejects it, while existing documents keep their typeId and stay readable, editable, and citable. Platform types cannot be archived, there is no restoreType, and repeating the call preserves the original archivedAt.

writecreateDocumentYes

Create a document with its content as version 1. creationId is an optional caller-owned UUIDv7 and is the only thing that makes a retry safe: it is stored, so replaying it returns the existing document instead of a duplicate, and without it a retry creates a second document. The server always mints the document id and always assigns fresh block _key values, so Markdoc read from an authoring scope round-trips verbatim into draft.markdoc.

writeeditDocumentYes

Apply an ordered batch of block ops against a fresh writeBasis. Every op names its verb in op (not type) and its target in key (not blockId), and the whole batch is one atomic transaction and one undo step: if any op fails, nothing lands. The response carries a structure echo of what changed, so a placement check usually needs no follow-up read.

writepatchDocumentEnvelopeYes

Rename a document or change its metadata, against a fresh writeBasis. metadata merges into the stored object: pass only the fields to change, null for a field to remove it, or metadata: null to clear everything.

writepatchSettingsYes

Patch template, layout, and other document-level settings against a fresh writeBasis. Settings merge by default; replace: true swaps the whole object, and clearing settings requires settings: null together with replace: true.

writereclassifyDocumentYes

Move one document to a different type without changing its identity or content. This is the retype operation; there is no retypeDocument. It requires the current writeBasis plus the current type as expectedTypeId, validates the exact content, settings, and metadata against the target type, and refuses incompatible state.

writearchiveDocumentYes

Move a document out of the default retrieval set without moving it out of existence. Nothing about it changes, updatedAt included, and it stays readable by id and citable. Idempotent and attributed: a repeat call preserves the original archiver and archivedAt.

writerestoreDocumentYes

Return an archived document to the default retrieval set, clearing the archive state and its attribution completely. Canonical reads see it immediately; ranked search catches up asynchronously.

writedeleteDocumentYes

Normal soft delete: the document is marked deleted and leaves reads, listings, and search. Content history is not hard-deleted by this tool. Archive is the reversible tool; reach for delete only when the document should stop existing.

writerepairDocumentJournalYes

Bounded recovery for a document a live read explicitly reports with journalStatus: "journal_corrupt". It refuses a healthy journal and refuses a stale basis, preserves document id and version history, and requires an explicit acknowledgement that unreplayable journal-only state may be discarded. Preserve any intended edit separately first.

writereadRescuesNo

Read what the server set aside before it discarded a document's unsaved live state, for example after a reset to the last version or a repairDocumentJournal (whose receipt names the rescueId). With only documentId it returns the document's rescues, oldest first. With a rescueId it also returns that rescue as JSON text of { content, settings } a page at a time: pass rescue.nextCursor back as cursor with the same rescueId, join the rescue.json pages, then JSON.parse. A document keeps its five newest rescues; one older than 30 days is dropped when the next is archived. Changes nothing, but needs write access, the same right that lets editors view and restore rescues. The content is not checked against the type: put entries back with editDocument, which checks them.

writecreateCommentYes

Open a comment thread anchored to a block, for a question or request about what that block says, so it sits in the place it belongs. A quote must be copied verbatim from a read of that block; one that is not present is rejected rather than stored pointing at nothing. Commenting changes no content and cuts no version, but it does advance the host document's live revision.

writereplyToCommentYes

Append a message to an open thread without closing it: ask back, report progress, or disagree. Pass clientKey when a retry is possible, because a message can commit and still report an error.

writeresolveCommentYes

Close a thread after addressing it, with a short note saying what changed. Resolving archives the thread's own comment document and the note becomes its last message. Takes an optional operationId you generate locally and reuse unchanged when retrying the same resolution; a keyless retry after another participant replied in between appends the note a second time. Resolving without an edit is fine when no change was needed as long as the note says so.

writeresolveEmbedYes

Resolve one supported embed URL into canonical provider metadata and an optional organization-owned thumbnail, scoped by the documentId whose organization and write authority it borrows. It never changes document content. Unsupported URLs and provider failures come back as a typed status: "degraded" result rather than an error.

Limitations

There is no documents.searchDocuments, no updateType, no restoreType, and no retypeDocument: the real names are search, createType with sourceTypeId, and reclassifyDocument, and archiveType has no inverse. Scope parameters cannot be invented: headingKey, anchor, and anchorBlockId do not exist, and the only addressing keys a scope accepts are blockKey, blockKeys, rootBlockKey, cursor, outlineCursor, and rows for a table window. Addressability has one rule everywhere, in reads, edits, hashes, and citations: root blocks, nested non-span blocks, and table cells have stable _key identity, while table rows and Portable Text spans are structural internals that cannot be read, pinned, or edited by key. Cells cannot be removed or moved on their own, and a whole-table replace that would regenerate existing cell identities is rejected. A block inserted earlier in an editDocument batch cannot be referenced by later ops in that batch, and a before or after anchor removed earlier in the batch fails the batch rather than quietly appending at the end. Ranked search is asynchronous and can lag a large write materially, so a document can be genuinely missing from search while listDocuments and getDocument already show it. There is no OR grammar in metadataFilters; filters AND together. Retracted or legally removed documents return a content-free terminal projection only, with no live or historical payload recoverable.

Usage

Every list-like method returns an envelope, never an array: listDocuments and getBacklinks and listTypes and searchTypes return { ..., hasMore, nextCursor } and are paginated by passing cursor until hasMore is false, search returns { results, limitApplied, truncated, ... } with no cursor because it is a bounded top-N sample, and listMetadataValues returns { field, values, truncated, declaringTypeIds }. Caps to plan against: limit maxes at 50 on listDocuments, search, getBacklinks, listTypes, and searchTypes, and defaults to 20; matchesPerDoc maxes at 5 and defaults to 1; orientation's outgoingLimit and backlinkLimit max at 20 and default to 5; a slice's above and below max at 50 and default to 3; a search query is at most 500 characters, a comment body at most 10,000, a comment quote at most 500, and a title at most 512. Reads take no id: search, listDocuments, listMetadataValues, getDocument, getBacklinks, getType, listTypes, and searchTypes are called as written, and each answer echoes the operationId the server minted for it. Pass that exact value back as operationId only to replay an ambiguous retry; a deliberate new call or next page takes none. Some writes take an optional caller-owned UUIDv7: creationId on createDocument and createComment, operationId on createType and resolveComment. It is your protection for an ambiguous retry, and what it buys differs. creationId is stored, so a retry carrying it returns the existing document or thread. The others ride on capacity admission: a retry it still recognizes is refused as a replay, which stops a second write but does not hand back the first one's result, and a retry admitted after that window runs again. Omit the key and every retry simply runs, which for a create means a second one, and for resolveComment means a repeated resolution note when somebody replied between the attempts. archiveType, archiveDocument, and restoreDocument are idempotent, so their operationId is optional and omitting it is safe. replyToComment takes an optional clientKey, worth passing whenever a retry is possible because a comment can commit and still report an error. resolveEmbed requires its operationId, because a duplicate there reaches an outside system. The remaining writes, editDocument, patchSettings, patchDocumentEnvelope, reclassifyDocument, deleteDocument, and repairDocumentJournal, take no key at all: writeBasis is their safety, and a stale one is rejected rather than applied. Generate every key you pass as a UUIDv7 on your own host before the first attempt and keep the literal. Call ids.mintOperationIds({ count }) only if your host cannot generate a UUIDv7, in a run of its own: it must be the only provider call in that run, because a key spent in the run that minted it dies with that run's response; it returns an envelope { operationIds } rather than a bare array, and count caps at 32. Reuse a key only for an exact ambiguous retry of the same intent; a deliberately new attempt takes a new one, accepting that the original may have completed.

Authorization

Two delegated OAuth scopes gate the namespace: mcp:read is required for every call, and every mutation additionally requires mcp:write. Behind the scope, authority comes from live role permissions on the document_management feature: reads need its read action, and creates and every mutation need its create or manage action. A caller who fails the permission check can still read a document whose organization is publicly visible, but public visibility never grants a write. organizationId is required on every organization-scoped method on this deployment, so ambiguous calls are rejected before dispatch rather than resolved against a default; discover the organizations available to you with the organization directory first. Document-scoped methods resolve authorization from the document itself and take no organizationId. Organizations in deletion quarantine have ordinary mutations fenced. Search queries against an organization you are a member of are recorded in its activity log alongside your connection, visible to that organization's log administrators; searches of organizations you are not a member of are recorded without any identity.

Composition

Documents is the durable knowledge plane the rest of the surface writes into: use the organization directory to resolve the organizationId every org-scoped method here requires, and ids.mintOperationIds in a run of its own only when the host cannot generate the UUIDv7 keys some writes here take; reads take none. Documents cite each other with the built-in {% docref %} annotation, which is what turns a corpus into a graph and what getBacklinks reads back. Comment threads are themselves documents of the deployment's registered Comment type, which is why a resolved thread is an archived document; reopenComment reopens it. resolveEmbed reaches an outside provider and can store a thumbnail as an organization-owned asset, the one place this namespace touches file storage.

Documents

What it does

The documents namespace is how an agent works with an Oddlynew organization's durable knowledge: find documents by meaning or by catalog facts, read exactly as much of one as the task needs, write block-level edits against a live collaboration state, classify documents with types and query them by declared metadata, cite across them, and discuss them in anchored comment threads. A document has a shell (id, title, metadata), a live state edited through the server, and an append-only chain of immutable persisted versions. Live writes land in the live state first and are already durable when confirmed; a new immutable version appears when that state flushes to the persisted head.

Two things decide whether a call is well formed before anything else. First, organization-scoped methods require organizationId on this deployment: search, listDocuments, listMetadataValues, listTypes, searchTypes, getType, createType, and createDocument all take it and fail without it. Second, document-scoped methods take documentId and no organizationId at all, because the document's organization is resolved from its id: getDocument, getBacklinks, editDocument, patchSettings, patchDocumentEnvelope, reclassifyDocument, archiveDocument, restoreDocument, deleteDocument, repairDocumentJournal, createComment, replyToComment, claimComment, resolveComment, reopenComment, and resolveEmbed. archiveType takes typeId together with a required organizationId, the organization that owns the type revision. getType likewise takes both a typeId and a required organizationId, because its diagnostic counts describe a specific organization's visible documents.

Choosing a read scope

getDocument returns what its scope asks for and nothing more, and picking the scope well is the difference between a cheap read and an expensive one. The scopes are these, exactly: omit scope or pass the bare string "envelope" for metadata only; { type: "orientation", outgoingLimit?, backlinkLimit?, outlineCursor? } for a context card, provenance, citation health, neighborhood samples, and a byte-bounded outline window with no prose; { type: "outline", rootBlockKey?, cursor? } for block previews and keys to navigate by; { type: "block", blockKey, rows? } for one exact addressable node, where a table window uses rows: { from, to?, after? }; { type: "blocks", blockKeys } for up to 50 keys in one read; { type: "section", blockKey } for a heading plus everything under it; { type: "slice", blockKey, above?, below? } for a window of neighbors around a block; { type: "read", maxChars?, cursor? } for the readable document without editing identities; and { type: "authoring" } for complete round-trip Markdoc plus outgoing citation diagnostics, with { type: "expand" } surviving as its deprecated spelling.

Those are the only addressing parameters that exist. There is no headingKey, no anchor, and no anchorBlockId; a heading is addressed by its ordinary block key through the section scope, and every key comes from an outline entry, a scope.blockKeys list, or a search match. The caps are small and worth remembering: outgoingLimit and backlinkLimit accept 0 to 20 and default to 5, a slice's above and below accept 0 to 50 and default to 3.

Pick by workflow rather than by size. To understand a document, use read and follow scope.nextCursor while it is present, concatenating content in page order. To navigate before acting, use orientation or outline. To prepare an edit, start with outline and then read the smallest block, section, or slice that covers the change. Reserve authoring for a genuine round trip of the source, because it returns everything. A section anchored on a heading is sized by that section and is not capped, so anchoring on the only h1 in a document returns the whole document: read the outline first, whose entries carry style, when the section's size is unknown. A non-heading key comes back as section_anchor_not_heading with the document's real heading keys, and an unknown key comes back as block_not_found with a sample of keys that do exist, so a wrong guess is always recoverable from the response itself.

The outline scope has a stated shape on a current read: the document envelope with its live title, metadata, updatedAt and currentVersion, plus persistedVersion, writeBasis, stateBasis, journalStatus when the journal is corrupt, settings, size when it is not ok, any open comments, the outline entries and scope. It never carries charCount, blockCount or background; those live in the envelope scope, so read the envelope when you need the counters or the projection state and read the outline when you need keys. Each outline entry may carry references, the documents its documentRef annotations target with an occurrence count per target, counted after deduplication; a table is one entry, so its cells' references are attributed to the table, while nested blocks keep their own. references is present only on entries that have at least one, and only on a current read. A historical outline read keeps the ordinary read shape.

Two continuation tokens exist and neither is interchangeable. orientation may return nextOutlineCursor, passed back unchanged as scope.outlineCursor, and continuation pages are deliberately lean: they omit the envelope, settings, state, context card, citation health, and neighborhood that the first page already returned. read may return scope.nextCursor, passed back unchanged as scope.cursor. Both are scoped to one document and one exact state, so they never move between documents, between live and pinned reads, or between pinned versions; if a live edit invalidates one, restart without a cursor or pin a version.

// Reads take no id; each answer echoes the operationId the server minted for it.
const organizationId = '019f9575-3a2b-7c11-9f00-1c4a2b7d5e88'

const found = await documents.search({
  organizationId,
  query: 'data retention policy',
  limit: 5,
})
if (found.results.length === 0) throw new Error('no match')
const top = found.results[0]

// Navigate before paying for prose, at the version that was searched.
const outline = await documents.getDocument({
  documentId: top.documentId,
  version: top.version,
  scope: { type: 'outline' },
})
const heading = outline.outline.find(entry => entry.style === 'h2')

const section = await documents.getDocument({
  documentId: top.documentId,
  version: top.version,
  scope: { type: 'section', blockKey: heading.key },
})

The example shows the flow; live server validation inside run is authoritative for exact parameters.

Editing: writeBasis, op shapes, and the batch rule

Every content write chains on a writeBasis. Read the document first without version, using the smallest useful scope, and pass the returned token unchanged: do not parse it, increment it, or substitute persistedVersion for it. It pins the live revision it was read at, so a write against a since-changed document fails with stale_live_revision rather than clobbering the change. Recover by reading again and deciding whether the edit still applies. A successful write returns a fresh writeBasis for the next one, so a chain of edits needs exactly one read at the start.

The ops are replaceBlock, updateFields, insertBlock, removeBlock, and moveBlock. Two field names are where calls most often go wrong: the verb field is op, not type, and the target field is key, not blockId. Positioning is expressed by parent and position, where position is "start", "end", { before: key }, or { after: key }; an op has no top-level before or after of its own. Use replaceBlock when body text, inline content, or nested children change, updateFields only for structured top-level attributes and never for body text, insertBlock to add, removeBlock to delete a block and its subtree, and moveBlock to reorder or reparent without rewriting content.

One call is one atomic transaction and one undo step, so a batch that fails anywhere lands nothing. Within a batch, a block inserted by an earlier op cannot be targeted by a later one, and a before or after anchor removed earlier fails the batch instead of silently appending at the end. When later ops need to see an insert, split into two calls. replaceBlock preserves the target's _key, and replacing a parent must carry every surviving descendant key through in the Markdoc: omitting one is rejected rather than silently breaking inbound citations. Outside the empty table-cell case, replaceBlock must produce exactly one top-level block, so count the Markdoc before choosing it, because every paragraph and every list item parses to its own block.

Citations are the other common failure. An inline reference is {% docref documentId="..." blockKey="..." %}anchor text{% /docref %} and both attributes are required; a docref with only a documentId is rejected. Get the blockKey from an outline[].key on the target document, where the first outline entry serves for a whole-document link, and note that the outline keeps a whole table as one entry, so citing a cell means reading that table with a block scope and picking from the returned scope.blockKeys. A rejected write names each failed reference with its coordinates and its own reason, while healthy references in the same document are not listed. A docref to a block of the same document is allowed: it is a jump to that block, stored without a pin and never a citation or backlink; 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, while a jump already broken before the write is tolerated. To append a row to a table, send one insertRow op with the table key and the row as one Markdown line, for example { op: "insertRow", key: tableKey, cells: "| Ada | Lead |" }, instead of rewriting the table.

// Reads take no id; the outline read echoes the operationId the server minted for it.
const read = await documents.getDocument({
  documentId,
  scope: { type: 'outline' },
})
const target = read.outline.find(entry => entry.preview.startsWith('Retention'))

const written = await documents.editDocument({
  documentId,
  writeBasis: read.writeBasis,
  ops: [
    { op: 'replaceBlock', key: target.key, markdoc: 'Retention is reviewed every quarter.' },
    { op: 'insertBlock', markdoc: 'Owner: platform team.', parent: null, position: { after: target.key } },
  ],
})

// The structure echo already says what changed; writeBasis chains the next write.
const insertedKeys = written.results[1].keys
const nextBasis = written.writeBasis

The example shows the flow; live server validation inside run is authoritative for exact parameters.

Finding documents: search, catalog, and the widening ladder

search finds documents by content or meaning, listDocuments browses or enumerates what exists, and the two are not substitutes. Search returns a bounded top-N sample from the current persisted head with matching passages in matches[], ranked by a score comparable only within one mode. The modes are hybrid (default, keyword plus semantic), semantic, fts, and exact, a literal, case-insensitive substring match with no regular expression mode. Every mode keeps a response cheap to skim with compact band: "tail" rows that carry no excerpts and are still real matches: in hybrid and semantic every row after the first five (raise excerptRows for more), in fts and exact the rows far below the top score. truncated reports whether the pass was capacity-limited, which is a real per-call signal in hybrid, fts, and exact modes and expected background noise in semantic mode, where nearest-neighbor retrieval always fills its pool.

listDocuments is a single canonical PostgreSQL query, so its filters always apply exactly and an empty page really means nothing matches. It is also the right tool when the title is already known, through the title filter with match: "exact", "prefix" or "contains" (a part of the title), which is cheaper than searching for a title you can spell. Use it as the canonical freshness check too: ranked indexing is asynchronous, so while a live read names search_projection in background.pendingProjections, a recent write may legitimately be invisible to search while listDocuments and getDocument already see it.

When a query comes back empty, widen deliberately rather than concluding nothing exists. Try a second phrasing or a different mode, widen the date window, and only then widen the archive scope: archived is shared by listDocuments, search, and listMetadataValues, and it takes "exclude" (the default), "include", or "only". Every archived row carries archivedAt, so an answer can always say which scope produced it.

Types and metadata as the query plane

Types are versioned in families. listTypes and searchTypes discover them and every row carries familyLatestTypeId to detect drift from the head, plus searchAttributes naming each declared field with its kind and its legal metadataFilters operators, so a listing already says which types are filterable on what. getType returns the full runtime definition, including a concrete markdocAuthoringGuide to consult before writing Markdoc for custom blocks, and the same searchAttributes with the per-organization projection diagnostics a listing leaves out. Platform types are visible to every organization; organization-owned types are not. Create a type only when nothing fits, and pass sourceTypeId to revise or fork rather than looking for an update method, which does not exist.

Filterable metadata is declared on the type, never on the document. The loop is declare, project, filter: getType(typeId).searchAttributes names the queryable fields with their kind and the operators each one legally takes, listMetadataValues(field) lists the values a text field actually carries here with counts, and metadataFilters on listDocuments or search then filters on them. Enumerate before filtering on an open field, because inventing a value returns an empty page rather than an error, while a field no visible type declares fails the call and names every declared field, so a typo can never read as an empty corpus. Kinds decide operators: text takes eq, in, and prefix; number and datetime take eq, in, and range; boolean takes eq. Range bounds are from and to, at least one of them, inclusive at both ends. A datetime operand is read at the granularity it was written in, so a date-only "2024-06-30" means the whole UTC day and works as a to bound covering that day; add a time when the bound is an instant.

// Reads take no id; each answer echoes the operationId the server minted for it.
const organizationId = '019f9575-3a2b-7c11-9f00-1c4a2b7d5e88'

const type = await documents.getType({ typeId, organizationId })
const filterable = type.searchAttributes.map(attribute => attribute.field)

const vocabulary = await documents.listMetadataValues({
  organizationId,
  field: 'collection',
})

const page = await documents.listDocuments({
  organizationId,
  typeIds: [typeId],
  metadataFilters: [{ field: 'collection', op: 'eq', value: vocabulary.values[0].value }],
  limit: 50,
})
// Envelope, not an array: read page.documents and follow page.nextCursor while page.hasMore.

The example shows the flow; live server validation inside run is authoritative for exact parameters.

Comment threads

Every live read returns openComments on the document: threads anchored to a block, each with an id, blockKey, quote, anchorStatus, and its messages. Treat them as work requests. Address the ones touching blocks you edit, then resolve them with a note saying what changed. anchorStatus tells you how much to trust the anchor: attached means the block still contains the quote, drifted means the block changed since the comment was written and the request may already be handled, and orphaned means the block is gone and the quote is all the context there is. Pass includeResolvedComments on a live read to see recent resolutions before re-deciding something already settled, including by you; it does nothing on a read pinned to a version, because comment state is current-state only.

Write through the comment methods rather than editing thread documents directly, because only these stamp authorship server-side. createComment opens a thread about what one block says (a question for a person that reaches beyond the block is kept as Ask a person, and keep the answer describes), replyToComment answers in it (with progress: true for an interim status), claimComment says you took it over, resolveComment closes it and reopenComment brings it back. Comments explains whose turn each of these leaves the thread in. Anchoring rules are strict on purpose: a blockKey must exist in the document, the block must have text to anchor to, and a quote must appear in it verbatim, each rejection naming the read that would have produced a usable anchor. Comments never change content and never cut a version, but every comment mutation advances the host document's live revision, so a stale-write error can simply mean a new comment arrived: re-read, look at it, then retry.

// A UUIDv7 generated on your own host and kept as a literal, so an ambiguous retry reuses it.
const opResolve = '019f9579-0002-7000-8000-0000000000d2'

// Reads take no id; the outline read echoes the operationId the server minted for it.
const doc = await documents.getDocument({
  documentId,
  scope: { type: 'outline' },
  includeResolvedComments: true,
})

// One operationId belongs to one resolution: a second thread needs its own literal.
const thread = (doc.openComments ?? []).find(entry => entry.anchorStatus !== 'orphaned')
// Make the requested edit first, chaining on the writeBasis from this read.
await documents.resolveComment({
  documentId,
  commentId: thread.id,
  operationId: opResolve,
  note: 'Rewrote the paragraph to name the quarterly review owner.',
})

The example shows the flow; live server validation inside run is authoritative for exact parameters.

Reading a write receipt, and what background means

A successful write returns writeConfirmed: true, and that is the completion boundary: the write is journaled in the durable live document and current unversioned reads already observe it. Nothing is left to poll, wait for, or re-apply. persistedVersion is the immutable snapshot head and may legitimately lag; writeBasis, not that number, chains the next write.

Optional background describes derived projections independently and disappears once everything is current. pendingProjections naming version_snapshot, reference_graph, or search_projection never makes a confirmed write incomplete; wait only when the very next operation reads one of those projections and stale data would change its result. projectionIssues carries a nextAction per condition, and the honest reading of it is literal: none means the work converges unattended, while split_document, repair_document_refs, refresh_live_read, or use_non_ranked_reads name a real move. Never duplicate a confirmed write because a projection is pending.

One read result deserves its own reflex. A current read normally reports stateBasis: "durable_live". If it instead reports "persisted_recovery" with journalStatus: "journal_corrupt", the content is a read-only persisted-head fallback and not current live truth. Preserve any intended edit separately, then pass that read's writeBasis to repairDocumentJournal with the explicit data-loss acknowledgement, and use the fresh basis it returns.

For discovery, ask guide a direct question such as How do I read and edit Oddlynew documents?. For exact parameters, live server validation inside run is authoritative.

Oddlynew PublicSign in