Pagination

Inbox has two pagination shapes, and which one you get depends on which surface you are on. Both live in meta alongside requestId, so you can always tell them apart by looking at the keys.

Surface Shape Default Max
Tenant-scoped lists cursor / keyset — meta.nextCursor 25 100
/v1/operator/* lists offset pages — meta: { page, limit, total, hasMore } 50 200 (1000 on /v1/operator/tenants)

The split is not an accident of history. A tenant's inbox is a live, high-churn list — new messages arrive while you are reading it, and offset paging over a moving list skips and repeats rows. An operator table is a reporting view where a human wants "page 3 of 47" and needs a total to render the control. Keyset paging cannot give you a total; offset paging cannot give you stability. Each surface gets the one it needs.

Cursor pages (tenant-scoped)

{
  "data": [ … 25 threads … ],
  "meta": {
    "requestId": "01J8Z…",
    "nextCursor": "MTc1NjY1NDMyMTAwMDp0aHJfMDFKOFo…"
  }
}

nextCursor is absent on the last page. That absence is the only end-of-list signal — there is no hasMore here.

The cursor is opaque. Concretely it is base64url("<epochMs>:<id>"), the sort timestamp and row id of the last item on the page you just received, but treat it as a token: pass it back untouched. A malformed cursor throws validation_failed (422) with "Malformed cursor.", so hand-constructing one fails loudly rather than silently returning the wrong window.

Limits come from clampLimit:

export const DEFAULT_PAGE_LIMIT = 25;
export const MAX_PAGE_LIMIT = 100;

Anything absent, non-numeric or ≤ 0 becomes 25. Anything above 100 is silently clamped to 100 — you do not get an error, you get 100 rows. If you asked for 500 and processed data.length as "all of them", you just lost 400.

Iterating:

let cursor: string | undefined;
const all: InboxThreadDto[] = [];

do {
  const { data, meta } = await inbox.inbox.threads.list({ limit: 100, cursor });
  all.push(...data);
  cursor = meta.nextCursor;
} while (cursor);

The loop condition is cursor, not data.length — a page can legitimately come back short while a nextCursor is still present.

Offset pages (operator)

{
  "data": [ … 50 rows … ],
  "meta": {
    "requestId": "01J8Z…",
    "page": 1,
    "limit": 50,
    "total": 1284,
    "hasMore": true
  }
}

page is 1-based. hasMore is computed as page * limit < total, and total respects the same filter as the page — a search narrows the count as well as the rows, so the total never describes a different set than what you are looking at.

Limits are clamped into [1, 200] with a default of 50. GET /v1/operator/tenants raises the ceiling to 1000, because a workspace picker genuinely wants the whole estate in one call; it still defaults to 50 rather than dumping everything on a caller who did not ask.

Iterating:

const ops = createOperatorClient({ baseUrl, session: operatorSessionId });

let page = 1;
const all: OperatorRow[] = [];

for (;;) {
  const { data, meta } = await ops.connections({ page, limit: 200 });
  all.push(...data);
  if (!meta.hasMore) break;
  page += 1;
}

Why paged() throws instead of guessing

The operator SDK's paged() refuses to invent a meta:

if (!meta) {
  // Do NOT synthesize `{ total: data.length, hasMore: false }`.
  throw new InboxError(502, "malformed_response",
    `Operator route ${path} returned no pagination meta; cannot page safely.`);
}

A synthesized meta is worse than an error because it is plausible. { total: data.length, hasMore: false } reads as a complete, small dataset — so the consumer renders one page, shows "50 results", and stops. Nobody notices, because nothing looks broken. The actual causes of a missing envelope are an older service version or a proxy stripping the body, and both need to be loud.

The same guard is on cursorPaged(), used for operator message transcripts. A transcript has no meaningful page number: it is walked by cursor so that appending on scroll cannot skip or repeat a message as new ones arrive underneath.

Note the asymmetry with the tenant-scoped client: requestPaged() defaults meta to {} rather than throwing, because on that surface an absent nextCursor is the ordinary end-of-list signal and cannot be distinguished from an absent envelope. If you are proxying tenant lists through a BFF, preserve meta end to end — see Envelopes and errors.