Skip to Content

cURL quickstart

Seven commands, from an unused token to a canvas you have verifiably changed. Nothing here spends provider credit.

You need curl and jq, an Agent Access token with canvas:read and canvas:write, and about five minutes.

Put the token in the environment

read -rs 'GAVANA_AGENT_TOKEN?Paste Agent Access token: '; printf '\n' export GAVANA_AGENT_TOKEN export GAVANA_BASE_URL='https://app.gavana.ai/api/canvas-agent/v1'

In bash use read -rsp 'Paste Agent Access token: ' GAVANA_AGENT_TOKEN. Either way the token stays out of shell history and out of ps output.

Never put the token in the URL, in a --data field, or in a script you commit. -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" is the only correct placement.

Confirm the token works

curl -sS "$GAVANA_BASE_URL/auth/status" \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq
{ "authenticated": true, "authType": "agent", "email": "you@example.com", "scopes": ["canvas:read", "canvas:write"], "agentTokenId": "2f1c9b40-7a3e-4c58-9d21-0b6e4f8a1c33", "agentLabel": "Quickstart" }

This is the cheapest call in the API and the only one that needs no product scope. If it fails, the problem is the credential itself — see Authentication. If it succeeds and something later fails with 403, the problem is scopes, and the message will name the missing one.

Check scopes against what you expect before going further. A token that turns out to hold image:generate when you only wanted to read is worth fixing now.

See the request id

Every response carries one. Ask cURL to show you the headers once so you know where to find it:

curl -sS -D - -o /dev/null "$GAVANA_BASE_URL/auth/status" \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | grep -i '^x-request-id'
x-request-id: 8c1f2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5f

Log this on every call in real code. It is safe to share with support; the token never is.

List canvases

curl -sS --get "$GAVANA_BASE_URL/canvases" \ --data-urlencode 'limit=10' \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq '.canvases[] | {handle, title, revision, nodeCount}'
{ "handle": "canvas:9Kd2Jv7QpR:product-launch", "title": "Product launch", "revision": "2026-08-04T09:12:44.318726Z", "nodeCount": 14 }

Canvas pages cap at 25 because each summary is derived from a complete graph document. When page.hasMore is true, pass page.nextCursor back as cursor with the same filters — see Pagination.

Pick one and keep its plain id — the part after the last colon:

export CANVAS_ID='product-launch'

If the canvas is shared with you rather than owned by you, its handle is canvas:<ownerUid>:<id> and you also need ownerUid as a query parameter on the calls below.

Read the graph and capture the revision

CANVAS=$(curl -sS "$GAVANA_BASE_URL/canvases/$CANVAS_ID" \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN") export BASE_REVISION=$(printf '%s' "$CANVAS" | jq -r '.canvas.revision') printf '%s' "$CANVAS" | jq '{title: .canvas.title, revision: .canvas.revision, nodes: (.canvas.nodes | length)}'
{ "title": "Product launch", "revision": "2026-08-04T09:12:44.318726Z", "nodes": 14 }

revision is the whole point of this step. It is an opaque, server-owned string that identifies the exact state you just read, and the next step proves to the server that you read it. Do not parse it or construct one.

Have a look at what is actually there before changing anything:

printf '%s' "$CANVAS" | jq '.canvas.nodes[] | {handle, type, title}'

Validate the change for free

validateOnly: true runs the real operation engine and graph validator, writes nothing, consumes no idempotency key, and needs only canvas:read.

curl -sS -X POST "$GAVANA_BASE_URL/canvases/$CANVAS_ID/operations" \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg rev "$BASE_REVISION" '{ validateOnly: true, baseRevision: $rev, operations: [ { type: "node.create", clientId: "client:quickstart-note", node: { type: "sticky", title: "API quickstart", position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { content: "Written from the cURL quickstart." } } } ] }')" | jq '{proposal: .proposal, destructive: .destructiveImpact, findings: (.validation.findings | length)}'
{ "proposal": { "clientReferences": ["client:quickstart-note"], "changedNodeReferences": ["client:quickstart-note"], "deletedNodeReferences": [], "changedConnectionReferences": [], "deletedConnectionReferences": [] }, "destructive": { "deletedNodeHandles": [], "deletedConnectionHandles": [], "mediaNodeHandles": [], "generatedNodeHandles": [] }, "findings": 0 }

Read destructiveImpact every time. Empty arrays mean nothing is being destroyed. If mediaNodeHandles or generatedNodeHandles is non-empty, you are about to delete images or generated output — stop and confirm with a human first.

Apply it

Same operations, validateOnly dropped, plus an idempotencyKey.

export IDEMPOTENCY_KEY="quickstart-$(date +%Y%m%d)-note-001" curl -sS -X POST "$GAVANA_BASE_URL/canvases/$CANVAS_ID/operations" \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg rev "$BASE_REVISION" --arg key "$IDEMPOTENCY_KEY" '{ baseRevision: $rev, idempotencyKey: $key, operations: [ { type: "node.create", clientId: "client:quickstart-note", node: { type: "sticky", title: "API quickstart", position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { content: "Written from the cURL quickstart." } } } ] }')" | jq '{replayed, resolvedIds, changedNodeIds, revision: .canvas.revision}'
{ "replayed": false, "resolvedIds": { "client:quickstart-note": "node:1a2b3c4d5e6f" }, "changedNodeIds": ["node:1a2b3c4d5e6f"], "revision": "2026-08-04T09:21:07.554213Z" }

Three things to notice:

  • resolvedIds maps the clientId you invented to the real node: handle. Capture it — this is how you refer to the node from now on.
  • replayed: false means this actually executed. Run the identical command again and it comes back true, with the same resolvedIds and no second node.
  • canvas.revision has moved. Use that value as baseRevision for your next change; you do not need to re-read.

Verify it landed

curl -sS "$GAVANA_BASE_URL/canvases/$CANVAS_ID/nodes/node:1a2b3c4d5e6f" \ -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq '{node: .node.handle, title: .node.title, incoming: (.incoming | length), outgoing: (.outgoing | length)}'

Or open the canvas in Gavana and look at it. The sticky note is there, attributed to the token’s label in the canvas activity.

What a failure looks like

Send a stale baseRevision — for example by making a change in the browser and then re-running the apply step:

{ "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 } } } }

Nothing was written. Re-read the canvas, decide whether your change still makes sense against the new graph, rebuild the batch, and retry. Do not just substitute currentRevision into the old request — see Revisions and baseRevision.

Send the same idempotencyKey with different operations and you get a different 409, this one naming the key. Re-reading will not help; use a new key for new intent.

Handy one-liners

# Scopes on the current token curl -sS "$GAVANA_BASE_URL/auth/status" -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq -r '.scopes | join(", ")' # Every accessible canvas handle curl -sS "$GAVANA_BASE_URL/canvases?limit=25" -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq -r '.canvases[].handle' # Runnable image models curl -sS "$GAVANA_BASE_URL/models?capability=image.generate" -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq -r '.models[] | "\(.handle)\t\(.modelId)\t~\(.estimatedSeconds)s"' # The deterministic Image Action catalogue curl -sS "$GAVANA_BASE_URL/actions" -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" | jq -r '.actions[] | "\(.id)\t\(.title)"' # Render a canvas to SVG curl -sS "$GAVANA_BASE_URL/canvases/$CANVAS_ID/render" -H "Authorization: Bearer $GAVANA_AGENT_TOKEN" -o canvas.svg

GET /models and GET /providers need at least one of image:generate or video:generate; a read-only token gets 403 there.

Next

Last updated on