Skip to Content
APIRecipesGenerate and observe an image

Generate and observe an image

This recipe spends provider credit. POST /images/generate queues paid work the moment it returns 202. If you are an agent acting for someone else, get explicit approval in the current turn before the start call — name the model and the number of outputs — and never auto-retry after a failure. Report actual usage when you are done.

Scopes: canvas:read, canvas:write, asset:read, image:generate — plus job:manage to poll or cancel. image:generate cannot exist without the first three; the dependency is enforced when the token is created. You end up with: a durable asset: handle and the node: it was written into. Everything else in this recipe is scaffolding that expires.

If the change you want is a raster transformation — resize, crop, aspect ratio, overlay, colour grade, rotate — use a deterministic Image Action instead. Same Run mechanics, same durable result, chargedCost: 0.

The shape of the task

Pick a model

GET /api/canvas-agent/v1/models?capability=image.generate&limit=100 Authorization: Bearer cba_…
{ "models": [ { "id": "…", "handle": "model:…", "modelId": "gpt-image-1", "name": "GPT Image 1", "provider": "openai", "providerLabel": "OpenAI", "connectionId": "…", "connectionName": "Team OpenAI key", "type": "image", "capabilities": ["image.generate", "image.edit", "image.variations"], "parameters": [ { "name": "size", "label": "Size", "description": "…", "type": "string", "required": false, "options": [ ], "capabilities": ["image.generate"] } ], "estimatedSeconds": 35, "beta": false } ], "page": { "limit": 100, "hasMore": false, "nextCursor": null } }

This endpoint returns only models Gavana can actually run — through the managed runtime or a saved provider connection on this account. A model that is not in this list cannot be used, whatever its name elsewhere.

Pass the opaque handle as model on the start request and Gavana resolves both the connection and the provider model without a second lookup. Do not decode or construct a model key. estimatedSeconds is operational guidance for your polling loop, not a deadline and not a billing figure.

GET /models requires at least one of image:generate or video:generate and no canvas scope.

Prepare a target node

The raw API writes into an image node that already exists. It does not create one for you — that convenience lives in the CLI and the browser.

Create an empty image node in a revision-safe batch first:

{ "baseRevision": "2026-08-04T09:12:44.318726Z", "idempotencyKey": "hero-render-target-001", "operations": [ { "type": "node.create", "clientId": "client:render-slot", "node": { "type": "image", "title": "Hero render", "position": { "x": 600, "y": 520 }, "width": 512, "height": 512 } } ] }

Keep two values from the response: resolvedIds["client:render-slot"] — your target — and canvas.revision, which becomes the baseRevision of the start call. See Apply a revision-safe batch.

Confirm before spending

This is the moment. Say what will run and what it will produce, then wait for a yes:

This will generate 1 image with gpt-image-1 into node:9c0d1e2f on Product launch, and charge your OpenAI connection. Proceed?

Approval is for this run. Not for a retry, not for a larger count, not for the next idea.

Start the job

POST /api/canvas-agent/v1/images/generate Authorization: Bearer cba_… Content-Type: application/json
{ "canvasId": "canvas:9Kd2Jv7QpR:product-launch", "baseRevision": "2026-08-04T09:21:07.554213Z", "idempotencyKey": "hero-render-2026-08-04-001", "targetNodeId": "node:9c0d1e2f", "prompt": "A matte black insulated travel bottle on pale linen, soft daylight from the left, quiet premium product photography, no people", "model": "model:…", "size": "1024x1024" }
{ "id": "job:2c9a1f3e-7b40-4d18-9e52-6f0a3b8c1d47", "run": "run:2c9a1f3e-7b40-4d18-9e52-6f0a3b8c1d47", "kind": "image", "operation": "generate", "pollUrl": "/api/canvas-agent/v1/runs/2c9a1f3e-7b40-4d18-9e52-6f0a3b8c1d47", "status": "queued", "estimatedSeconds": 35, "canvasId": "canvas:9Kd2Jv7QpR:product-launch", "canvasRevision": "2026-08-04T09:21:07.554213Z", "targets": ["node:9c0d1e2f"], "replayed": false }

202 Accepted. The job is queued and the money is committed. run: is the handle you poll; job: is a compatibility alias for the same record.

Poll the Run to a terminal state

GET /api/canvas-agent/v1/runs/2c9a1f3e-7b40-4d18-9e52-6f0a3b8c1d47 Authorization: Bearer cba_…

Poll about every 1.5 seconds, with a generous overall timeout — the first-party CLI defaults to a 1.5-second interval and a 15-minute ceiling. Use estimatedSeconds to set expectations, not to decide when to stop.

statusTerminalMeaning
preparingNoSetting up canvas targets and provider inputs
queuedNoWaiting for a worker
runningNoThe provider is generating
finalizingNoWriting durable asset and canvas node
cancelingNoA cancellation is in flight
succeededYesOutput is durable
failedYesSee failure
canceledYesStopped before completion
expiredYesThe observation record aged out before anyone read it

Polling is not passive. Reading a completed image child through GET /runs/{runId} finalizes its durable asset and canvas node. A job whose result is never read — and which has no webhook — can expire before its output becomes durable. Poll to a terminal state, or attach a webhook; do not start a job and walk away.

Read the result

{ "id": "job:2c9a1f3e-…", "run": "run:2c9a1f3e-…", "kind": "image", "operation": "generate", "pollUrl": "/api/canvas-agent/v1/runs/2c9a1f3e-…", "status": "succeeded", "estimatedSeconds": 35, "durationMs": 31204, "timing": { "createdAt": "2026-08-04T09:21:10.100Z", "queuedAt": "2026-08-04T09:21:10.480Z", "startedAt": "2026-08-04T09:21:11.292Z", "completedAt": "2026-08-04T09:21:41.304Z", "queueDurationMs": 812, "executionDurationMs": 30012, "totalDurationMs": 31204 }, "canvasId": "canvas:9Kd2Jv7QpR:product-launch", "canvasRevision": "2026-08-04T09:21:41.902Z", "canvasUrl": "https://app.gavana.ai/canvas/product-launch", "targets": ["node:9c0d1e2f"], "images": [ { "assetId": "asset:9Kd2Jv7QpR:7d3e9f21", "nodeId": "node:9c0d1e2f", "mediaType": "image/png", "width": 1024, "height": 1024, "bytes": 1418293, "previewUrl": "https://app.gavana.ai/api/canvas-agent/v1/assets/preview?token=…", "markdown": "![Hero render](https://app.gavana.ai/api/canvas-agent/v1/assets/preview?token=…)" } ] }

Store the durable handles

const { assetId, nodeId } = run.images[0] // Persist these two. Discard everything else.

assetId and nodeId are permanent. The run: handle, the job: alias, and the previewUrl are not — see the retention rules below.

Targets and count

The relationship between targetNodeId, targetNodeIds, and count is enforced as a cross-field rule, and getting it wrong is the most common 400 on this endpoint.

You sendRule
targetNodeIdExactly one output. count must be omitted or 1. Do not also send targetNodeIds.
targetNodeIds (1 to 4)One output per target. If you send count, it must equal the number of targets.
NeitherRejected. The raw API does not create targets for you.

The spec states the rule plainly: count equals the selected target count. If you want four variations, create four empty image nodes and list all four.

References and source

references accepts stable image handles — node: or asset: — and source is a convenience single reference appended to the same list. Three rules apply:

  • At most 16 handles across references and source combined.
  • Every handle must be unique across both fields.
  • POST /images/edit and POST /images/variations require at least one of references or source. POST /images/generate does not.

POST /images/edit additionally requires a prompt or a promptNodeId, as does generate. variations needs neither.

When it fails

A terminal job with a failure object is a completed job that did not produce an image. It is not an HTTP error and not an authentication problem with Gavana.

{ "status": "failed", "durationMs": 42118, "failure": { "code": "provider_rate_limited", "message": "The image provider is rate limiting requests. Wait briefly, then retry.", "retryable": true, "providerStatus": 429 } }
failure.codeTypical cause
provider_authentication_failedThe saved provider connection’s credential is invalid
provider_timeoutThe provider did not answer in time
provider_rate_limitedThe provider is throttling
provider_rejectedThe provider refused the request — often the prompt or a reference
provider_unavailableThe provider is down
runtime_failedGavana’s own execution failed
action_input_invalidAn Image Action received inputs it cannot process
result_expiredThe observation record aged out before the result was read

Use failure.retryable rather than inferring from the message or the status.

retryable: true means the failure might succeed on another attempt. It does not mean retry automatically. Another attempt is another charge. Surface the failure and its cost, get approval again, and start with a new idempotency key — reusing the old key would replay the failed record instead of starting new work.

Retention: why you store handles, not Runs

Image and Action Runs are temporary observation records with a deliberately short life:

Record stateRetained for
Succeeded, before the first successful server finalization24 hours
Succeeded, after the first successful server finalization15 minutes
Failed or canceled24 hours
expired tombstone7 days, after which the handle returns 404

An authenticated GET /runs/{runId} and the worker finalization that precedes a signed callback both count as “the first successful server finalization”. So the normal, healthy path is: succeed, get read, become durable, and let the temporary record drop 15 minutes later.

Operators may override these windows. Recipe Run records do not use this temporary TTL.

The consequence is simple: asset: and node: are the result; run: and job: are the receipt. Store the result.

Canceling

DELETE /api/canvas-agent/v1/runs/2c9a1f3e-7b40-4d18-9e52-6f0a3b8c1d47 Authorization: Bearer cba_…

Needs job:manage. Returns 200 or 202 with the Run in canceling or canceled. Cancellation is best-effort: work already dispatched to a provider may still complete and may still be charged. Cancel early, and do not assume a cancel prevents a bill.

Skipping the poll

Attach a webhook to the start request and Gavana posts one signed terminal event instead:

{ "webhook": { "url": "https://automation.example.com/hooks/gavana", "secret": "a-caller-owned-secret-of-at-least-32-characters" } }

The successful callback already contains data.run.images with durable assetId and nodeId values, because Gavana finalizes the result before it delivers the event. A callback-only integration needs no follow-up GET. See Webhooks and Run a Recipe with a webhook for verification code.

The whole thing in TypeScript

const TERMINAL = new Set(['succeeded', 'failed', 'canceled', 'expired']) async function generateHeroImage(canvasId: string, ownerUid: string, targetNodeId: string, prompt: string) { // 1. Current revision — cheap, and required by the start call. const { canvas } = await gavana.request<{ canvas: Canvas }>( `/canvases/${encodeURIComponent(canvasId)}`, { query: { ownerUid } } ) // 2. Start. Approval must already have happened, above this function. const job = await gavana.request<{ run: string; status: string; estimatedSeconds?: number }>( '/images/generate', { method: 'POST', body: { canvasId: canvas.handle, baseRevision: canvas.revision, idempotencyKey: `hero-${canvasId}-${targetNodeId}-001`, // stable across retries targetNodeId, prompt, model: 'model:…' } } ) // 3. Poll. Reading a completed child is what makes the output durable. const runId = job.run.replace(/^run:/, '') const startedAt = Date.now() while (Date.now() - startedAt < 15 * 60_000) { const run = await gavana.request<{ status: string images?: Array<{ assetId: string; nodeId: string }> failure?: { code: string; message: string; retryable: boolean } }>(`/runs/${encodeURIComponent(runId)}`) if (TERMINAL.has(run.status)) { if (run.status !== 'succeeded') { // Surface it. Do not loop. Another attempt is another charge. throw new Error(`${run.status}: ${run.failure?.code} (retryable: ${run.failure?.retryable})`) } return run.images![0] // { assetId, nodeId } — the durable result } await new Promise((resolve) => setTimeout(resolve, 1_500)) } throw new Error(`Run ${job.run} did not finish in time. Resume by polling it.`) }

Next

Last updated on