Skip to Content
APIPlatformRevisions and baseRevision

Revisions and baseRevision

A Gavana canvas is a single graph document, not a collection of independently addressable rows. There is no PATCH /nodes/{nodeId}. Every change goes through one operation — POST /canvases/{canvasId}/operations — as a batch applied to a specific version of the document.

That version is the revision, and quoting it back is how you prove you are editing the graph you actually read.

What a revision is

Every canvas read returns a revision string:

{ "canvas": { "id": "product-launch", "handle": "canvas:9Kd2…:product-launch", "title": "Product launch", "revision": "2026-08-04T09:12:44.318726Z", "nodes": [], "connections": [] }, "activity": [] }

It is a server-owned, opaque token for “the state of this document at the moment you read it”. It appears on the full canvas from GET /canvases/{canvasId} and on every summary in GET /canvases. Do not parse it, compare it for ordering, or construct one. The only valid operations on a revision are: store it, and send it back.

What baseRevision protects against

Consider two agents editing the same canvas.

Without baseRevision, agent A reads the canvas, agent B deletes a node, then agent A submits a batch that moves that node and adds a connection to it. A’s batch is built against a graph that no longer exists. Best case it errors confusingly; worst case it re-creates state the user deliberately removed. This is the lost-update problem, and a graph document makes it worse than usual because a single write covers the whole document.

baseRevision turns that into a compare-and-set. You send the revision you read; the server applies your batch only if the canvas is still at that exact revision. If it is not, nothing is written and you are told so.

You Canvas Someone else | GET /canvases/… | | |<----- revision R1 ---------| | | |<-- applies batch --| | (now at R2) | | POST /operations | | baseRevision = R1 ------->| | |<-- 409 conflict -----------| | | details.currentRevision = R2 | | GET /canvases/… | | |<----- revision R2 ---------| | | POST /operations | | baseRevision = R2, batch rebuilt --> | |<-- 200, canvas now at R3 --| |

The write sequence

Read the canvas

GET /canvases/{canvasId} returns the complete graph and its revision. Keep both — you need the graph to decide what to change and the revision to prove when you read it.

Build the batch

An operations request is a list of 1 to 200 operations, applied in order, as one atomic unit. Either the whole batch applies or none of it does.

{ "baseRevision": "2026-08-04T09:12:44.318726Z", "idempotencyKey": "launch-sync-2026-08-04-001", "operations": [ { "type": "node.create", "clientId": "client:hero", "node": { "type": "text", "title": "Hero copy", "position": { "x": 0, "y": 0 }, "width": 320, "height": 180, "metadata": { "content": "Built for the way you actually work." } } }, { "type": "node.move", "nodeId": "node:8f2a91c4", "position": { "x": 400, "y": 0 } }, { "type": "connection.create", "from": "client:hero", "to": "node:8f2a91c4" } ] }

Eight operation types are available, discriminated by type: canvas.update, node.create, node.update, node.move, node.resize, node.delete, connection.create, and connection.delete. Full field tables are on the Canvases resource page.

Validate first, when the batch is not trivially safe

Set validateOnly: true to run exactly the same operation engine and graph validator without writing anything. See below.

Apply

Send the batch with baseRevision, idempotencyKey, and validateOnly omitted or false. A 200 response returns the new canvas, its new revision, and the handles of everything that changed.

Store the new revision

The response contains the canvas at its new revision. If you are about to make another change, use that value as the next baseRevision — you do not need to re-read.

Forward references with clientId

A batch frequently needs to connect a node it is creating in the same batch. You cannot know the server-assigned id in advance, so node.create and connection.create accept a clientId you choose, and later operations in the same batch can reference it:

[ { "type": "node.create", "clientId": "client:brief", "node": { "type": "sticky", "position": { "x": 0, "y": 0 } } }, { "type": "node.create", "clientId": "client:output", "node": { "type": "image", "position": { "x": 400, "y": 0 } } }, { "type": "connection.create", "from": "client:brief", "to": "client:output" } ]

The response’s resolvedIds map tells you what each client reference became:

{ "replayed": false, "resolvedIds": { "client:brief": "node:1a2b3c4d", "client:output": "node:5e6f7a8b" }, "changedNodeIds": ["node:1a2b3c4d", "node:5e6f7a8b"], "deletedNodeIds": [], "changedConnectionIds": ["connection:9c8d7e6f"], "deletedConnectionIds": [] }

Capture resolvedIds on the first successful response. It is the durable link between the names you invented and the handles the rest of the API expects.

What a 409 conflict looks like

When the canvas moved on, you get 409 with the current revision inside details:

{ "error": { "code": "conflict", "message": "The canvas changed after it was read. Read the latest revision and retry the batch.", "details": { "currentRevision": "2026-08-04T09:18:02.771044Z", "canvas": { "id": "product-launch", "handle": "canvas:9Kd2…:product-launch", "ownerUid": "9Kd2…", "title": "Product launch", "createdAt": "2026-07-11T14:02:10.000Z", "updatedAt": "2026-08-04T09:18:02.771044Z", "revision": "2026-08-04T09:18:02.771044Z", "nodeCount": 14, "connectionCount": 9 } } } }

details.canvas is a canvas summary, not the full graph — enough to see how much the document changed, not enough to rebuild against. Nothing was written.

Do not resend the same batch with currentRevision swapped in. That defeats the entire mechanism — it is the lost update you were protecting against, now performed deliberately. Re-read the canvas, check that your intended change still makes sense against the new graph, and rebuild the batch.

The correct recovery is a loop with a bound:

async function applyWithRetry(canvasId: string, plan: (canvas: Canvas) => Operation[]) { for (let attempt = 0; attempt < 3; attempt += 1) { const { canvas } = await getCanvas(canvasId) const operations = plan(canvas) // rebuild against what is there NOW if (operations.length === 0) return null // someone already did it try { return await applyOperations(canvasId, { baseRevision: canvas.revision, idempotencyKey: `${intentId}-attempt-${attempt}`, operations }) } catch (error) { if (error.status !== 409) throw error // Another actor won the race. Loop and rebuild. } } throw new Error('Canvas is changing faster than we can apply a batch.') }

Two details in that snippet matter. plan() is re-run against the freshly read canvas, so a change that has become unnecessary produces an empty batch and the function stops. And the idempotency key varies per attempt because each attempt is a genuinely different batch — if you rebuild the operations, reusing the key would produce a fingerprint mismatch. When you are retrying the identical batch after a network timeout, keep the key stable instead. See Idempotency.

The other 409

A 409 on this route can also mean an idempotency-key collision — the same key used for a different batch. Tell them apart by details: a revision conflict carries details.currentRevision, a key collision does not and its message names the idempotency key.

One more subtlety

The server checks for an idempotency replay before it checks baseRevision. A retry of a batch that already applied therefore succeeds with replayed: true even if its baseRevision is now stale. That is intentional — the work exists, so there is nothing to conflict with.

The free dry run: validateOnly

validateOnly: true is the most underused feature in this API. It runs the canonical operation engine and the graph validator against the real canvas and returns what would happen — while:

  • writing nothing,
  • consuming no idempotency key,
  • requiring only canvas:read, not canvas:write.

A read-only token can validate a destructive batch. That is the point.

{ "validateOnly": true, "baseRevision": "2026-08-04T09:12:44.318726Z", "operations": [ { "type": "node.delete", "nodeId": "node:8f2a91c4" }, { "type": "node.delete", "nodeId": "node:1a2b3c4d" } ] }

baseRevision is optional here, but supplying it is better: if the canvas has moved on, you get the same 409 with details.currentRevision rather than a validation result computed against a state you have not seen.

The response is a different shape from a mutation result — four top-level fields:

FieldWhat it tells you
canvasA canvas summary: id, handle, owner, title, revision, node and connection counts
proposalThe resolved effect of the batch — clientReferences, changedNodeReferences, deletedNodeReferences, changedConnectionReferences, deletedConnectionReferences
destructiveImpactWhat would be destroyed: deletedNodeHandles, deletedConnectionHandles, mediaNodeHandles, generatedNodeHandles
validationGraph validator output: guideVersion, scope, canvas, summary, findings

destructiveImpact deserves the attention. mediaNodeHandles and generatedNodeHandles call out nodes that hold images or generated output — the irreplaceable ones. A batch that deletes three empty sticky notes and a batch that deletes three generated images are the same shape in JSON and very different in consequence.

Use it as the confirmation step:

Validate

Send the batch with validateOnly: true.

Show the impact

Present destructiveImpact and any validation.findings to the person you are acting for, in plain language: what will be created, what will be moved, what will be permanently deleted.

Apply only after approval

Resend the identical operations with validateOnly removed, plus a baseRevision and an idempotencyKey.

An agent applying a destructive batch — anything containing node.delete or connection.delete — should validate first and get explicit approval in the current turn. Deleted nodes are not recoverable through this API, and the graph gives you no undo.

Which operations carry baseRevision

baseRevision is not exclusive to the operations route. Anything that appends to a canvas takes it, for the same reason:

OperationFieldRequired
POST /canvases/{canvasId}/operationsbaseRevisionYes, unless validateOnly is true
POST /images/generate · /images/edit · /images/variationsbaseRevisionYes
POST /actions/{actionKey}/runsbaseRevisionYes
POST /recipes/{recipeId}/forkbaseRevisionYes
POST /recipes/{recipeId}/runsbaseRevisionYes

POST /images/import, POST /videos/generate, and POST /canvases/{canvasId}/workflows take an idempotencyKey but no baseRevision — they add to a canvas without depending on its current shape.

A practical consequence: to start an image job you need a revision, so the read you do before generating is not optional overhead. It is the same read that tells you which target node to write into.

Field-level rules worth knowing before your first batch

  • Batch size: 1 to 200 operations.
  • Node metadata: the serialized metadata object must stay under 64 KiB, and only publicly writable fields are accepted. Server-owned provenance, upload, storage, and generation-job fields are rejected rather than ignored.
  • metadata.content: writable only on text and sticky nodes. A non-empty value is rejected on an image or video node even when you omit type in a patch — the server checks the existing node’s type. Use a generation or asset operation to change what an image node shows.
  • Geometry: node width and height are 40 to 10000; positions are within ±10,000,000.
  • Setting a field to null in a node.update patch removes it.
Last updated on