Skip to Content
APIQuickstartsTypeScript

TypeScript quickstart

There is no published Gavana TypeScript SDK. Nothing on npm is required, and you should be suspicious of any package claiming to be one. The API is plain JSON over HTTPS and the client below — about 80 lines, zero dependencies — is the whole thing. The Gavana repository does contain a first-party JavaScript client bundled with the CLI package, but this help center does not treat its public availability as verified; see Install the CLI.

Everything here runs on Node 20+ (native fetch), Bun, Deno, and edge runtimes. No build step is required beyond your own TypeScript setup.

The client

Save as gavana.ts.

/** Minimal Gavana Canvas API client. No dependencies. */ const DEFAULT_BASE_URL = 'https://app.gavana.ai/api/canvas-agent/v1' /** The 17 stable error codes in the Gavana error envelope. */ export type GavanaErrorCode = | 'input_validation_error' | 'invalid_json' | 'unauthorized' | 'payment_required' | 'forbidden' | 'not_found' | 'conflict' | 'gone' | 'payload_too_large' | 'unsupported_media_type' | 'unprocessable_entity' | 'rate_limited' | 'internal_error' | 'upstream_error' | 'service_unavailable' | 'upstream_timeout' | 'request_failed' export type GavanaErrorField = { field: string; message: string } export class GavanaApiError extends Error { readonly status: number readonly code: GavanaErrorCode readonly requestId: string | null readonly fields?: GavanaErrorField[] readonly details?: Record<string, unknown> constructor(init: { status: number code: GavanaErrorCode message: string requestId: string | null fields?: GavanaErrorField[] details?: Record<string, unknown> }) { super(init.message) this.name = 'GavanaApiError' this.status = init.status this.code = init.code this.requestId = init.requestId this.fields = init.fields this.details = init.details } /** Safe to show a user or paste into a support request. Contains no credential. */ get supportLine(): string { return `${this.code} (${this.status}) · request ${this.requestId ?? 'unknown'}` } } export type GavanaClientOptions = { token: string baseUrl?: string /** Sent as X-Gavana-Agent-Surface. Affects provenance only. */ surface?: 'api' | 'cli' | 'mcp' fetchImpl?: typeof fetch } export function createGavanaClient(options: GavanaClientOptions) { const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '') const doFetch = options.fetchImpl ?? fetch async function request<T>( path: string, init: { method?: string; query?: Record<string, string | number | undefined>; body?: unknown; signal?: AbortSignal } = {} ): Promise<T> { const url = new URL(baseUrl + path) for (const [key, value] of Object.entries(init.query ?? {})) { if (value !== undefined && value !== '') url.searchParams.set(key, String(value)) } const response = await doFetch(url, { method: init.method ?? 'GET', headers: { Authorization: `Bearer ${options.token}`, Accept: 'application/json', 'X-Gavana-Agent-Surface': options.surface ?? 'api', ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: init.body === undefined ? undefined : JSON.stringify(init.body), cache: 'no-store', signal: init.signal }) // Capture this on EVERY response, not just failures. On a 5xx it is the // only diagnostic you get — the body is deliberately generic. const requestId = response.headers.get('x-request-id') if (!response.ok) { const text = await response.text().catch(() => '') let payload: { error?: { code?: GavanaErrorCode; message?: string; fields?: GavanaErrorField[]; details?: Record<string, unknown> } } = {} try { payload = text ? JSON.parse(text) : {} } catch { /* non-JSON error body */ } throw new GavanaApiError({ status: response.status, code: payload.error?.code ?? 'request_failed', message: payload.error?.message ?? `Gavana request failed (${response.status}).`, requestId, fields: payload.error?.fields, details: payload.error?.details }) } if (response.status === 204) return null as T return (await response.json()) as T } return { baseUrl, request } }

Two decisions in there are worth calling out, because both are easy to get wrong:

  • requestId is read before the response.ok branch. Successful responses carry it too, and logging it on success is what lets you correlate a later problem with the call that caused it.
  • The error body is parsed defensively. A proxy or gateway between you and Gavana can return non-JSON, and a client that assumes JSON turns a 502 into a confusing SyntaxError.

Types for what you will touch

export type CanvasSummary = { id: string handle: string ownerUid: string title: string createdAt: string updatedAt: string revision: string accessRole?: 'owner' | 'editor' | 'viewer' nodeCount: number connectionCount: number } export type CanvasNode = { id: string handle: string type: 'image' | 'video' | 'text' | 'sticky' title: string position: { x: number; y: number } width: number height: number metadata?: Record<string, unknown> asset?: string } export type Canvas = { id: string handle: string ownerUid: string title: string createdAt: string updatedAt: string revision: string nodes: CanvasNode[] connections: Array<{ id: string; handle: string; from: string; to: string; fromNodeId: string; toNodeId: string }> } export type CursorPage = { limit: number; hasMore: boolean; nextCursor: string | null } export type MutationResponse = { canvas: Canvas replayed?: boolean resolvedIds?: Record<string, string> changedNodeIds?: string[] deletedNodeIds?: string[] changedConnectionIds?: string[] deletedConnectionIds?: string[] }

These mirror the schemas on the Canvases resource page. They are not exhaustive — Canvas and CanvasNode both allow additional server-owned properties — but they cover everything the quickstart uses.

The script

Save as quickstart.ts and run with node --experimental-strip-types quickstart.ts (Node 22+), bun quickstart.ts, or your usual TypeScript runner.

import { createGavanaClient, GavanaApiError, type Canvas, type CanvasSummary, type CursorPage, type MutationResponse } from './gavana.ts' const token = process.env.GAVANA_AGENT_TOKEN if (!token) throw new Error('Set GAVANA_AGENT_TOKEN in the environment.') const gavana = createGavanaClient({ token }) async function main() { // 1. Confirm the credential and see what it is actually allowed to do. const auth = await gavana.request<{ email: string; scopes: string[] | null; agentLabel?: string }>('/auth/status') console.log(`Authenticated as ${auth.email} (${auth.agentLabel ?? 'no label'})`) console.log(`Scopes: ${auth.scopes?.join(', ') ?? 'none'}`) if (!auth.scopes?.includes('canvas:write')) { throw new Error('This quickstart needs canvas:read and canvas:write.') } // 2. List canvases. Pages cap at 25 for this endpoint. const list = await gavana.request<{ canvases: CanvasSummary[]; page: CursorPage }>('/canvases', { query: { limit: 10 } }) if (list.canvases.length === 0) throw new Error('No canvases are visible to this token.') const target = list.canvases[0] console.log(`Using ${target.handle} — "${target.title}" (${target.nodeCount} nodes)`) // 3. Read the graph. The revision is the whole reason for this call. const { canvas } = await gavana.request<{ canvas: Canvas }>(`/canvases/${encodeURIComponent(target.id)}`, { query: { ownerUid: target.ownerUid } }) console.log(`Revision: ${canvas.revision}`) const 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 TypeScript quickstart.' } } } ] // 4. Dry run. Costs nothing, writes nothing, consumes no idempotency key, // and needs only canvas:read. const validation = await gavana.request<{ destructiveImpact: Record<string, string[]> validation: { findings: unknown[] } }>(`/canvases/${encodeURIComponent(canvas.id)}/operations`, { method: 'POST', query: { ownerUid: target.ownerUid }, body: { validateOnly: true, baseRevision: canvas.revision, operations } }) const destroyed = Object.values(validation.destructiveImpact).flat() if (destroyed.length > 0) { // Anything on this list is permanent. A human approves it, not the code. throw new Error(`Batch would destroy: ${destroyed.join(', ')}`) } console.log(`Validation findings: ${validation.validation.findings.length}`) // 5. Apply. The key is computed once, outside any retry. const idempotencyKey = `quickstart-${canvas.id}-note-001` const applied = await gavana.request<MutationResponse>( `/canvases/${encodeURIComponent(canvas.id)}/operations`, { method: 'POST', query: { ownerUid: target.ownerUid }, body: { baseRevision: canvas.revision, idempotencyKey, operations } } ) console.log(`replayed: ${applied.replayed}`) console.log(`resolvedIds: ${JSON.stringify(applied.resolvedIds)}`) console.log(`new revision: ${applied.canvas.revision}`) } main().catch((error) => { if (error instanceof GavanaApiError) { console.error(`Gavana error: ${error.message}`) console.error(error.supportLine) for (const field of error.fields ?? []) console.error(` ${field.field}: ${field.message}`) process.exit(1) } throw error })

Run it twice. The second run prints replayed: true, the same resolvedIds, and creates no second node — that is the idempotency key doing its job.

Handling a conflict properly

The one piece of real-world logic the script above leaves out is what to do when someone else edits the canvas between your read and your write.

async function applyWithRetry( gavana: ReturnType<typeof createGavanaClient>, canvasId: string, ownerUid: string, intentId: string, plan: (canvas: Canvas) => unknown[] ): Promise<MutationResponse | null> { for (let attempt = 0; attempt < 3; attempt += 1) { const { canvas } = await gavana.request<{ canvas: Canvas }>( `/canvases/${encodeURIComponent(canvasId)}`, { query: { ownerUid } } ) // Rebuild against what is there NOW, not against a stale plan. const operations = plan(canvas) if (operations.length === 0) return null // already done by someone else try { return await gavana.request<MutationResponse>( `/canvases/${encodeURIComponent(canvasId)}/operations`, { method: 'POST', query: { ownerUid }, body: { baseRevision: canvas.revision, // The batch is rebuilt each attempt, so it is a genuinely different // request and needs a different key. When retrying an IDENTICAL // batch after a network timeout, keep the key stable instead. idempotencyKey: `${intentId}-attempt-${attempt}`, operations } } ) } catch (error) { const isRevisionConflict = error instanceof GavanaApiError && error.code === 'conflict' && typeof error.details?.currentRevision === 'string' if (!isRevisionConflict) throw error // Someone won the race. Loop, re-read, rebuild. } } throw new Error('Canvas is changing faster than we can apply a batch.') }

The details.currentRevision check is what separates the two meanings of 409. A revision conflict carries it and is worth retrying after a re-read; an idempotency-key collision does not and will fail identically forever. See Revisions and Idempotency.

Retrying transient failures

const RETRYABLE = new Set(['rate_limited', 'internal_error', 'upstream_error', 'service_unavailable', 'upstream_timeout']) async function withBackoff<T>(operation: () => Promise<T>, attempts = 3): Promise<T> { for (let attempt = 0; ; attempt += 1) { try { return await operation() } catch (error) { const retryable = error instanceof GavanaApiError && RETRYABLE.has(error.code) if (!retryable || attempt >= attempts - 1) throw error const delay = 250 * 2 ** attempt + Math.random() * 100 // jitter await new Promise((resolve) => setTimeout(resolve, delay)) } } }

Wrap reads freely. For mutations, only wrap calls that carry a stable idempotency key, and never wrap a call that spends provider credit — /images/*, /videos/generate, and /recipes/{recipeId}/runs. A failed paid call goes to a human with its failure.retryable flag, not into a retry loop.

Polling a Run

const TERMINAL = new Set(['succeeded', 'failed', 'canceled', 'expired']) async function waitForRun( gavana: ReturnType<typeof createGavanaClient>, runHandle: string, options: { intervalMs?: number; timeoutMs?: number } = {} ) { const intervalMs = options.intervalMs ?? 1_500 const timeoutMs = options.timeoutMs ?? 15 * 60_000 const startedAt = Date.now() const runId = runHandle.replace(/^run:/, '') while (Date.now() - startedAt <= timeoutMs) { const run = await gavana.request<{ status: string; estimatedSeconds?: number }>( `/runs/${encodeURIComponent(runId)}` ) if (TERMINAL.has(run.status)) return run await new Promise((resolve) => setTimeout(resolve, intervalMs)) } throw new Error(`Run ${runHandle} did not finish in time. Resume by polling ${runHandle}.`) }

GET /runs/{runId} needs job:manage. The interval and timeout above match the first-party CLI’s defaults. Reading a completed image child also finalizes its durable asset and canvas node, which is why polling is not merely observation — see Generate and observe an image.

Handling the token

// Good: from the environment or a secret manager, once, at startup. const token = process.env.GAVANA_AGENT_TOKEN // Never: hard-coded, committed, in a URL, or forwarded to a webhook receiver.

If you log request headers anywhere, allowlist the headers you log rather than trying to redact Authorization. And when you attach a webhook, give it its own secret — your API token must never leave your process.

Next

Last updated on