Save an Image
Getting an image into Gavana is not generation. Nothing is sent to an AI provider and no credits are spent — but it does write to the canvas, so it needs approval like any other write.
There are two routes, and they produce different things.
| Route | Source | Produces | Scopes |
|---|---|---|---|
| Import | A public HTTPS URL, including an image your chat client just produced | A durable image node on a canvas, plus an asset | canvas:read, canvas:write, asset:read, image:generate |
| Upload | A local file, stdin, or the macOS clipboard | A durable asset in your library, reusable as a reference or input | asset:read plus image:generate or video:generate |
Import when the image should appear on the board. Upload when you need a handle to feed into an Action, an image edit, a Recipe, or a video frame.
Import requires the image:generate scope even though it generates nothing — the scope gates the image pipeline as a whole, not generation specifically. Plan for that when scoping a token whose job is only to file images.
Route A — Import a public HTTPS image
Call sequence
canvas_get → save_image_to_canvas → canvas_get (verify)Read the destination canvas
gavana canvas get canvas:OWNER_UID:CANVAS_ID --jq '.canvas.revision' -rCheck. Note the revision and the current visible bounds. New work goes outside the existing bounding box — to the right with at least 160 units of spacing, or below with the same spacing.
Import the image
MCP: call save_image_to_canvas with the canvas handle, the image URL, and a caller-stable idempotency key.
CLI, through the raw passthrough — there is no dedicated image import command:
gavana api POST /images/import --json '{
"canvasId": "canvas:OWNER_UID:CANVAS_ID",
"imageUrl": "https://example.com/render.png",
"title": "Hero render v3",
"idempotencyKey": "import-hero-v3-2026-08-04",
"x": 1600,
"y": 240
}'| Field | Notes |
|---|---|
canvasId | A plain id, or a canvas: handle. Required |
imageUrl | Must be https://, at most 4,096 characters. Required |
idempotencyKey | 8–200 characters. Required |
sourceId | Optional stable source identity, such as a ChatGPT file_id. When present Gavana matches idempotent retries on it rather than on an expiring download URL |
title | Up to 160 characters; defaults to Image from ChatGPT |
x, y | Position, from −100,000 to 100,000 |
Gavana downloads the image through an SSRF-safe fetcher that rejects private and local-network destinations, stores it as a canvas-scoped asset, and adds one deterministic image node.
If the image URL expires — as chat-client download URLs usually do — a later retry with the same idempotency key may not be able to fetch it again. Pass sourceId so Gavana can match the retry on stable identity instead of on the URL.
Verify
The response is your verification. It returns:
status "succeeded"
replayed true when this was an idempotent replay, not a new import
canvasId canvas:…
canvasRevision the new revision
canvasUrl a review link for a human
image.assetId asset:… ← durable
image.nodeId node:… ← durable
image.mediaType, width, height, bytes
image.previewUrl, image.markdownCheck. replayed: true means you already did this — no duplicate was created. That is the idempotency key working, not a failure.
Report the durable handles
Hand back image.nodeId, image.assetId, and canvasUrl. Do not hand back previewUrl as if it were durable — it is a scoped preview for review, not the record.
Route B — Upload a local file as an asset
Call sequence
asset upload → (use the returned asset: handle as an input)Upload
gavana asset upload ./product.png --jq '.asset.handle' -rAlso accepted: --file PATH, - for stdin, and clipboard on macOS.
cat ./product.png | gavana asset upload -
gavana asset upload clipboardThe file must be a PNG, JPEG, WebP, or GIF, and at most 50 MB. The CLI checks the magic bytes rather than the extension, and rejects an unsupported or empty file before sending anything.
Check. You now hold an asset: handle. This is a private library asset — it is not yet a node on any canvas.
Use it
The asset handle is now valid input anywhere Gavana takes an image reference:
# As a deterministic Action input — no AI credits
gavana action run action:resize \
--input asset:ASSET_ID \
--destination canvas:OWNER_UID:CANVAS_ID \
--width 1080 --height 1350
# As a reference for an image edit — paid, ask first
gavana image edit \
--destination canvas:OWNER_UID:CANVAS_ID \
--reference asset:ASSET_ID \
--prompt "Warm the lighting"
# As a Recipe image-port input
gavana recipe run recipe:RECIPE_ID \
--input product-image=asset:ASSET_ID \
--destination agent-canvas
# As a video first frame — paid, ask first
gavana video generate \
--model model:OPAQUE_MODEL_KEY \
--prompt "A slow turntable" \
--first-frame asset:ASSET_IDAn Action run on an existing handle costs no AI credits and lands a real node on the canvas — that is the cheapest way to turn an uploaded asset into visible canvas work.
The shortcut
Every image, Action, and Recipe command accepts a local path directly and uploads it for you:
gavana action run action:resize --input ./product.png --destination agent-canvas --width 1080 --height 1350Upload separately when the same file is used more than once — one upload, one handle, reused. Within a single command the CLI already deduplicates identical files by content hash.
What you end up with
| Route | Durable output |
|---|---|
| Import | node: on the canvas, asset:, a new canvas revision, and a review link |
| Upload | asset: in your library, reusable across canvases |
Both are durable. The previewUrl on an import response is not — treat it as a review aid only, and never paste it anywhere a credential should not go.
Common mistakes
| Mistake | What happens |
|---|---|
Writing an image URL into a node’s metadata.content | Rejected. Text content on an image node is not allowed — use import or a generation operation |
| Uploading a file and expecting it on the canvas | You get an asset, not a node. Import, or run an Action, to place it |
| Reusing an idempotency key for a different image | You get the first result back, or a conflict. New image, new key |
| Importing from a private or internal URL | Rejected by the SSRF-safe fetcher |
Sharing previewUrl as the deliverable | It is scoped and temporary. Share the node: and asset: handles |