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:
requestIdis read before theresponse.okbranch. 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
502into a confusingSyntaxError.
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
- Apply a revision-safe batch — larger batches and forward references
- Generate and observe an image — the first paid flow
- Run a Recipe with a webhook — including verification code for the callback
- Errors and X-Request-ID — the full code table