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-1intonode:9c0d1e2fon 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.
status | Terminal | Meaning |
|---|---|---|
preparing | No | Setting up canvas targets and provider inputs |
queued | No | Waiting for a worker |
running | No | The provider is generating |
finalizing | No | Writing durable asset and canvas node |
canceling | No | A cancellation is in flight |
succeeded | Yes | Output is durable |
failed | Yes | See failure |
canceled | Yes | Stopped before completion |
expired | Yes | The 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": ""
}
]
}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 send | Rule |
|---|---|
targetNodeId | Exactly 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. |
| Neither | Rejected. 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
referencesandsourcecombined. - Every handle must be unique across both fields.
POST /images/editandPOST /images/variationsrequire at least one ofreferencesorsource.POST /images/generatedoes 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.code | Typical cause |
|---|---|
provider_authentication_failed | The saved provider connection’s credential is invalid |
provider_timeout | The provider did not answer in time |
provider_rate_limited | The provider is throttling |
provider_rejected | The provider refused the request — often the prompt or a reference |
provider_unavailable | The provider is down |
runtime_failed | Gavana’s own execution failed |
action_input_invalid | An Image Action received inputs it cannot process |
result_expired | The 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 state | Retained for |
|---|---|
| Succeeded, before the first successful server finalization | 24 hours |
| Succeeded, after the first successful server finalization | 15 minutes |
| Failed or canceled | 24 hours |
expired tombstone | 7 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.`)
}