Skip to Content
AgentsRecipesSave an image

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.

RouteSourceProducesScopes
ImportA public HTTPS URL, including an image your chat client just producedA durable image node on a canvas, plus an assetcanvas:read, canvas:write, asset:read, image:generate
UploadA local file, stdin, or the macOS clipboardA durable asset in your library, reusable as a reference or inputasset: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' -r

Check. 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 }'
FieldNotes
canvasIdA plain id, or a canvas: handle. Required
imageUrlMust be https://, at most 4,096 characters. Required
idempotencyKey8–200 characters. Required
sourceIdOptional stable source identity, such as a ChatGPT file_id. When present Gavana matches idempotent retries on it rather than on an expiring download URL
titleUp to 160 characters; defaults to Image from ChatGPT
x, yPosition, 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.markdown

Check. 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' -r

Also accepted: --file PATH, - for stdin, and clipboard on macOS.

cat ./product.png | gavana asset upload - gavana asset upload clipboard

The 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_ID

An 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 1350

Upload 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

RouteDurable output
Importnode: on the canvas, asset:, a new canvas revision, and a review link
Uploadasset: 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

MistakeWhat happens
Writing an image URL into a node’s metadata.contentRejected. Text content on an image node is not allowed — use import or a generation operation
Uploading a file and expecting it on the canvasYou get an asset, not a node. Import, or run an Action, to place it
Reusing an idempotency key for a different imageYou get the first result back, or a conflict. New image, new key
Importing from a private or internal URLRejected by the SSRF-safe fetcher
Sharing previewUrl as the deliverableIt is scoped and temporary. Share the node: and asset: handles
Last updated on