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, notcanvas: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:
| Field | What it tells you |
|---|---|
canvas | A canvas summary: id, handle, owner, title, revision, node and connection counts |
proposal | The resolved effect of the batch — clientReferences, changedNodeReferences, deletedNodeReferences, changedConnectionReferences, deletedConnectionReferences |
destructiveImpact | What would be destroyed: deletedNodeHandles, deletedConnectionHandles, mediaNodeHandles, generatedNodeHandles |
validation | Graph 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:
| Operation | Field | Required |
|---|---|---|
POST /canvases/{canvasId}/operations | baseRevision | Yes, unless validateOnly is true |
POST /images/generate · /images/edit · /images/variations | baseRevision | Yes |
POST /actions/{actionKey}/runs | baseRevision | Yes |
POST /recipes/{recipeId}/fork | baseRevision | Yes |
POST /recipes/{recipeId}/runs | baseRevision | Yes |
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
metadataobject 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 ontextandstickynodes. A non-empty value is rejected on an image or video node even when you omittypein 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
widthandheightare 40 to 10000; positions are within ±10,000,000. - Setting a field to
nullin anode.updatepatch removes it.
Related
- Idempotency — the other half of a safe retry
- Errors and X-Request-ID — every code, and what to retry
- Apply a revision-safe batch — the worked sequence with real payloads
- Read a canvas — the read that precedes every write
- Canvases — the generated operation contract