> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination

> How ModuleX list endpoints paginate — offset/page, offset/limit, and next_cursor styles — plus the SDK auto-paginators (listAll, list_all, iter_runs) and manual cursor loops, with cURL, Python, and JavaScript.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

ModuleX list endpoints return one page at a time. There is **no single pagination style** across the API — different resources use different schemes, and the response field you read to fetch the next page depends on which scheme the endpoint uses. This page documents all three styles, the exact query parameters and response fields for each list endpoint, and the SDK helpers that page automatically so you do not have to loop by hand.

Every list call is authenticated the same way as any other request: `Authorization: Bearer mx_live_…` plus `X-Organization-ID`. See [Authentication](/api-reference/authentication) for the full model. All list endpoints in this reference are read-only `GET` requests, which means the SDKs will safely retry them on transient failures (`429`/`500`/`502`/`503`) — see [SDK errors & retries](/sdks/errors-retries).

## The three pagination styles

| Style              | Query params        | Response page key | "More?" signal                                         | Used by                                       |
| ------------------ | ------------------- | ----------------- | ------------------------------------------------------ | --------------------------------------------- |
| **Offset / page**  | `page`, `page_size` | `workflows`       | `total`, `total_pages` (only when `page_size` is sent) | `GET /workflows`                              |
| **Offset / limit** | `limit`, `offset`   | `runs`            | `has_more` (no total count)                            | `GET /workflow-runs`                          |
| **Cursor**         | `limit`, `cursor`   | `items`           | `next_cursor` (`null` at the end)                      | `GET /composer/chats`, `GET /assistant/chats` |

<Note>
  Responses are **snake\_case on the wire** for both SDKs. The JavaScript SDK converts your *request* parameters from camelCase to snake\_case (so you pass `pageSize` and it sends `page_size`), but it does **not** convert responses back — you read `response.total_pages`, `response.has_more`, and `response.next_cursor` exactly as the API returns them. The Python SDK is snake\_case in both directions.
</Note>

<Note>
  There is no generic, top-level `{ items, total, … }` envelope shared by every endpoint. Each list nests its rows under a **resource-specific key** (`workflows`, `runs`, or `items`) and exposes only the "more?" signal that fits its style. The JavaScript SDK ships a `PaginatedList<T>` interface whose fields are all optional precisely because it is a superset across these styles — concrete list responses use their own narrower types, not `PaginatedList` directly.
</Note>

### Choosing a style as a caller

* **Offset / page** lets you jump to an arbitrary page and shows a total count and page count — good for "page 3 of 12" UIs, at the cost of an extra `COUNT(*)` on the server.
* **Offset / limit** with `has_more` avoids the count query and is cheaper for deep history; you cannot show a total, only "there is another page."
* **Cursor** is the most stable under concurrent writes: rows are keyed by their `updated_at` timestamp, so inserting a new row while you page does not shift or duplicate items the way a moving `offset` can. Cursor lists are user-scoped and always newest-first.

<MediaEmbed id="MX-MEDIA-1210" type="image" caption={"Side-by-side comparison diagram of the three ModuleX pagination styles (offset/page, offset/limit with has_more, cursor with next_cursor)."} />

## Offset / page — `GET /workflows`

`GET /workflows` lists the workflows in your organization. It is the only list that exposes a **total count and page count**, and it does so **only when you supply `page_size`**. With no pagination parameters, it returns *all* matching workflows in a single response plus `total` — there is no implicit default page size.

This is also the only endpoint with an SDK **auto-paginator** in both clients (JavaScript `workflows.listAll`, Python `workflows.list_all`).

### Request parameters

<ParamField query="page" type="integer">
  1-based page number. Must be `>= 1`. Optional. When omitted but `page_size` is sent, the server defaults to page `1`.
</ParamField>

<ParamField query="page_size" type="integer">
  Items per page, `1`–`100`. Optional. **Pagination metadata (`page`, `page_size`, `total_pages`) is included in the response only when this parameter is present.** Omit both `page` and `page_size` to receive every matching workflow at once. In the JavaScript SDK this parameter is `pageSize` (camelCase) and is sent as `page_size`.
</ParamField>

<ParamField query="status" type="string">
  Filter by lifecycle status (for example `active`, `draft`, `published`). Optional.
</ParamField>

<ParamField query="category" type="string">
  Filter by workflow category. Optional.
</ParamField>

<ParamField query="visibility" type="string">
  Filter by sharing visibility — one of `private`, `organization`, `public`, or `system`. Optional. New workflows default to `organization`, and `private` is no longer creator-only (it behaves like `organization`).
</ParamField>

<ParamField query="search" type="string">
  Free-text search over workflow name/description. Optional.
</ParamField>

### Response

```json theme={null}
{
  "workflows": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Weekly AI digest",
      "description": "Summarize the week's AI news",
      "version": "1.0",
      "status": "active",
      "visibility": "organization",
      "category": "research",
      "tags": ["news", "summary"],
      "creator_id": "9c4f2e1a-7b3d-4c8e-9f0a-1b2c3d4e5f6a",
      "created_at": "2026-06-01T09:00:00+00:00",
      "updated_at": "2026-06-19T14:32:10+00:00"
    }
  ],
  "total": 134,
  "page": 1,
  "page_size": 50,
  "total_pages": 3
}
```

<ResponseField name="workflows" type="WorkflowSummary[]">
  The page of workflow summaries. Rows are nested under `workflows`, not a generic `items` key.
</ResponseField>

<ResponseField name="total" type="integer">
  The total number of workflows matching the filters across all pages. **Always present**, regardless of whether pagination parameters were sent.
</ResponseField>

<ResponseField name="page" type="integer">
  The current 1-based page. **Present only when `page_size` was supplied** in the request.
</ResponseField>

<ResponseField name="page_size" type="integer">
  The page size echoed back. **Present only when `page_size` was supplied.**
</ResponseField>

<ResponseField name="total_pages" type="integer">
  `ceil(total / page_size)`. **Present only when `page_size` was supplied.** This is the field auto-paginators read to know when to stop.
</ResponseField>

### Paging by hand

Increment `page` until you reach `total_pages`.

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.modulex.dev/workflows?status=active&page=1&page_size=50" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      async with Modulex(
          api_key="mx_live_xxx",
          organization_id="11111111-1111-1111-1111-111111111111",
      ) as client:
          page = 1
          while True:
              result = await client.workflows.list(
                  status="active", page=page, page_size=50
              )
              for wf in result.workflows:
                  print(wf.id, wf.name)
              if page >= result.total_pages:
                  break
              page += 1


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "11111111-1111-1111-1111-111111111111",
  });

  let page = 1;
  while (true) {
    const result = await client.workflows.list({
      status: "active",
      page,
      pageSize: 50,
    });
    for (const wf of result.workflows) {
      console.log(wf.id, wf.name);
    }
    const totalPages = result.total_pages ?? Math.ceil(result.total / 50);
    if (page >= totalPages) break;
    page += 1;
  }
  ```
</CodeGroup>

### Auto-paginating with the SDKs

Both SDKs expose a generator that walks every page for you, fixing `page_size` at `100` internally and yielding one workflow at a time. The JavaScript method is `workflows.listAll`; the Python method is `workflows.list_all`. Pass the same filters as `list`, **minus** `page`/`page_size` (the helper controls those).

<CodeGroup>
  ```python Python theme={null}
  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      # list_all returns an AsyncPage[WorkflowResponse]; iterate it directly.
      async for wf in client.workflows.list_all(status="active", search="digest"):
          print(wf.id, wf.name)  # typed WorkflowResponse
  ```

  ```javascript JavaScript theme={null}
  // listAll is the ONLY auto-paginator in the JS SDK — workflows only.
  for await (const wf of client.workflows.listAll({ status: "active", search: "digest" })) {
    console.log(wf.id, wf.name); // WorkflowSummary
  }
  ```
</CodeGroup>

<Note>
  There is no cURL equivalent for `listAll` / `list_all`. Auto-pagination is an SDK convenience that loops the underlying `GET /workflows` calls; with raw HTTP you page manually as shown above.
</Note>

## Offset / limit — `GET /workflow-runs`

`GET /workflow-runs` returns durable run history for the organization, newest first. It is **preview-optimized** (it omits the heavy input/output snapshot columns) and deliberately **does not return a total count** — paging uses a `has_more` boolean instead, so the server never runs a `COUNT(*)` over potentially huge history. Advance by adding `limit` to `offset` until `has_more` is `false`.

<Warning>
  The `id` field on each run record is what you pass to `GET /workflow-runs/{run_pk}`. It is **not** the same as the per-execution `run_id` used by `GET /workflows/listen/{run_id}` and `POST /workflows/cancel/{run_id}`. Passing a `run_id` to the by-id endpoint returns `404`. ModuleX uses "run id" in three distinct senses — see [Workflows & runs](/concepts/workflows-and-runs).
</Warning>

### Request parameters

<ParamField query="limit" type="integer" default="50">
  Page size, `1`–`100`. Optional, defaults to `50`.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Number of rows to skip from the start of the (newest-first) result set. Must be `>= 0`. Optional, defaults to `0`.
</ParamField>

<ParamField query="workflow_id" type="string">
  Filter to a single workflow's run history (UUID). Optional. In the JavaScript SDK this is `workflowId`.
</ParamField>

<ParamField query="status" type="string">
  Filter by durable run status (for example `succeeded`, `failed`, `running`). Optional.
</ParamField>

<ParamField query="trigger_type" type="string">
  Filter by how the run was triggered (for example `api`, `schedule`, `chat`). Optional. In the JavaScript SDK this is `triggerType`.
</ParamField>

### Response

```json theme={null}
{
  "runs": [
    {
      "id": "7f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
      "run_id": "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "trigger_type": "api",
      "is_ad_hoc": false,
      "status": "succeeded",
      "started_at": "2026-06-19T14:30:00+00:00",
      "completed_at": "2026-06-19T14:30:42+00:00",
      "duration_seconds": 42.0,
      "error_message": null,
      "created_at": "2026-06-19T14:30:00+00:00",
      "has_output": true
    }
  ],
  "has_more": true,
  "limit": 50,
  "offset": 0
}
```

<ResponseField name="runs" type="WorkflowRunListItem[]">
  The page of run-history rows, newest first. Rows are nested under `runs`.
</ResponseField>

<ResponseField name="has_more" type="boolean">
  `true` when another page exists at `offset + limit`. The server determines this without a count query (it fetches one row beyond the page). Stop when this is `false`.
</ResponseField>

<ResponseField name="limit" type="integer">
  The page size echoed back.
</ResponseField>

<ResponseField name="offset" type="integer">
  The offset echoed back.
</ResponseField>

### Paging by hand

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.modulex.dev/workflow-runs?status=succeeded&limit=50&offset=0" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      offset = 0
      limit = 50
      while True:
          result = await client.executions.list_runs(
              status="succeeded", limit=limit, offset=offset
          )
          for run in result.runs:
              print(run.run_id, run.status)
          if not result.has_more:
              break
          offset += limit
  ```

  ```javascript JavaScript theme={null}
  let offset = 0;
  const limit = 50;
  while (true) {
    const result = await client.workflowRuns.list({
      status: "succeeded",
      limit,
      offset,
    });
    for (const run of result.runs) {
      console.log(run.run_id, run.status);
    }
    if (!result.has_more) break;
    offset += limit;
  }
  ```
</CodeGroup>

<Note>
  Run history is grouped differently in the two SDKs. The Python SDK folds it into `client.executions` (`list_runs` for one page, `iter_runs` to auto-paginate). The JavaScript SDK exposes a separate `client.workflowRuns` resource (`list` / `get`) and has **no** auto-paginator for runs — loop manually. Both call the same `GET /workflow-runs` routes.
</Note>

### Auto-paginating runs (Python only)

The Python SDK provides `executions.iter_runs`, which walks every page using `has_more` and yields typed `WorkflowRunListItem` records. Its internal page size defaults to `50`.

<CodeGroup>
  ```python Python theme={null}
  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      async for run in client.executions.iter_runs(status="succeeded"):
          print(run.run_id, run.status)  # typed WorkflowRunListItem
  ```
</CodeGroup>

<Warning>
  There is **no JavaScript equivalent of `iter_runs`**. The JS SDK's only auto-paginator is `workflows.listAll`. For run history in JavaScript, loop `client.workflowRuns.list({ offset })` on `has_more` as shown above. This gap is tracked in the [SDK parity matrix](/sdks/parity).
</Warning>

## Cursor — `GET /composer/chats` and `GET /assistant/chats`

The [AI Composer](/concepts/ai-composer) and [Assistant](/assistant/overview) chat lists are **user-scoped** (they return the calling user's own chats) and use cursor pagination keyed on each row's `updated_at` timestamp, newest first. You pass the previous page's last `updated_at` back as the `cursor` to fetch the next page; the server returns `next_cursor`, which is `null` once there are no more pages. Both endpoints have an identical contract.

Because the cursor is an `updated_at` timestamp rather than a moving offset, inserting or updating chats while you page does not skip or duplicate rows the way `offset` can.

### Request parameters

<ParamField query="limit" type="integer" default="20">
  Page size, `1`–`100`. Optional, defaults to `20`.
</ParamField>

<ParamField query="cursor" type="string">
  An ISO 8601 timestamp — the `updated_at` of the **last item on the previous page**. Omit it to fetch the first (newest) page. Optional.
</ParamField>

<Warning>
  `cursor` must be a valid ISO 8601 timestamp. A malformed value returns `400` with `{"detail": "cursor must be an ISO timestamp"}`. Always source the cursor from a prior response's `next_cursor` rather than constructing it yourself.
</Warning>

### Response

```json theme={null}
{
  "items": [
    {
      "id": "c1a2b3c4-d5e6-7f80-9a1b-2c3d4e5f6a7b",
      "title": "Build a lead-routing workflow",
      "created_at": "2026-06-18T11:00:00+00:00",
      "updated_at": "2026-06-19T16:45:12+00:00",
      "is_running": false
    }
  ],
  "next_cursor": "2026-06-19T16:45:12+00:00"
}
```

<ResponseField name="items" type="ChatSummary[]">
  The page of chat summaries, newest first. Rows are nested under `items` (this is the one list family that uses the generic `items` key). The Composer list adds a `workflow_id` field on each row; the Assistant list does not.
</ResponseField>

<ResponseField name="next_cursor" type="string | null">
  The cursor for the next page — the `updated_at` of the last item — **or `null` when there are no more pages**. The server returns a non-null `next_cursor` only when the page came back full (exactly `limit` rows), so a short final page yields `null`.
</ResponseField>

### Paging by hand

Loop, passing `next_cursor` back as `cursor`, until `next_cursor` is `null`. Neither SDK ships an auto-paginator for these lists, so you loop manually in every language.

<CodeGroup>
  ```bash cURL theme={null}
  # First page
  curl "https://api.modulex.dev/assistant/chats?limit=20" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"

  # Next page — pass the previous response's next_cursor (URL-encoded)
  curl "https://api.modulex.dev/assistant/chats?limit=20&cursor=2026-06-19T16%3A45%3A12%2B00%3A00" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      cursor = None
      while True:
          page = await client.assistant.list(limit=50, cursor=cursor)
          for chat in page.items:
              print(chat.title)
          cursor = page.next_cursor
          if cursor is None:
              break
  ```

  ```javascript JavaScript theme={null}
  let cursor;
  do {
    const page = await client.assistant.list({ limit: 50, cursor });
    for (const chat of page.items) {
      console.log(chat.title);
    }
    cursor = page.next_cursor ?? undefined;
  } while (cursor);
  ```
</CodeGroup>

<Note>
  Swap `assistant` for `composer` to page Composer chats — `client.composer.list(...)` takes the same `limit` and `cursor` and returns the same `{ items, next_cursor }` shape (plus a `workflow_id` per row).
</Note>

## SDK auto-paginator reference

Only three SDK methods auto-paginate. Everything else is a single page you loop yourself.

| Method                       | SDK        | Endpoint             | Style  | Yields                | Notes                                                                              |
| ---------------------------- | ---------- | -------------------- | ------ | --------------------- | ---------------------------------------------------------------------------------- |
| `workflows.listAll(params?)` | JavaScript | `GET /workflows`     | page   | `WorkflowSummary`     | The **only** JS auto-paginator. Returns an `AsyncGenerator`; fixes `pageSize=100`. |
| `workflows.list_all(...)`    | Python     | `GET /workflows`     | page   | `WorkflowResponse`    | Returns an `AsyncPage[T]`. Fixes `page_size=100`.                                  |
| `executions.iter_runs(...)`  | Python     | `GET /workflow-runs` | offset | `WorkflowRunListItem` | Returns an `AsyncPage[T]`; default page size `50`. No JS equivalent.               |

<Note>
  In Python, `list_all` and `iter_runs` return an `AsyncPage[T]` — a lazy async-iterable that validates each row into the typed Pydantic model as you iterate. You consume it with `async for`; it fetches pages on demand, not all at once. In JavaScript, `listAll` is a plain `AsyncGenerator` you consume with `for await`.
</Note>

The cursor lists (`composer.list`, `assistant.list`) and the JavaScript runs list (`workflowRuns.list`) return a **single page**; you loop them by hand using the snippets above. There is no plan to wrap the cursor lists in an auto-paginator at present. The full route-to-method mapping, including these pagination gaps, is in the [SDK ⇄ API parity matrix](/sdks/parity).

## Edge cases and gotchas

<AccordionGroup>
  <Accordion title="Why doesn't GET /workflows return total_pages?">
    It does — but **only when you send `page_size`**. With no pagination parameters, `GET /workflows` returns every matching workflow plus `total`, and omits `page`, `page_size`, and `total_pages` entirely. If your code reads `response.total_pages` unconditionally, send `page_size` or fall back to `Math.ceil(total / pageSize)` (which is exactly what the JS `listAll` helper does).
  </Accordion>

  <Accordion title="GET /workflow-runs has no total — how do I show a count?">
    You can't get an exact count from this endpoint; it intentionally trades the count query for cheaper deep paging. Use `has_more` to drive a "Load more" affordance instead of "page N of M". If you need an authoritative total, that signal is not exposed here.
  </Accordion>

  <Accordion title="My cursor request returns 400.">
    `cursor` must be a valid ISO 8601 timestamp. The response is `{"detail": "cursor must be an ISO timestamp"}`. Always reuse a prior response's `next_cursor` verbatim; do not hand-build or truncate it. When sending it over raw HTTP, URL-encode it (the `+` and `:` characters in a timestamp must be percent-encoded).
  </Accordion>

  <Accordion title="Items shift or duplicate while I page through history.">
    This is inherent to `offset`-based paging (`GET /workflow-runs`) when rows are inserted during iteration — a newer run pushes everything down by one, so a fixed `offset` can re-show or skip a row. Cursor pagination (`GET /composer/chats`, `GET /assistant/chats`) avoids this because the cursor pins to an `updated_at` timestamp rather than a positional offset. If stable iteration over runs matters, filter to a closed set (for example a single `workflow_id` plus a terminal `status`) so new runs don't enter the window.
  </Accordion>

  <Accordion title="Do list calls hit the billing gate?">
    No. The flat `DenialEnvelope` (`402`/`403`/`429`) is returned only by the billing-gated surfaces — run, composer, assistant, and managed knowledge. The list endpoints documented here are plain reads; on error they return the standard FastAPI `{"detail": "<message>"}` shape, or a header-based `429` if you exceed the request rate limit. See [Errors & status codes](/api-reference/errors) and [Rate limiting](/api-reference/rate-limiting).
  </Accordion>

  <Accordion title="JavaScript: why do I read response.total_pages and not response.totalPages?">
    The JS SDK converts your **request** parameters from camelCase to snake\_case (`pageSize` → `page_size`) but leaves **responses** in their original snake\_case. So you pass `pageSize`/`workflowId`/`triggerType` in, and read `total`, `total_pages`, `has_more`, and `next_cursor` out. The Python SDK is snake\_case in both directions.
  </Accordion>
</AccordionGroup>

## Errors on list endpoints

List endpoints use the standard FastAPI error envelope `{"detail": "<message>"}`.

| Status | When it happens                                                                                                                                                                                                        |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Missing `X-Organization-ID`; or a malformed `cursor` on a cursor list (`{"detail": "cursor must be an ISO timestamp"}`).                                                                                               |
| `401`  | Missing or invalid token (`WWW-Authenticate: Bearer` is returned).                                                                                                                                                     |
| `403`  | The caller lacks the required role. The run-history, Composer, and Assistant lists require an organization **owner or admin** (the `member` role is retired — see [Roles & permissions](/security/roles-permissions)). |
| `422`  | A query parameter failed validation (for example `page_size` outside `1`–`100`), returned as the FastAPI validation array.                                                                                             |
| `429`  | You exceeded the per-key or per-user request rate limit. The response carries `Retry-After` and `X-RateLimit-*` headers — see [Rate limiting](/api-reference/rate-limiting).                                           |
| `500`  | An unexpected server error.                                                                                                                                                                                            |

The SDKs map these to typed errors: `401` → `AuthenticationError`, `403` → `PermissionError`, `404` → `NotFoundError`, `422` → `ValidationError`, and `429` → `RateLimitError` (which exposes `retryAfter`/`retry_after`, `limit`, `remaining`, and `reset` from the headers). The read-only list calls are auto-retried on `429`/`500`/`502`/`503` with backoff that honors `Retry-After`. See [SDK errors & retries](/sdks/errors-retries) for the complete error class hierarchy and retry policy.

## Next steps

<CardGroup cols={2}>
  <Card title="SDKs overview" icon="cubes" href="/sdks/overview">
    The JavaScript and Python clients, unified by operation, with install and configuration.
  </Card>

  <Card title="SDK ⇄ API parity matrix" icon="table-list" href="/sdks/parity">
    Every REST route mapped to its JS and Python methods, with pagination and other gaps called out.
  </Card>

  <Card title="Errors & status codes" icon="triangle-exclamation" href="/api-reference/errors">
    The three error-envelope shapes and which surface emits each.
  </Card>

  <Card title="Rate limiting" icon="gauge-high" href="/api-reference/rate-limiting">
    Per-key and per-user limits, the `429` response, and the rate-limit headers.
  </Card>
</CardGroup>
