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 fromclient:render-slottonode:9c0d1e2fexists. Capture it now; the next recipe generates into that node handle.canvas.revision— feed it straight into your nextbaseRevision. You do not need another read.
The eight operations
type | Required fields | Notes |
|---|---|---|
canvas.update | — | title, backgroundMode (dots | lines | blank), showImageInfo, viewport |
node.create | node | Optional clientId for forward references |
node.update | nodeId, patch | null in the patch removes a field |
node.move | nodeId, position | Coordinates within ±10,000,000 |
node.resize | nodeId, width, height | Both 40 to 10000 |
node.delete | nodeId | Destructive. Validate first. |
connection.create | connection, or from and to | Optional mode: list, reference, first-frame, last-frame |
connection.delete | connectionId | Destructive. |
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
| Limit | Value |
|---|---|
| Operations per batch | 1 to 200 |
Serialized node metadata | 64 KiB |
metadata.content | 8000 characters, text and sticky nodes only |
| Node title | 160 characters |
| Node width and height | 40 to 10000 |
| Node position | ±10,000,000 |
metadata.references | 32 handles |
metadata.listItems | 200 items |
idempotencyKey | 8 to 200 characters |
baseRevision | 1 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
- Generate and observe an image — fill the image node you just created
- Revisions and baseRevision — the mechanism in full
- Idempotency — replay semantics and the retention window
- Canvases — the generated operation contract