Send a request ID with capture, remember, or a document save so a retry after a lost response finishes the same write instead of duplicating or overwriting it.
A timeout, dropped connection, crash, or server restart can leave a caller unsure whether a write landed. Attach an optional request ID to a capture, a remember, or a document save and GNO records it in a private local ledger. Retrying with the same ID returns the original outcome, or finishes an interrupted write, instead of creating a second note, superseding a fact twice, or overwriting a newer edit.
Callers that send no request ID keep their response shapes, with one documented exception: capture and document saves now run under the shared write lease, so a busy lease returns the typed busy error (409 LOCKED for REST saves) and two saves from the same revision cannot both win. A CLI or SDK capture whose lexical sync fails still comes back as a receipt with sync.status: "failed". See Without a request ID.
| Operation | CLI | MCP | REST | SDK |
|---|---|---|---|---|
| Capture | gno capture ... --request-id <id> | gno_capture argument requestId | POST /api/capture body requestId | client.capture({ ..., requestId }) |
| Remember (add or supersede) | gno remember ... --add|--supersede ... --request-id <id> | gno_remember argument requestId | POST /api/memory/remember body requestId | client.remember({ ..., requestId }) |
| Document save (content and tags) | not available | not available | PUT /api/docs/:id body requestId | not available |
., _, :, and -, starting with a letter or digit. A UUID works. The format is checked before anything is admitted.add or supersede decision. A candidates-only call that carries an ID is rejected with REQUEST_ID_INVALID.POST /api/capture/clip does not accept requestId; a body containing it is rejected with 400 CLIPPER_INVALID_REQUEST. The clipper keeps its own idempotency-key recovery.committed: use the recorded result and do not resend. pending: resend the exact same call with the same ID; GNO finishes it. not_found: nothing was accepted under the ID; resend with the same ID. expired: the ID already ran and will not run again; check current state before choosing a new ID.id=$(uuidgen)
gno capture --file ./call-notes.md --collection notes --request-id "$id" --json
# The terminal closed before the result arrived.
gno request-status "$id"
# pending or not_found: run the identical command again with the same ID.
gno capture --file ./call-notes.md --collection notes --request-id "$id" --json| Surface | Lookup |
|---|---|
| CLI | gno request-status <request-id> [--json] |
| MCP | gno_request_status { requestId }, read-only, registered with --enable-write on the full tool profile (not in core) |
| REST | GET /api/requests/:requestId |
| SDK | client.requestStatus(requestId) |
{
"requestId": "3f2c9a0e-5b1d-4c7a-9e21-6d0f8a4b7c11",
"status": "committed",
"operation": "capture",
"createdAt": "2026-09-24T08:00:00.000Z",
"updatedAt": "2026-09-24T08:00:00.120Z",
"result": {
"uri": "gno://notes/inbox/call-notes.md",
"docid": "#abc123",
"contentHash": "..."
}
}status is pending, committed, expired, or not_found. operation is capture, remember, or document.update.result appears only for committed requests: the uri, docid, and contentHash (capture, remember) or sourceHash (document save). It never contains note or fact text.not_found carries only requestId and status. Lookups see only the caller’s own namespace.A successful write that carried an ID gains one field. It is absent when no ID was sent.
"request": {
"requestId": "3f2c9a0e-5b1d-4c7a-9e21-6d0f8a4b7c11",
"status": "committed",
"replayed": false,
"committedAt": "2026-09-24T08:00:00.120Z"
}replayed: false. An identical retry returns the retained outcome unchanged with replayed: true; nothing runs again.jobId: null: the original save already started its index refresh.Request: <id> committed, plus (replayed, nothing written again) on a replay.caller and session, MCP session IDs, and server instance IDs are excluded, so a retry after a reconnect or a server restart still matches.REQUEST_ID_CONFLICT. The ID belongs to another intent.REQUEST_PENDING; retry the same ID later.| Operation | Committed when |
|---|---|
| Capture | The note file is written and lexically synced, so it is retrievable. |
| Remember add | The fact file is written and lexically synced. |
| Remember supersede | The successor is written and synced and its supersedes edge is projected. |
| Document save | The source file is written and its new sourceHash is recorded, along with any tag changes. |
A committed document save means the canonical file on disk is updated. Lexical search catches up in a deferred sync job and embeddings follow through the scheduler. Neither is part of the commit, and a failure in either never undoes the save: run gno update or gno embed to recover. A retried exact-duplicate remember --add is recorded as its existing outcome and replays as such.
CONFLICT, a missing document, or a busy lease. Nothing is recorded, the lookup reads not_found, and retrying the same ID evaluates it again against current state.CAPTURE_SYNC_FAILED, MEMORY_SYNC_FAILED, or MEMORY_SUPERSEDE_PROJECTION_FAILED. With a request ID a sync failure is returned as an error on every surface, and the request stays pending. Retrying the same ID finishes the recorded write (sync, edge projection) instead of writing again. Retrying with a new ID would be a second write.REQUEST_RECOVERY_CONFLICT and leaves the current state untouched. GNO never recreates a note you deleted. Read the current state and decide again; a new ID is a new intent.The guarantee is one durable local side effect per admitted request for these writes. It makes no claim about external systems, and the deferred index refresh and embeddings after a document save are separate work.
| Code | Meaning | HTTP | CLI exit |
|---|---|---|---|
REQUEST_ID_INVALID | Malformed or oversized ID, or an ID on a non-write call | 400 | 1 |
REQUEST_ID_CONFLICT | The ID was already used for a different intent | 409 | 1 |
REQUEST_EXPIRED | The ID was used before and its full receipt has expired; it will not run again | 410 | 1 |
REQUEST_PENDING | Accepted and still in progress elsewhere; retry the same ID later | 409 | 4 |
REQUEST_RECOVERY_CONFLICT | The recorded write’s target changed or disappeared after it was written; nothing was overwritten or recreated | 409 | 2 |
REQUEST_CAPACITY_EXHAUSTED | The ledger is full; rejected before any write | 507 | 2 |
REQUEST_LEDGER_UNAVAILABLE | The ledger cannot be opened; rejected before any write | 503 | 2 |
{ "error": { "code": "...", "message": "..." } }.VALIDATION, BUSY, or RUNTIME, with the request code in details.requestCode.GnoSdkError (VALIDATION or RUNTIME) with the request code in details.code.CODE: message and structuredContent { error: CODE, ... }.REQUEST_RECOVERY_CONFLICT, CONFLICT, and MEMORY_PREDECESSOR_HASH_MISMATCH all mean the same thing for a caller: read the current document or recall the current fact, then decide again. Never force an overwrite.
gno serve share one local-owner namespace per index.not_found, and the same ID under another identity is simply that caller’s own request.Each index has one private SQLite ledger next to its index database, at <data dir>/write-receipts/<index db filename>:
~/.local/share/gno/write-receipts/index-default.sqlite~/Library/Application Support/gno/data/write-receipts/index-default.sqlite%LOCALAPPDATA%\gno\data\write-receipts\On POSIX systems the directory is created 0700 and the file 0600. The ledger is local state: nothing in it leaves your machine.
gno update, re-embedding, and deleting or rebuilding index-*.sqlite do not touch it. gno reset preserves write-receipts/ and removes everything else in the data directory; nothing in GNO removes the ledger. Deleting that directory by hand discards replay protection for every request it recorded. Include it when you back up private GNO state.REQUEST_EXPIRED, reads expired, and never runs again.REQUEST_CAPACITY_EXHAUSTED before any write, existing receipts still replay, and writes without an ID are unaffected. There is no configuration setting and no cleanup command.receipt fences remembered text; a requestId identifies one write for retries.The capture dialog and document saves (editor autosave, Cmd/Ctrl+S, and tag saves) generate one request ID per submitted intent and reuse it while the same payload is retried, for example with Try again after a network error. Edited content or different tags get a new ID, and after a success the next submit is a new intent.
A save whose response was lost and is then retried gets the committed result instead of a false “Document changed on disk” conflict. Pending and conflict errors show the server message. Stale edits still get CONFLICT and are never force-overwritten. The unconfirmed request ID is kept for the browser tab, so retrying the same capture or save after a page refresh still replays instead of writing twice; after a refresh the editor reloads the current document from disk.
Writes without an ID keep their response shapes and outcomes, with a weaker guarantee when a response is lost and the caller retries:
create_with_suffix) or fail because the note already exists (error).remember --add returns existing, or adds the fact again if it was superseded in between.MEMORY_SUPERSEDE_CONFLICT.CONFLICT when it sends expectedSourceHash, or overwrites a newer edit when it does not.One change applies to every client: capture and document saves run under the shared write lease, and the SDK capture takes it too.
PUT /api/docs/:id (the Web UI editor and tag editing) that is 409 LOCKED.409 CONFLICT.gno capture or client.capture() whose lexical sync fails still exits 0 or resolves with a receipt carrying sync.status: "failed"; run gno update to index the note. Opening an existing note that was never indexed (open_existing) still returns sync.status: "skipped". Only a capture that carries a request ID turns a sync failure into an error and a pending request.