# Pagination

Every list endpoint answers with the same envelope:

```json
{
  "data": [ … ],
  "has_more": true,
  "next_cursor": "eyJzIjoiMjAyNi0wOC0wOVQxMjozNDo1NloiLCJpIjoi…"
}
```

| Field | |
|---|---|
| `data` | The rows. |
| `has_more` | Whether more rows exist past this page. |
| `next_cursor` | Pass back as `cursor` to get the next page. `null` on the last page. |

`has_more` is not inferred from the page being full. The API requests one row more than
you asked for and reports whether it came back, so a full page with `has_more: false` is
an accurate answer rather than an estimate.

## Page with a cursor

Send the previous response's `next_cursor` back as `cursor`:

<CodeTabs syncKey="lang">

```bash title="cURL"
curl "https://api.genuineai.app/api/v1/files?repo_type=media&limit=100" \
  -H "X-Api-Key: gai_…" -H "X-Tenant-Id: <workspace-id>"

curl "https://api.genuineai.app/api/v1/files?repo_type=media&limit=100&cursor=eyJzIjoi…" \
  -H "X-Api-Key: gai_…" -H "X-Tenant-Id: <workspace-id>"
```

```js title="JavaScript"
const first = await fetch(
  "https://api.genuineai.app/api/v1/files?repo_type=media&limit=100",
  { headers },
).then(r => r.json());

const second = await fetch(
  `https://api.genuineai.app/api/v1/files?repo_type=media&limit=100&cursor=${first.next_cursor}`,
  { headers },
).then(r => r.json());
```

```python title="Python"
first = requests.get(
    "https://api.genuineai.app/api/v1/files",
    params={"repo_type": "media", "limit": 100},
    headers=headers,
).json()

second = requests.get(
    "https://api.genuineai.app/api/v1/files",
    params={"repo_type": "media", "limit": 100, "cursor": first["next_cursor"]},
    headers=headers,
).json()
```

</CodeTabs>

Stop when `has_more` is `false`:

<CodeTabs syncKey="lang">

```js title="JavaScript"
let cursor = null;
do {
  const url = new URL("https://api.genuineai.app/api/v1/files");
  url.searchParams.set("repo_type", "media");
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("cursor", cursor);

  const page = await fetch(url, { headers }).then(r => r.json());
  await handle(page.data);

  cursor = page.next_cursor;
} while (cursor);
```

```python title="Python"
cursor = None
while True:
    params = {"repo_type": "media", "limit": 100}
    if cursor:
        params["cursor"] = cursor

    page = requests.get(
        "https://api.genuineai.app/api/v1/files",
        params=params,
        headers=headers,
    ).json()
    handle(page["data"])

    cursor = page["next_cursor"]
    if not cursor:
        break
```

</CodeTabs>

The cursor is opaque. It is base64url and encodes a position in the sort order, but its
contents are not part of the contract, so decoding it will break your client. A cursor
the API cannot read is ignored rather than rejected, and you get the first page back.

**A cursor belongs to the sort it was issued under.** Change `sort` between pages and the
position no longer means anything. Start the pass again instead.

## Why cursors rather than offsets

Cost. Several of the tables you are most likely to page (messages, tasks, threads,
usage) are partitioned by time, and a deep `offset` has to scan and discard rows across
partitions to reach the requested starting point. It gets slower the further you go, and
it degrades fastest for the workspaces with the most data. A keyset cursor costs the same
on page 900 as on page 2.

Cursors are also stable under writes. With `offset`, a row inserted while you page shifts
everything down by one and you read a row twice; delete a row and you skip one entirely.
A cursor records a position in the data rather than a count of rows already seen.

`offset` is still accepted for page-number grids that need to jump to page 12, but it is
not the recommended approach for integrations.

## Sorting

Use `sort=name` for ascending and `sort=-created` for descending. Sortable columns are
declared per resource. A column that is not sortable falls back to the resource's default
order rather than being sorted on, so check the results rather than assuming.

## Limits

| Parameter | Default | Maximum |
|---|---|---|
| `limit` | 50 | 1000 |
| `limit_extended` | — | 10000 |

Where an endpoint accepts `limit_extended`, it is intended for exports and bulk reads
rather than interactive listing.

Asking for more than the maximum returns the clamped page with `has_more: true`, so the
loop above terminates correctly regardless of what you requested.

## Filtering

Collections filter on their own columns, using the column name directly:

```
GET /api/v1/agents?agent_active=true
GET /api/v1/files?repo_type=ds&repo_id=<kb-id>&file_name=invoice
```

Each operation's page in the [reference](/reference) lists the filters it accepts. They
are read from the validators that enforce them, so the list matches the endpoint's actual
behavior.

## Not every list is paginated yet

The envelope is being adopted resource by resource. A collection that has not been
converted returns a bare JSON array, so check the shape rather than assuming:

<CodeTabs syncKey="lang">

```js title="JavaScript"
const rows = Array.isArray(body) ? body : body.data;
```

```python title="Python"
rows = body if isinstance(body, list) else body["data"]
```

</CodeTabs>

This is a temporary compatibility check. New resources ship with the envelope, and
converted resources do not revert.
