Errors, receipts, and safe retries
Errors are documentation
Oddlynew exposes two MCP tools. guide is the read-only discovery surface: ask a natural-language question and read the maintained guidance it returns. run executes JavaScript against the live namespaces. You do not need to guess blindly: guide teaches the workflow, and when an exact call is wrong the server error includes the live signature.
Call a method that does not exist and you get the whole callable surface back, not a scolding:
Tool "getDocumnet" not found. Available: createDocument(input: { organizationId: string; typeId: string; creationId?: string; ... }); editDocument(input: { documentId: string; writeBasis: string; ops: [...] }); getDocument(input: { documentId: string; version?: number; scope?: ... }); ...Pass a parameter that does not exist, and the error suggests the nearest real one and prints the compact signature of the method you were calling:
Unknown parameter "documentID" for getDocument. Did you mean "documentId"? Signature: getDocument(input: { documentId: string; version?: number; scope?: ... })Omit a required one and you get the same signature rather than one key at a time:
Missing required parameter "typeId" for createDocument. Signature: createDocument(input: { organizationId: string; typeId: string; ... })The signature bodies are rendered from the live schema when the error is raised and collapsed onto one line: the unknown-method list cuts each signature off past 220 characters, while parameter errors carry the method's full signature (up to 20 KB), and a missing-parameter error also quotes that parameter's description, so the field list you actually see may be longer or shorter than the examples above.
The rule behind all three: one round trip should be enough to correct yourself. A schema failure deeper inside a method appends Signature: ... to the decode diagnostics for the same reason.
The outer MCP boundary teaches the same way. An unknown top-level key on the run or guide tool call itself is rejected before the tool runs, with Unknown parameter. Accepted parameters: followed by every accepted key, so a mistyped tool parameter also costs one round trip rather than a guess.
Where an error actually appears
run returns an envelope with code, result, bounded logs, and a host-recorded calls ledger whenever provider calls ran. An uncaught provider rejection, a bare throw, a timeout, or the 64-call budget breach arrives as a failure envelope instead of an output-schema error. This preserves the public error message, bounded logs, and the available provider-call ledger. Do not catch every provider call only to expose diagnostics. Catch a failure when it is expected and the workflow can return an honest partial result. Calls completed before a failure may already have taken effect, so read current state before you retry an ambiguous write.
Use try / catch when a provider failure is an expected part of a bounded workflow, such as continuing a per-item sweep after one inaccessible item. A handled rejection does not fail the run: the run settles successfully, and the call stays visible in calls as failed_but_handled, so a partial result cannot silently look like a complete one. Catching does not surface the message on its own, so return the caught error text as part of your result if you want to read it.
const me = await orientation.whoami()
return { userId: me.userId, docsOrg: me.documentationOrganizationId }Some misses are results, not errors
Not every miss is a failure. documents.getDocument answers several kinds of miss with a normal return value carrying an error discriminant plus the information you need to correct the call:
{ error: "block_not_found", requestedBlockKey, availableBlockKeys }whereavailableBlockKeysis a sample of real keys (capped at 50).{ error: "section_anchor_not_heading", requestedBlockKey, blockType, style, headingBlockKeys }when you anchored a section scope on a block that is not a heading.{ error: "version_not_found", requestedVersion, currentVersion, nearestSurviving }with the nearest older and newer surviving versions.
So branch on the shape rather than assuming a read either throws or hands back content:
const read = await documents.getDocument({
documentId: "0199c3a1-6f0e-7b2d-9c11-4a1c2d3e4f50",
scope: { type: "block", blockKey: "b7f2a9" },
})
if (read.error === "block_not_found") return { retryWith: read.availableBlockKeys.slice(0, 5) }
return read.scope.blockKeysBlockers are not failures
The cross-organization copy workflow separates "this cannot proceed" from "this went wrong". organizations.planDocumentCopy writes nothing to the destination. If the source is not eligible, the plan still succeeds and returns a receipt whose state is "blocked", with a blockers array. Every blocker carries code, message, and resolution, so the receipt tells you what to change:
return await organizations.planDocumentCopy({
copyId: "0199d1a4-6f2a-7e0b-9a2c-1d3e5b7c9f01",
sourceDocumentId: "0199c3a1-6f0e-7b2d-9c11-4a1c2d3e4f50",
destinationOrganizationId: "0199aa02-1c44-7c31-88ef-9d2b4c6a8e10",
})A blocked receipt looks like this (abridged: the real receipt also carries operationId, copyId, protocol, source, destination, planToken, copiedAssets, planExpiresAt, completedAt, retryAfter, and disclosure):
{
"state": "blocked",
"nextAction": "create_new_plan",
"blockers": [
{
"code": "source_not_clean",
"message": "The source does not currently have one clean persisted snapshot.",
"resolution": "Save or settle the source document, then create a new plan."
}
]
}Snapshot-copy v1 is deliberately narrow, and the blocker codes say exactly how: source_archived, source_type_unsupported, metadata_unsupported, content_association_unsupported, source_not_clean, document_references_unsupported, outside_image_unsupported, asset_references_unresolved, asset_file_too_large, asset_capacity_exceeded, and settings_unsupported. New plans copy the document's stated language, while historical receipts may still contain language_unsupported. Files the document references are copied with it; the three asset codes name a file that does not resolve, is too large for its type, or would not fit the destination's file capacity. Each code names a change to the source or the destination, and one plan reports all of them at once, so you fix the set rather than discovering them one at a time.
Retrying is the one thing that cannot help here. Replaying the same copyId returns the recorded receipt, blockers included, even after you have fixed the source. Once the resolutions are done, plan again with a fresh copyId.
Retryable, terminal, and what nextAction tells you
Once you call organizations.applyDocumentCopy, the receipt is your durable record. It carries state (blocked, ready, applying, completed, failed, expired), a failure object or null, and a nextAction that is one of apply, wait_then_retry_apply, retry_apply, create_new_plan, or done.
failure encodes the retry decision directly:
{ code: "apply_retryable", retryable: true, resolution: "Retry applyDocumentCopy with the same operationId and planToken." }{ code: "verification_failed", retryable: false, resolution: "Inspect the source and target, then create a new plan if appropriate." }{ code: "manifest_incomplete", retryable: false, resolution: "Create a new copy plan." }
Two sharp edges worth knowing. First, getDocumentCopyReceipt never advances work. There is no background copy pipeline: only applyDocumentCopy moves an unfinished copy forward. Second, when a receipt is in applying, it carries a retryAfter lease deadline, and you must not retry before it. retryAfter is null in every other state.
const receipt = await organizations.getDocumentCopyReceipt({
operationId: "0199c4b7-2d1a-7f04-b3c8-77e1a9f0c221",
})
return { state: receipt.state, next: receipt.nextAction, after: receipt.retryAfter }The provider-call ledger
When provider work needs accounting, the envelope includes calls: rows of { tool, outcome, count } where outcome is "succeeded", "failed_but_handled" (the call rejected, your code handled it, and the run continued), or "in_flight_unknown". The available ledger is returned on successful and failed runs when calls were recorded. The sandbox loses its local values when it ends, so this bounded ledger is the account you use to decide what to verify. A failed run also carries outcomes, one entry per call, write-capable calls first: { seq, tool, access, outcome }, plus the call's value when it succeeded, or omitted: true and the value's size in bytes when it did not fit the result.
Read it honestly, because the runtime is honest about it. succeeded means the call completed on the server; on a failed run its result is in outcomes. That says the call answered, not what it answered: evaluate the method's own result there before you treat the work as done. failed_but_handled means the call rejected and your code carried on, so a partial result stays legible as partial. in_flight_unknown calls never reported an outcome at all.
{
"error": "Execution timed out. Provider-call ledger in `calls`: `succeeded` calls completed on the server and each call's result is in `outcomes`; re-read any value marked `omitted` there and do not repeat succeeded writes. `in_flight_unknown` calls have no known outcome - check their effects with reads before retrying them.",
"calls": [
{ "tool": "documents.createDocument", "outcome": "succeeded", "count": 2 },
{ "tool": "documents.editDocument", "outcome": "in_flight_unknown", "count": 1 }
],
"outcomes": [
{ "seq": 1, "tool": "documents.createDocument", "access": "write_capable", "outcome": "succeeded", "value": { "documentId": "019faa94-2c1e-7b3d-9a41-6e2f8c0d5b17", "action": "created" } },
{ "seq": 2, "tool": "documents.createDocument", "access": "write_capable", "outcome": "succeeded", "omitted": true, "bytes": 48213 },
{ "seq": 3, "tool": "documents.editDocument", "access": "write_capable", "outcome": "in_flight_unknown" }
]
}The ledger tells you what to verify, never what to assume. Take the results of completed calls from outcomes, re-read any value marked omitted, and do not repeat a write that succeeded. Before retrying an in_flight_unknown call, read current state.
Idempotent retry rules, by operation
createDocument: replay with creationId
documents.createDocument accepts an optional creationId: a client-generated UUID version 7 used only as an idempotency key (a version 4 UUID is rejected). The server mints the document id independently. A retry with the same creationId returns the existing document instead of creating a duplicate, and the response's action field says which happened: "created" on the first success, "alreadyBootstrapped" on a replay. The replay ignores the retry's draft.
For a caller-owned key, run async () => await ids.mintOperationIds({ count: 1 }) as a separate preparation run; it must be the first and only provider call. Preserve the returned UUID outside the sandbox, paste it as a literal into the mutation run, and reuse it only for retries of that same intended operation. A UUIDv7 generated on your own host is also valid.
return await documents.createDocument({
organizationId: "0199aa02-1c44-7c31-88ef-9d2b4c6a8e10",
typeId: "0199b7de-3f21-7a90-8c44-2e5f7a1b3c6d",
creationId: "0199d1a4-3f2b-7c14-9d7e-5e6f7a8b9c0d",
title: "Q3 retrospective",
draft: { markdoc: "# Q3 retrospective\n\nWhat we learned.\n" },
})One guard: a replay that contradicts the original fails rather than reconciling. If the existing document's creator, title, type, metadata, language, translation source, explicitly supplied sourceVersion, deletion state, or lifecycle state conflicts with what you are now sending, you get Existing document creation identity has incompatible input. followed by UUIDv7 guidance, but without the conflicting field names; compare the original request with a read of the existing document. Do not reuse a creationId across documents, and do not derive it from content.
editDocument and the basis writes: not idempotent
documents.editDocument, documents.patchSettings, and documents.patchDocumentEnvelope have no idempotency key. They are guarded by optimistic concurrency instead: read the document live (call getDocument with no version), then pass the returned writeBasis unchanged.
const doc = await documents.getDocument({
documentId: "0199c3a1-6f0e-7b2d-9c11-4a1c2d3e4f50",
scope: { type: "outline" },
})
return await documents.editDocument({
documentId: doc.documentId,
writeBasis: doc.writeBasis,
ops: [{ op: "replaceBlock", key: doc.outline[0].key, markdoc: "# Q3 retrospective (final)" }],
})If the document changed since your read, the write is rejected with stale_live_revision:
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 malformed or invented token fails with invalid_write_basis: "Invalid document writeBasis. Call getDocument without a version and pass the returned writeBasis unchanged."
This is the operation to be most careful with after an ambiguous failure. Re-sending the same editDocument call blind can apply the same edit twice if the first one landed. Re-read writeBasis and the affected blocks first, then decide whether the edit is still needed. One editDocument call is one atomic transaction and one undo step, so a batch either applied entirely or not at all: there is no partial batch to reconcile.
Historical reads (getDocument with version) never return writeBasis, so you cannot accidentally write against a snapshot.
archiveDocument and restoreDocument: safe to repeat
documents.archiveDocument is idempotent and attributed. Archiving an already-archived document succeeds and keeps the original archivedAt and archiver, so a retry never rewrites who archived what, or when. documents.restoreDocument is idempotent the same way: restoring an active document succeeds unchanged. Neither writes a version or changes content, and neither moves updatedAt.
planDocumentCopy and applyDocumentCopy: two keys, two scopes
The copy workflow uses two different keys. copyId is yours: a UUID version 7 used only for plan idempotency (version 4 is rejected). Mint it with ids.mintOperationIds in a separate preparation run (or in the host), preserve it, and reuse it exactly when retrying the same plan. organizations.planDocumentCopy then returns a platform-authored operationId and planToken; pass those returned values back unchanged to apply or recover the copy—do not mint a replacement operationId. Reusing copyId with a different source document, source organization, or destination organization fails with:
This copyId is already bound to different request fields. Generate a UUIDv7 locally and pass it as a literal; call ids.mintOperationIds only if your host cannot generate one, in a run of its own. Reuse the literal only for the same intent.operationId and planToken come back from the plan and belong to the apply step. Retry applyDocumentCopy with both unchanged: each request performs at most one idempotent target creation attempt and reuses the plan's pre-minted destination document id, which is what makes a post-timeout retry safe. A planToken that does not match the operation fails with a message telling you to read the receipt and retry with the recorded token. Note that planExpiresAt bounds starting or retrying a ready plan; once apply has started, the receipt and source pin stay recoverable past that deadline.
Timeouts, interrupted creates, and partial persistence
A run gets 30 seconds, and that budget bounds awaited work rather than a synchronous loop, which the Worker CPU cap ends instead. When the budget runs out, the sandbox settles the run with Execution timed out and the platform appends the ledger pointer to it. The host waits two seconds longer than the sandbox on purpose so that this message wins, because it is the one that still carries your logs and the ledger. Only when the sandbox never answers at all do you get the host's fallback instead: Codemode executor timed out after 30000 ms. The timeout does not roll anything back. Work that already reached the product stays there.
Document editing runs on Durable Objects, and a create whose document-editor call is interrupted does not leave a half-made document behind. The platform cleans up in two directions: a shell that never finished bootstrapping is deleted and the original error is raised, and a document that did finish bootstrapping is recovered and returned as a normal success. Either way there is nothing orphaned for you to find, and either way the correction is the same. Retry the same call unchanged, with the same idempotency key, and you converge on one document rather than two.
That safety only exists where an idempotency key exists. For everything else, including every editDocument, the rule is: a timeout or an ambiguous error means partial persistence is possible, so verify with reads before retrying. getDocument on the affected document, or the copy receipt for a copy, will tell you what actually landed.
Live write responses lead with writeConfirmed: true, and that is the completion boundary: your edit is journaled in the durable live document and every session can already read it. saveState, dirty, lastSaveError, and nextRetryAt describe background snapshot convergence only. pending and retrying need no polling — the receipt's pendingProjections names what is still catching up (version_snapshot, reference_graph, search_projection), and the only reason to wait is a next call that reads one of those projections and would get a wrong answer from stale rows. blocked is different. It does not resolve on its own, and saveBlockedReason says why (document_size_limit means split the document into smaller ones).
Repairing a corrupt journal
A corrupt live journal is a different failure with a different repair. When a live read comes back with journalStatus: "journal_corrupt", the document is being served from its persisted head, and documents.repairDocumentJournal({ documentId, writeBasis, acceptDiscardingUnpersistedState: true }) is the way back. Pass the writeBasis from that same read, because the repair refuses a stale one. The parameter name is the warning: recovery rebuilds from the persisted head and discards journal-only state that cannot be replayed, so read the document first, keep anything you still need outside the call, and reapply it afterwards with the fresh writeBasis the repair returns. It refuses a healthy journal, so it is not a general fix for a stuck save: a blocked save state is a size or scheduling problem, not a corrupt journal.
Limits that change how you retry
Splitting work across runs is often better than retrying a run that hit a ceiling. Per run: 64 provider calls, 30 seconds, 32 KiB of code, 512 KiB of arguments per call, normally 128 KiB per provider result with tool-specific exceptions, 128 KiB for the final result, and 50 log entries capped at 4 KiB each and 24 KiB total. Error messages are capped at 32 KB, so a complete method signature fits in one.
The failure modes differ in an important way. Oversized arguments fail before the provider is called, so nothing was attempted and a corrected retry is safe. An oversized provider result is different: the call already ran. Instead of killing the run, the runtime replaces the value with an envelope carrying codemodeResultTruncated: true, the tool name, originalSizeBytes, maxSizeBytes, a preview you may inspect for shape only, and guidance telling you to call again with a narrower scope. Do not read fields from that envelope or compute with it. Exceeding the call budget is the harshest case: the 65th call ends the run and cannot be caught inside the script. The failure envelope exposes Codemode provider call budget exceeded: 65 calls, limit 64 calls per run. together with available logs and ledger entries. Split the work, and read current state before retrying any earlier write.
When in doubt, the shortest correct loop is the same one every time: read current state, decide, write once with the right key, and verify.