Pagination
Every list endpoint in the Canvas API pages the same way: a named array plus a
page object holding an opaque cursor. There are no page numbers and no offsets.
{
"canvases": [
{ "id": "product-launch", "handle": "canvas:9Kd2…:product-launch", "revision": "…", "nodeCount": 14, "connectionCount": 9 }
],
"page": {
"limit": 25,
"hasMore": true,
"nextCursor": "eyJ2IjoxLCJzY29wZSI6ImNhbnZhc2VzOl8…"
}
}| Field | Type | Meaning |
|---|---|---|
page.limit | integer | The page size actually used for this response |
page.hasMore | boolean | Whether more items exist after this page |
page.nextCursor | string or null | Pass as cursor on the next request. null when hasMore is false. |
The item array keeps its own resource-specific name — canvases, assets,
recipes, actions, models, connections — so a generic pager needs to know
which key to read, or read the only array-valued key in the object.
Per-endpoint limits
The limit ceiling is not uniform. Sending a value above the endpoint’s maximum
is a 400 input_validation_error, not a silent clamp.
| Endpoint | Array key | limit range | Default |
|---|---|---|---|
GET /canvases | canvases | 1–25 | 25 |
GET /assets | assets | 1–200 | server default |
GET /recipes | recipes | 1–100 | server default |
GET /actions | actions | 1–100 | server default |
GET /models | models | 1–100 | server default |
GET /providers | connections | 1–100 | server default |
Canvases cap at 25 for a concrete reason: each CanvasSummary — including
nodeCount and connectionCount — is derived from the complete graph document,
so a page of canvas summaries is a page of full document reads. A larger page
would be a slower, less reliable request rather than a faster walk. Do not treat
25 as an oversight to work around; treat it as the endpoint telling you its real
cost.
GET /campaigns is the one list that does not paginate. It is a
deprecated legacy surface and returns a bare
runs array with no page object.
Walking a list
Request the first page
Send limit if you want a specific page size. Omit cursor.
curl -sS "https://app.gavana.ai/api/canvas-agent/v1/canvases?limit=25" \
-H "Authorization: Bearer $GAVANA_AGENT_TOKEN"Follow nextCursor
While page.hasMore is true, send page.nextCursor back as the cursor
query parameter — with every other filter unchanged.
curl -sS --get "https://app.gavana.ai/api/canvas-agent/v1/canvases" \
--data-urlencode "limit=25" \
--data-urlencode "cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $GAVANA_AGENT_TOKEN"Stop on hasMore false
nextCursor is null at that point. Do not send it.
async function* listAllCanvases(token: string) {
let cursor: string | null = null
do {
const url = new URL('https://app.gavana.ai/api/canvas-agent/v1/canvases')
url.searchParams.set('limit', '25')
if (cursor) url.searchParams.set('cursor', cursor)
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!response.ok) throw new Error(`${response.status} ${response.headers.get('x-request-id')}`)
const body = await response.json()
yield* body.canvases
cursor = body.page.hasMore ? body.page.nextCursor : null
} while (cursor)
}Always bound the loop in production — a page counter or a hard item cap — so a bug in cursor handling cannot turn into an unbounded request loop.
Cursor rules
A cursor is a scope-bound continuation token, at most 8192 characters. Treat it as opaque: it is not signed and carries no secret, so it authenticates nothing — the request’s own bearer token does that. What the cursor does carry is a digest of the list it came from, which is why it is rejected on a different list. Four rules govern it, and each one is enforced rather than advisory:
- Reuse it with the same filters. The cursor encodes the scope of the list
it came from — the endpoint plus the filter values you sent. Send it back with
a different
canvasId,ownerUid,q,provider, orcapabilityand you get400 input_validation_errorwith the message"cursor is invalid or belongs to a different list."limitis not part of the scope, so you may change the page size mid-walk. - Do not decode or construct one. It carries an internal version, the scope digest, and a position key. Its structure is not part of the contract and may change.
- Do not persist one long-term. A cursor is a position in a live list, not a bookmark. Storing one for hours and resuming from it is not a supported pattern — resume by re-walking from the start, or by filtering on data you control.
- One cursor, one next page. Cursors are not random-access. There is no way to jump to page 5, and no total count is returned by any list endpoint.
Ordering, and what changes underneath you
Canvases and assets are ordered updatedAt descending, then handle
descending — newest first, with a stable tiebreak. For canvases, owned and
shared records are merged into that single recent-first order.
That ordering has a direct consequence for a live list, because a cursor identifies the last item of the previous page, not a snapshot:
| What happens mid-walk | What you observe |
|---|---|
| An item is updated and jumps to the top | You may see it again on a later page if it was already returned, or miss it if it moved past your position |
| A new item is created | It sorts above your cursor position and will not appear in the remainder of this walk |
| An item is deleted, including the one the cursor points at | The walk continues from the next older item rather than restarting or duplicating a page |
None of this is a defect to work around — it is the normal behaviour of cursor paging over mutable data, and Gavana handles the worst case (a deleted cursor item) gracefully rather than erroring. Two habits keep it from surprising you:
- Deduplicate by handle as you collect. It costs a
Setand removes the entire class of “why did this canvas appear twice”. - Treat a full walk as approximate, not as a transactional snapshot. If you need exactness, walk to completion quickly and re-walk rather than pausing.
Pagination is not the same as scope
An empty page is not proof that nothing exists. GET /canvases returns what this
token can see, and GET /assets likewise. A token without asset:read does
not get a filtered asset list — it gets 403 forbidden. Check
GET /auth/status before concluding that an
account has no canvases.
Related
- Errors and X-Request-ID — the
400you get from a badlimitor a foreign cursor - Canvases · Assets · Recipes · Actions · Models · Providers — per-endpoint parameter tables
- Read a canvas — listing, then reading one graph