Skip to Content
APIRecipesApply a revision-safe batch

Apply a revision-safe batch

All graph editing goes through one operation. There is no endpoint that moves a single node — you send a batch of up to 200 operations against a specific revision, and either the whole batch applies or none of it does.

Scopes: canvas:read and canvas:write. The validation step alone needs only canvas:read. Cost: none. Graph edits do not spend provider credit. You end up with: a new canvas revision and the resolvedIds map linking the client names you invented to real node: and connection: handles.

The task

Take a canvas that already holds a brief and a hero image, and add a structured comparison: two new sticky notes, an empty image slot for a future render, and connections wiring the brief into all three.

The sequence

Read the canvas

GET /api/canvas-agent/v1/canvases/product-launch Authorization: Bearer cba_…

Keep two things: the revision, and enough of the graph to know your change still makes sense. See Read a canvas.

{ "canvas": { "revision": "2026-08-04T09:12:44.318726Z", "nodes": [ … ] } }

Build the batch

Operations apply in order. Later operations may reference earlier ones by the clientId you assign.

{ "baseRevision": "2026-08-04T09:12:44.318726Z", "idempotencyKey": "launch-compare-2026-08-04-001", "operations": [ { "type": "node.create", "clientId": "client:option-a", "node": { "type": "sticky", "title": "Option A", "position": { "x": 40, "y": 520 }, "width": 240, "height": 160, "metadata": { "content": "Studio light, hard shadow, single product." } } }, { "type": "node.create", "clientId": "client:option-b", "node": { "type": "sticky", "title": "Option B", "position": { "x": 320, "y": 520 }, "width": 240, "height": 160, "metadata": { "content": "Daylight, soft shadow, product in context." } } }, { "type": "node.create", "clientId": "client:render-slot", "node": { "type": "image", "title": "Comparison render", "position": { "x": 600, "y": 520 }, "width": 512, "height": 512 } }, { "type": "connection.create", "from": "node:3f8a12bc", "to": "client:option-a" }, { "type": "connection.create", "from": "node:3f8a12bc", "to": "client:option-b" }, { "type": "connection.create", "from": "client:option-a", "to": "client:render-slot" }, { "type": "node.update", "nodeId": "node:8f2a91c4", "patch": { "title": "Hero render (approved)" } } ] }

Note the image node is created with no metadata.content. Content is writable only on text and sticky nodes; a non-empty value on an image node is rejected. An empty image node is exactly what an image job wants as a target.

Validate

Same body, validateOnly: true, no idempotencyKey needed:

POST /api/canvas-agent/v1/canvases/product-launch/operations Authorization: Bearer cba_… Content-Type: application/json
{ "canvas": { "id": "product-launch", "handle": "canvas:9Kd2Jv7QpR:product-launch", "ownerUid": "9Kd2Jv7QpR", "title": "Product launch", "revision": "2026-08-04T09:12:44.318726Z", "nodeCount": 14, "connectionCount": 9 }, "proposal": { "clientReferences": ["client:option-a", "client:option-b", "client:render-slot"], "changedNodeReferences": ["client:option-a", "client:option-b", "client:render-slot", "node:8f2a91c4"], "deletedNodeReferences": [], "changedConnectionReferences": ["…"], "deletedConnectionReferences": [] }, "destructiveImpact": { "deletedNodeHandles": [], "deletedConnectionHandles": [], "mediaNodeHandles": [], "generatedNodeHandles": [] }, "validation": { "guideVersion": "…", "scope": "canvas", "canvas": "canvas:9Kd2Jv7QpR:product-launch", "summary": { }, "findings": [] } }

This runs the real operation engine and the real graph validator. It writes nothing, consumes no idempotency key, and needs only canvas:read — a read-only token can validate a batch it could never apply.

Check three things before continuing:

  • destructiveImpact — all four arrays empty means nothing is being destroyed.
  • validation.findings — graph problems the validator caught.
  • proposal.changedNodeReferences — that the batch touches what you expected and nothing more.

Apply

Drop validateOnly, keep baseRevision, add the idempotencyKey.

{ "canvas": { "id": "product-launch", "handle": "canvas:9Kd2Jv7QpR:product-launch", "revision": "2026-08-04T09:21:07.554213Z", "nodes": [ ], "connections": [ ] }, "replayed": false, "resolvedIds": { "client:option-a": "node:1a2b3c4d", "client:option-b": "node:5e6f7a8b", "client:render-slot": "node:9c0d1e2f" }, "changedNodeIds": ["node:1a2b3c4d", "node:5e6f7a8b", "node:9c0d1e2f", "node:8f2a91c4"], "deletedNodeIds": [], "changedConnectionIds": ["connection:aa11bb22", "connection:cc33dd44", "connection:ee55ff66"], "deletedConnectionIds": [], "activity": [] }

Store what you got back

Two values matter beyond this call:

  • resolvedIds — the only place the mapping from client:render-slot to node:9c0d1e2f exists. Capture it now; the next recipe generates into that node handle.
  • canvas.revision — feed it straight into your next baseRevision. You do not need another read.

The eight operations

typeRequired fieldsNotes
canvas.update—title, backgroundMode (dots | lines | blank), showImageInfo, viewport
node.createnodeOptional clientId for forward references
node.updatenodeId, patchnull in the patch removes a field
node.movenodeId, positionCoordinates within ±10,000,000
node.resizenodeId, width, heightBoth 40 to 10000
node.deletenodeIdDestructive. Validate first.
connection.createconnection, or from and toOptional mode: list, reference, first-frame, last-frame
connection.deleteconnectionIdDestructive.

Full field tables are on the Canvases resource page.

Forward references in detail

A clientId is a name you invent, matching ^(?:client:)?[A-Za-z0-9_-]{1,100}$. Any nodeId, from, or to later in the same batch may use it in place of a real handle.

[ { "type": "node.create", "clientId": "client:a", "node": { "type": "sticky", "position": { "x": 0, "y": 0 } } }, { "type": "node.create", "clientId": "client:b", "node": { "type": "sticky", "position": { "x": 300, "y": 0 } } }, { "type": "connection.create", "from": "client:a", "to": "client:b" }, { "type": "node.move", "nodeId": "client:a", "position": { "x": 0, "y": 200 } } ]

Client references are scoped to the single batch. They do not persist and cannot be used in a later request — after the response, the handles in resolvedIds are the only valid references.

Recovering from a conflict

A 409 with details.currentRevision means someone changed the canvas between your read and your write. Nothing was written.

{ "error": { "code": "conflict", "message": "The canvas changed after it was read. Read the latest revision and retry the batch.", "details": { "currentRevision": "2026-08-04T09:24:11.008Z", "canvas": { "id": "product-launch", "revision": "2026-08-04T09:24:11.008Z", "nodeCount": 15, "connectionCount": 9 } } } }

The correct recovery re-reads and rebuilds:

async function addComparison(canvasId: string, ownerUid: string) { for (let attempt = 0; attempt < 3; attempt += 1) { const { canvas } = await gavana.request<{ canvas: Canvas }>( `/canvases/${encodeURIComponent(canvasId)}`, { query: { ownerUid } } ) // Re-derive from the CURRENT graph. If someone already added Option A and // Option B while we were away, this returns fewer operations — or none. const existing = new Set(canvas.nodes.map((node) => node.title)) const operations = buildComparisonOperations(canvas, existing) if (operations.length === 0) return null try { return await gavana.request<MutationResponse>( `/canvases/${encodeURIComponent(canvasId)}/operations`, { method: 'POST', query: { ownerUid }, body: { baseRevision: canvas.revision, idempotencyKey: `launch-compare-${canvas.id}-attempt-${attempt}`, operations } } ) } catch (error) { const isRevisionConflict = error instanceof GavanaApiError && error.code === 'conflict' && typeof error.details?.currentRevision === 'string' if (!isRevisionConflict) throw error } } throw new Error('Canvas is changing faster than we can apply a batch.') }

Do not substitute currentRevision into the original request and resend. That turns the safety check into a deliberate overwrite of whatever the other actor just did — precisely the failure baseRevision exists to prevent.

Two details in that loop are deliberate:

  • The batch is rebuilt each attempt, so the idempotency key varies with the attempt. A rebuilt batch is genuinely different work, and reusing the key would produce a fingerprint mismatch.
  • When retrying the identical batch after a network timeout, do the opposite: keep the key stable and resend byte-for-byte. That is the case idempotency exists for. See Idempotency.

The other 409

If the 409 message names the idempotency key rather than the canvas, you reused a key for a different batch. Re-reading will not help. Resend the original body under that key, or use a new key.

Replay is checked before revision

If your first attempt applied and the response was lost, resending the identical request returns replayed: true even though baseRevision is now stale. The server looks for a replay before it compares revisions — the work exists, so there is nothing to conflict with.

Deleting things

A batch containing node.delete or connection.delete deserves a different process:

Validate first

Send the batch with validateOnly: true and read destructiveImpact.

Read the two fields that matter

mediaNodeHandles and generatedNodeHandles list nodes holding images or generated output. Those are the irreplaceable ones. deletedNodeHandles and deletedConnectionHandles are the full list.

Get explicit approval

Show a human what will be permanently removed, in their terms — “this deletes 3 generated images and 2 sticky notes” — and wait for a yes in the current turn.

Then apply

There is no undo through this API. A deleted node is gone.

Limits worth knowing before you build a large batch

LimitValue
Operations per batch1 to 200
Serialized node metadata64 KiB
metadata.content8000 characters, text and sticky nodes only
Node title160 characters
Node width and height40 to 10000
Node position±10,000,000
metadata.references32 handles
metadata.listItems200 items
idempotencyKey8 to 200 characters
baseRevision1 to 200 characters

A change larger than 200 operations becomes multiple batches — and each one is independently atomic, so plan the split so that a partial application still leaves a coherent graph. Carry the revision from each response into the next request.

Next

Last updated on