> ## 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.

# SDK to API parity matrix

> Every backend REST route mapped to its modulex-js and modulex-python method, with JS-only and Python-only gaps, naming divergences, and the routes neither SDK covers.

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>;
};

{/* FEEDS: X08 (sdk-vs-api-parity), X09 (glossary), _sdk-findings */}

Use this page to find the exact [modulex-js](/sdks/javascript) and [modulex-python](/sdks/python) method for any backend REST route — and to see, at a glance, where the two SDKs diverge in coverage, naming, or behavior. It is the single source of truth for "which method calls this endpoint" and "does this endpoint have a method at all."

Every row traces to a backend router and a per-SDK method. Where the SDKs disagree, both sides are shown. For the operation-level request and response detail, follow the route into the [Endpoints reference](/api-reference/overview); for the SDK client setup, see the [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python) pages.

<Note>
  This matrix covers **method-to-route coverage** and **cross-SDK naming and behavior divergence**. Per-route status codes and error envelopes live in [Errors and status codes](/api-reference/errors); SSE frame shapes live in [SSE run streaming](/realtime/sse-streaming); credit gating lives in [Usage gating and limits](/billing/usage-gating).
</Note>

<MediaEmbed id="MX-MEDIA-2050" type="image" caption={"A three-column parity diagram — REST route, modulex-js method, modulex-python method — that visually flags the two real coverage gaps (JS-only items and the Python-only `subscriptions` resource)."} />

## How to read this page

The SDKs are thin clients over the backend REST surface. A method calls `base_url + path` and returns the response. Three rules make the mapping predictable, and three structural facts make it diverge.

The predictable rules:

* **Method names follow the route, with casing per language.** JavaScript uses camelCase verbs (`setDefault`, `getState`); Python uses snake\_case (`set_default`, `get_state`). The [naming divergences](#method-name-divergences) section lists the handful of cases where the names differ by more than casing.
* **Auth is identical in both SDKs.** Every request sends `Authorization: Bearer mx_live_…` plus `X-Organization-ID` when an organization is resolved. See [Authentication](/api-reference/authentication). There is no `X-Authorization` header anywhere — neither SDK sends it and the backend does not read it.
* **One backend route can map to differently-grouped SDK methods.** For example, `GET /workflow-runs` is `workflowRuns.list` in JavaScript but `executions.list_runs` in Python.

The structural divergences — covered in detail below — are: the JavaScript SDK returns **raw, un-modeled responses** while Python returns **typed Pydantic models**; the **`subscriptions` resource exists only in Python**; and **environment-variable fallback exists only in Python**.

## The three structural divergences

These three facts are load-bearing for any side-by-side SDK code. Read them before the matrix.

<AccordionGroup>
  <Accordion title="Responses: JS returns raw snake_case, Python returns typed models" icon="code">
    The JavaScript SDK takes **camelCase** parameters and converts them to snake\_case before sending (`convertKeysToSnakeCase`), but it does **not** convert responses back — it returns the parsed JSON verbatim, so response fields stay snake\_case (`run_id`, `created_at`). A JS call is effectively `response.json() as T`: there is no runtime model, no validation, and no field renaming on the way out. This is the JS-only "raw response" behavior — camelCase in, snake\_case out, asymmetric by design.

    The Python SDK is snake\_case end to end. Requests are built as snake\_case dicts verbatim, and responses are parsed into Pydantic models (configured `extra="allow"`, with a dict-compatible shim). Field names are stable in both directions.

    ```ts JavaScript theme={null}
    // camelCase params in, snake_case fields out
    const wf = await client.workflows.create({ name: "Lead triage", isActive: true });
    console.log(wf.created_at); // snake_case on the response — NOT createdAt
    ```

    ```python Python theme={null}
    # snake_case both ways, typed model out
    wf = await client.workflows.create(name="Lead triage", is_active=True)
    print(wf.created_at)  # typed attribute on a Pydantic model
    ```

    There are two casing traps inside the JS request body itself: DSL config fields are already snake\_case in TypeScript (`integration_name`, `system_prompt`), so the converter is a no-op on them, and `UpdateChatParams.is_private` is snake\_case in the TS parameter surface — both break the otherwise-camelCase JS convention.
  </Accordion>

  <Accordion title="subscriptions resource: Python-only" icon="credit-card">
    `modulex-python` exposes a full `client.subscriptions` resource (four methods). `modulex-js` has **no** `subscriptions` resource and no subscriptions methods — there is no `subscriptions.ts` in the JS SDK at all. This is a real parity gap, not a naming difference. See [the subscriptions table](#subscriptions-python-only) and [Subscriptions and Stripe](/billing/subscription-lifecycle).

    The JavaScript SDK surfaces billing only **indirectly**: `organizations.invitePreview` returns a prorated seat cost, and the dashboard analytics payloads carry `subscription` and `current_month_credit_usage` fields. If you need plan, billing, checkout, or customer-portal calls from JavaScript, call the REST routes directly with `fetch`.
  </Accordion>

  <Accordion title="Environment-variable fallback: Python YES, JS NO" icon="terminal">
    The Python client reads `MODULEX_API_KEY`, `MODULEX_BASE_URL`, and `MODULEX_ORGANIZATION_ID` from the environment when the corresponding argument is omitted. The JavaScript client reads **none** of them — `apiKey` must be passed to the constructor or it throws, and `baseUrl` / `organizationId` are constructor-only.

    | Setting         | modulex-js                                                    | modulex-python                                                   |
    | --------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- |
    | API key         | constructor `apiKey` only; throws if missing                  | `api_key=` arg **or** `MODULEX_API_KEY`; `ValueError` if neither |
    | Base URL        | constructor `baseUrl` only; default `https://api.modulex.dev` | `base_url=` arg **or** `MODULEX_BASE_URL` **or** default         |
    | Organization ID | constructor `organizationId` only                             | `organization_id=` arg **or** `MODULEX_ORGANIZATION_ID`          |

    ```ts JavaScript theme={null}
    // JS: no env fallback — apiKey is required in the constructor
    import { Modulex } from "modulex-js";

    const client = new Modulex({
      apiKey: "mx_live_xxx",
      organizationId: "org_xxx",
    });
    ```

    ```python Python theme={null}
    # Python: omit args to fall back to MODULEX_API_KEY / MODULEX_BASE_URL / MODULEX_ORGANIZATION_ID
    from modulex import Modulex

    client = Modulex()  # reads the environment
    ```

    The JavaScript README shows `process.env.MODULEX_API_KEY` in example code, but that is caller code, not SDK behavior. `timeout`, `max_retries`, and `default_headers` have **no** environment fallback in either SDK.
  </Accordion>
</AccordionGroup>

### Header divergences (the small ones)

The auth and org headers are identical across both SDKs. Only two header behaviors differ:

| Header                  | modulex-js                   | modulex-python                                           | Backend                               |
| ----------------------- | ---------------------------- | -------------------------------------------------------- | ------------------------------------- |
| `Authorization: Bearer` | sent                         | sent                                                     | required (or alternative `X-API-KEY`) |
| `X-Organization-ID`     | sent when org resolves       | sent when org resolves                                   | read; `400` if required and missing   |
| `User-Agent`            | not sent                     | `modulex-python/<version>`                               | not significant                       |
| `Idempotency-Key`       | **never sent by any method** | sent when you pass `idempotency_key=` on a mutating call | see the note below                    |

<Warning>
  **`Idempotency-Key` does not de-duplicate runs.** Python plumbs an `idempotency_key` argument through to the `Idempotency-Key` header, but `POST /workflows/run` assigns its own `run_id`, so passing it does **not** prevent a duplicate run. JavaScript does not send the header. Do not rely on `Idempotency-Key` to make `executions.run` safe to retry; see [Errors and retries](/sdks/errors-retries).
</Warning>

## Resource-group coverage

Both SDKs advertise 17 resource groups, but the **sets differ by one**: JavaScript lacks `subscriptions`. Two more groups are structured differently:

| Resource group  | modulex-js                                                                                           | modulex-python                                                                           | Note                                    |
| --------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------- |
| Run history     | **two** groups: `executions` (run/listen/resume/cancel/getState) + `workflowRuns` (list/get history) | **one** group: `executions`, folding history in as `list_runs` / `iter_runs` / `get_run` | same backend routes, different grouping |
| `subscriptions` | **absent**                                                                                           | present (4 methods)                                                                      | Python-only parity gap                  |
| `system`        | 2 methods (no `health`)                                                                              | 3 methods (includes `health`)                                                            | JS-only gap on `GET /system/health`     |

Per-resource method counts:

| Resource      |   JS  |           Python           |
| ------------- | :---: | :------------------------: |
| auth          |   6   |              6             |
| workflows     |   8   |              8             |
| executions    |   5   |              8             |
| workflowRuns  |   2   | — (folded into executions) |
| deployments   |   6   |              6             |
| schedules     |   11  |             11             |
| chats         |   6   |              6             |
| credentials   |   17  |     15 + 1 SSE factory     |
| integrations  |   8   |              8             |
| knowledge     |   19  |             19             |
| organizations |   12  |             11             |
| apiKeys       |   4   |              4             |
| dashboard     |   5   |              5             |
| notifications |   2   |              2             |
| composer      |   11  |             11             |
| assistant     |   8   |              8             |
| system        |   2   |              3             |
| subscriptions | **0** |              4             |

<Note>
  Two count differences are **tally artifacts, not coverage gaps**. The credentials sets are equivalent — Python counts its sync SSE factory (`bulk_modulex_keys_stream`) separately, so it reads "15 + 1" against JS's "17". The organizations sets cover the **same 11 routes**; the JS note tallies 12. Treat both rows as full parity by route. The open question on the organizations tally is tracked in [Open questions](#open-questions).
</Note>

## The route-to-method matrix

Legend: a check means the SDK has a method for that route; a cross means it does not. Paths are backend-relative — the SDK calls `base_url + path`. Where a method name differs by more than casing, the cell shows the exact name.

### auth

| Route                                | modulex-js               | modulex-python            |
| ------------------------------------ | ------------------------ | ------------------------- |
| `GET /auth/me`                       | `auth.me`                | `auth.me`                 |
| `GET /auth/me/organizations`         | `auth.organizations`     | `auth.organizations`      |
| `GET /auth/invitations/my`           | `auth.invitations`       | `auth.invitations`        |
| `POST /auth/invitations/{id}/accept` | `auth.acceptInvitation`  | `auth.accept_invitation`  |
| `POST /auth/invitations/{id}/reject` | `auth.rejectInvitation`  | `auth.reject_invitation`  |
| `POST /auth/organizations/leave`     | `auth.leaveOrganization` | `auth.leave_organization` |

`POST /auth/organizations/leave` requires `X-Organization-ID`.

### workflows (CRUD and builder)

| Route                               | modulex-js                             | modulex-python                          |
| ----------------------------------- | -------------------------------------- | --------------------------------------- |
| `POST /workflows` (201)             | `workflows.create`                     | `workflows.create`                      |
| `GET /workflows`                    | `workflows.list` + `workflows.listAll` | `workflows.list` + `workflows.list_all` |
| `GET /workflows/{id}`               | `workflows.get`                        | `workflows.get`                         |
| `PUT /workflows/{id}`               | `workflows.update`                     | `workflows.update`                      |
| `DELETE /workflows/{id}`            | `workflows.delete`                     | `workflows.delete`                      |
| `GET /workflows/builder/details`    | `workflows.builderDetails`             | `workflows.builder_details`             |
| `GET /workflows/{id}/changes` (SSE) | `workflows.listenChanges`              | `workflows.listen_changes`              |

`workflows.create`, `update`, and `delete` require an owner or admin [role](/security/roles-permissions). `DELETE /workflows/{id}` is a hard delete. `listAll` / `list_all` are auto-paginators over the same `GET /workflows` route, not separate endpoints.

### executions (run, state, resume, cancel, listen)

These routes live under the `/workflows` router; on the SDK side they are `executions.*`.

| Route                                 | modulex-js            | modulex-python         |
| ------------------------------------- | --------------------- | ---------------------- |
| `POST /workflows/run`                 | `executions.run`      | `executions.run`       |
| `GET /workflows/state/{threadId}`     | `executions.getState` | `executions.get_state` |
| `POST /workflows/resume/{threadId}`   | `executions.resume`   | `executions.resume`    |
| `POST /workflows/cancel/{runId}`      | `executions.cancel`   | `executions.cancel`    |
| `GET /workflows/listen/{runId}` (SSE) | `executions.listen`   | `executions.listen`    |

The legacy LLM-only mode of `POST /workflows/run` returns **410 Gone**; use [`assistant.chat`](#assistant) for agentic chat. `getState` / `get_state` return `404` (not `403`) when you do not own the thread.

<Warning>
  **Three distinct "run id" identities.** Do not assume one identifier across these calls. The per-execution `run_id` is what SSE, status, and history use; a **new** `run_id` is minted on every agent resume. The durable `run_id` keys the run record. The `id` field (returned by list/get) is a third identifier — and it, not the execution `run_id`, is what `workflowRuns.get` / `get_run` take. See [Workflows and runs](/concepts/workflows-and-runs).
</Warning>

### workflow-runs (durable history)

| Route                        | modulex-js          | modulex-python                                  |
| ---------------------------- | ------------------- | ----------------------------------------------- |
| `GET /workflow-runs`         | `workflowRuns.list` | `executions.list_runs` + `executions.iter_runs` |
| `GET /workflow-runs/{runPk}` | `workflowRuns.get`  | `executions.get_run`                            |

This is the clearest grouping divergence: JavaScript exposes a separate `workflowRuns` resource; Python folds run history into `executions`. `{runPk}` is the run record's `id` (returned by list/get), **not** the execution `run_id`. `iter_runs` is a Python-only offset auto-paginator over `GET /workflow-runs`.

### deployments

| Route                                              | modulex-js               | modulex-python           |
| -------------------------------------------------- | ------------------------ | ------------------------ |
| `POST /workflows/{id}/deploy`                      | `deployments.create`     | `deployments.create`     |
| `GET /workflows/{id}/deployments`                  | `deployments.list`       | `deployments.list`       |
| `GET /workflows/{id}/deployments/{depId}`          | `deployments.get`        | `deployments.get`        |
| `PUT /workflows/{id}/deployments/{depId}/activate` | `deployments.activate`   | `deployments.activate`   |
| `DELETE /workflows/{id}/deployments/live`          | `deployments.deactivate` | `deployments.deactivate` |
| `DELETE /workflows/{id}/deployments/{depId}`       | `deployments.delete`     | `deployments.delete`     |

List items omit `workflow_id` and `description`. `DeactivateDeploymentResponse.previous_live_deployment_id` is optional in both SDKs (omitted on a no-op).

### schedules

| Route                                     | modulex-js           | modulex-python        |
| ----------------------------------------- | -------------------- | --------------------- |
| `POST /schedules`                         | `schedules.create`   | `schedules.create`    |
| `GET /schedules`                          | `schedules.list`     | `schedules.list`      |
| `GET /schedules/{id}`                     | `schedules.get`      | `schedules.get`       |
| `PUT /schedules/{id}`                     | `schedules.update`   | `schedules.update`    |
| `DELETE /schedules/{id}`                  | `schedules.delete`   | `schedules.delete`    |
| `POST /schedules/{id}/pause`              | `schedules.pause`    | `schedules.pause`     |
| `POST /schedules/{id}/resume`             | `schedules.resume`   | `schedules.resume`    |
| `GET /schedules/{id}/runs`                | `schedules.runs`     | `schedules.list_runs` |
| `GET /schedules/{id}/runs/stats`          | `schedules.runStats` | `schedules.run_stats` |
| `GET /schedules/{id}/runs/{runId}`        | `schedules.getRun`   | `schedules.get_run`   |
| `POST /schedules/{id}/runs/{runId}/retry` | `schedules.retryRun` | `schedules.retry_run` |
| `POST /schedules/admin/trigger-tick`      | —                    | —                     |

`POST /schedules` requires a live deployment. The admin tick route (`include_in_schema=False`) has **no SDK method in either SDK**. The exact response keys of `retryRun` / `retry_run` are an [open question](#open-questions).

### composer

| Route                                          | modulex-js        | modulex-python       |
| ---------------------------------------------- | ----------------- | -------------------- |
| `POST /composer/chat`                          | `composer.chat`   | `composer.chat`      |
| `GET /composer/chats`                          | `composer.list`   | `composer.list`      |
| `GET /composer/chat/{id}`                      | `composer.get`    | `composer.get`       |
| `GET /composer/chat/{id}/listen/{runId}` (SSE) | `composer.listen` | `composer.listen`    |
| `POST /composer/chat/{id}/resume`              | `composer.resume` | `composer.resume`    |
| `PATCH /composer/chat/{id}/focus`              | `composer.focus`  | `composer.set_focus` |
| `POST /composer/chat/{id}/save`                | `composer.save`   | `composer.save`      |
| `POST /composer/chat/{id}/revert`              | `composer.revert` | `composer.revert`    |
| `DELETE /composer/chat/{id}`                   | `composer.delete` | `composer.delete`    |
| `GET /composer/chat/{id}/status`               | `composer.status` | `composer.status`    |
| `POST /composer/chat/{id}/cancel`              | `composer.cancel` | `composer.cancel`    |

`POST /composer/chat` returns `409` if a HITL prompt is pending, and `402` / `403` / `429` from the [billing gate](/billing/usage-gating). `resume` mints a **new** `run_id`; its `llm` parameter is optional at the type level but required in production (the backend returns `400` without it). The `focus` / `set_focus` name difference is intentional.

### assistant

| Route                                           | modulex-js         | modulex-python     |
| ----------------------------------------------- | ------------------ | ------------------ |
| `POST /assistant/chat`                          | `assistant.chat`   | `assistant.chat`   |
| `GET /assistant/chats`                          | `assistant.list`   | `assistant.list`   |
| `GET /assistant/chat/{id}`                      | `assistant.get`    | `assistant.get`    |
| `GET /assistant/chat/{id}/listen/{runId}` (SSE) | `assistant.listen` | `assistant.listen` |
| `POST /assistant/chat/{id}/resume`              | `assistant.resume` | `assistant.resume` |
| `GET /assistant/chat/{id}/status`               | `assistant.status` | `assistant.status` |
| `POST /assistant/chat/{id}/cancel`              | `assistant.cancel` | `assistant.cancel` |
| `DELETE /assistant/chat/{id}`                   | `assistant.delete` | `assistant.delete` |

`POST /assistant/chat` returns `409` on a pending HITL prompt **or** a run already in progress, plus `402` / `403` / `429` from the billing gate. Unlike composer, `assistant.resume`'s `llm` parameter is **required at the type level** in both SDKs. There is **no assistant rename route** on the backend (`PATCH /assistant/chat/{id}` / `.../title` does not exist) — this is an unbuilt route, not an SDK gap, so no method references it. See [Assistant permissions and limits](/assistant/permissions-and-limits).

### chats

| Route                      | modulex-js       | modulex-python   |
| -------------------------- | ---------------- | ---------------- |
| `GET /chats`               | `chats.list`     | `chats.list`     |
| `GET /chats/stream` (SSE)  | `chats.stream`   | `chats.stream`   |
| `GET /chats/{id}`          | `chats.get`      | `chats.get`      |
| `GET /chats/{id}/messages` | `chats.messages` | `chats.messages` |
| `PATCH /chats/{id}`        | `chats.update`   | `chats.update`   |
| `DELETE /chats/{id}`       | `chats.delete`   | `chats.delete`   |

`chats.list` groups by folder with no pagination. `chats.messages` uses offset/limit, and its body omits `total` / `has_next` despite the docstring. `chats.delete` is a soft delete with **no `permanent` flag** (unlike composer/assistant delete). There is **no `POST /chats`** route or method — chats are created implicitly by execution, so this is not a gap.

### credentials

| Route                                              | modulex-js                     | modulex-python                         |
| -------------------------------------------------- | ------------------------------ | -------------------------------------- |
| `GET /credentials`                                 | `credentials.list`             | `credentials.list`                     |
| `GET /credentials/{id}`                            | `credentials.get`              | `credentials.get`                      |
| `POST /credentials` (201)                          | `credentials.create`           | `credentials.create`                   |
| `PUT /credentials/{id}`                            | `credentials.update`           | `credentials.update`                   |
| `DELETE /credentials/{id}` (204)                   | `credentials.delete`           | `credentials.delete`                   |
| `POST /credentials/{id}/set-default`               | `credentials.setDefault`       | `credentials.set_default`              |
| `POST /credentials/test-temporary`                 | `credentials.testTemporary`    | `credentials.test_temporary`           |
| `POST /credentials/{id}/test`                      | `credentials.test`             | `credentials.test`                     |
| `GET /credentials/{id}/usage`                      | `credentials.usage`            | `credentials.usage`                    |
| `GET /credentials/{id}/audit`                      | `credentials.audit`            | `credentials.audit`                    |
| `POST /credentials/oauth2/initiate`                | `credentials.initiateOAuth2`   | `credentials.initiate_oauth2`          |
| `POST /credentials/{id}/oauth2/refresh`            | `credentials.refreshOAuth2`    | `credentials.refresh_oauth2`           |
| `GET /credentials/oauth2/callback`                 | —                              | —                                      |
| `POST /credentials/mcp-server`                     | `credentials.mcpServer`        | `credentials.create_mcp_server`        |
| `POST /credentials/{id}/refresh-discovery`         | `credentials.refreshDiscovery` | `credentials.refresh_mcp_discovery`    |
| `GET /credentials/{id}/mcp-tools`                  | `credentials.mcpTools`         | `credentials.mcp_tools`                |
| `POST /credentials/bulk-modulex-keys/stream` (SSE) | `credentials.bulkModulexKeys`  | `credentials.bulk_modulex_keys_stream` |

`GET /credentials/oauth2/callback` is a browser redirect target and has **no SDK method by design**. `credentials.list` returns a discriminated grouped/flat union in JavaScript but raw `Any` in Python (the backend types it `Dict[str, Any]`). The JS `MCPServerCredentialResponse` omits `updated_at`, `integration_type`, `last_used_at`, and `expires_at` versus the full `CredentialResponse`. `bulkModulexKeys` / `bulk_modulex_keys_stream` is a sync SSE factory on both sides.

<Warning>
  The SDK `credentials.refreshOAuth2` / `refresh_oauth2` methods exist and map to a real backend route, but the **app's** "refresh OAuth2" flow is a [known limitation](/reference/known-limitations) — the UI's BFF route is missing. When an OAuth credential expires, reconnect it rather than relying on an in-app refresh.
</Warning>

### integrations

| Route                                          | modulex-js                        | modulex-python                           |
| ---------------------------------------------- | --------------------------------- | ---------------------------------------- |
| `GET /integrations/browse`                     | `integrations.browse`             | `integrations.browse`                    |
| `GET /integrations/tools`                      | `integrations.tools`              | `integrations.tools`                     |
| `GET /integrations/tools/{name}`               | `integrations.tool`               | `integrations.tool_detail`               |
| `GET /integrations/llm-providers`              | `integrations.llmProviders`       | `integrations.llm_providers`             |
| `GET /integrations/llm-providers/{name}`       | `integrations.llmProvider`        | `integrations.llm_provider_detail`       |
| `GET /integrations/knowledge-providers`        | `integrations.knowledgeProviders` | `integrations.knowledge_providers`       |
| `GET /integrations/knowledge-providers/{name}` | `integrations.knowledgeProvider`  | `integrations.knowledge_provider_detail` |
| `GET /integrations/{name}`                     | `integrations.get`                | `integrations.get`                       |

`GET /integrations/tools` is admin-gated (`organization_admin_required`). Several detail methods carry a name difference (`tool` / `tool_detail`, and so on). See the [Integrations overview](/integrations/overview).

### knowledge-bases

| Route                                                   | modulex-js                     | modulex-python                   |
| ------------------------------------------------------- | ------------------------------ | -------------------------------- |
| `GET /knowledge-bases`                                  | `knowledge.list`               | `knowledge.list`                 |
| `POST /knowledge-bases` (201)                           | `knowledge.create`             | `knowledge.create`               |
| `GET /knowledge-bases/stats`                            | `knowledge.stats`              | `knowledge.stats`                |
| `GET /knowledge-bases/{id}`                             | `knowledge.get`                | `knowledge.get`                  |
| `PUT /knowledge-bases/{id}`                             | `knowledge.update`             | `knowledge.update`               |
| `DELETE /knowledge-bases/{id}` (204)                    | `knowledge.delete`             | `knowledge.delete`               |
| `POST /knowledge-bases/{id}/archive`                    | `knowledge.archive`            | `knowledge.archive`              |
| `GET /knowledge-bases/{id}/documents`                   | `knowledge.documents`          | `knowledge.list_documents`       |
| `POST /knowledge-bases/{id}/documents` (201, multipart) | `knowledge.uploadDocument`     | `knowledge.upload_document`      |
| `GET /knowledge-bases/{id}/documents/{doc}`             | `knowledge.getDocument`        | `knowledge.get_document`         |
| `GET /knowledge-bases/{id}/documents/{doc}/status`      | `knowledge.documentStatus`     | `knowledge.document_status`      |
| `DELETE /knowledge-bases/{id}/documents/{doc}` (204)    | `knowledge.deleteDocument`     | `knowledge.delete_document`      |
| `POST /knowledge-bases/{id}/documents/{doc}/retry`      | `knowledge.retryDocument`      | `knowledge.retry_document`       |
| `GET /knowledge-bases/{id}/documents/{doc}/chunks`      | `knowledge.documentChunks`     | `knowledge.document_chunks`      |
| `POST /knowledge-bases/{id}/search`                     | `knowledge.search`             | `knowledge.search`               |
| `POST /knowledge-bases/search`                          | `knowledge.searchMultiple`     | `knowledge.multi_search`         |
| `POST /knowledge-bases/{id}/hybrid-search`              | `knowledge.hybridSearch`       | `knowledge.hybrid_search`        |
| `POST /knowledge-bases/{id}/retrieve-context`           | `knowledge.retrieveContext`    | `knowledge.retrieve_context`     |
| `GET /knowledge-bases/info/supported-file-types`        | `knowledge.supportedFileTypes` | `knowledge.supported_file_types` |

Full 1:1 coverage (19 methods each). Note the two name differences beyond casing: `documents` / `list_documents` and `searchMultiple` / `multi_search`. Managed (modulexdb) search and ingest are billed in credits — see [Managed knowledge](/platform/knowledge/managed).

### organizations

| Route                                              | modulex-js                         | modulex-python                           |
| -------------------------------------------------- | ---------------------------------- | ---------------------------------------- |
| `POST /organizations`                              | `organizations.create`             | `organizations.create`                   |
| `GET /organizations/llms`                          | `organizations.llms`               | `organizations.llms`                     |
| `POST /organizations/invite`                       | `organizations.invite`             | `organizations.invite`                   |
| `POST /organizations/invite/preview`               | `organizations.invitePreview`      | `organizations.preview_invite`           |
| `POST /organizations/invitations/{id}/cancel`      | `organizations.cancelInvitation`   | `organizations.cancel_invitation`        |
| `POST /organizations/invitations/{id}/reinvite`    | `organizations.reinvite`           | `organizations.reinvite`                 |
| `PUT /organizations/{orgId}/users/{userId}/role`   | `organizations.updateRole`         | `organizations.update_user_role`         |
| `DELETE /organizations/{orgId}/users/{userId}`     | `organizations.removeUser`         | `organizations.remove_user`              |
| `GET /organizations/settings`                      | `organizations.getSettings`        | `organizations.get_settings`             |
| `PUT /organizations/settings/llm-model-visibility` | `organizations.setModelVisibility` | `organizations.set_llm_model_visibility` |
| `PUT /organizations/settings/composer-llm`         | `organizations.setComposerLlm`     | `organizations.set_composer_llm`         |

<Warning>
  **The `member` role is retired.** Invite and role-update methods accept only `'admin'`; passing `'member'` returns `422`. The live [organization roles](/concepts/organizations-roles) are **owner** and **admin** only, and composer / assistant / knowledge routes require owner or admin (`organization_admin_required`). The Python assistant docstring's "org member access" wording understates this — a former member is treated as having no access. See [Roles and permissions](/security/roles-permissions).
</Warning>

### api-keys

| Route                   | modulex-js       | modulex-python    |
| ----------------------- | ---------------- | ----------------- |
| `POST /api-keys` (201)  | `apiKeys.create` | `api_keys.create` |
| `GET /api-keys`         | `apiKeys.list`   | `api_keys.list`   |
| `GET /api-keys/{id}`    | `apiKeys.get`    | `api_keys.get`    |
| `DELETE /api-keys/{id}` | `apiKeys.revoke` | `api_keys.revoke` |

`organization_id` is sent in the **request body** here, not via the `X-Organization-ID` header. `apiKeys.revoke` is unusual: the backend `DELETE` returns a JSON body with `200` (not `204`), so both SDKs parse a result — unlike credentials/knowledge delete, which return `204`.

### dashboard

| Route                                | modulex-js                    | modulex-python                  |
| ------------------------------------ | ----------------------------- | ------------------------------- |
| `GET /dashboard/logs`                | `dashboard.logs`              | `dashboard.logs`                |
| `GET /dashboard/analytics/overview`  | `dashboard.analyticsOverview` | `dashboard.analytics_overview`  |
| `GET /dashboard/analytics/tools`     | `dashboard.analyticsTools`    | `dashboard.analytics_tools`     |
| `GET /dashboard/analytics/llm-usage` | `dashboard.analyticsLlmUsage` | `dashboard.analytics_llm_usage` |
| `GET /dashboard/users`               | `dashboard.users`             | `dashboard.users`               |

<Warning>
  All five dashboard routes return HTTP `200` with a `success:false` envelope on failure, **bypassing the SDK error-mapping path**. JavaScript callers must branch on the `success` field rather than rely on a thrown error; the Python SDK handles the nested-`data` envelope via its page unwrapper. See [Errors and status codes](/api-reference/errors).
</Warning>

### notifications

| Route                              | modulex-js             | modulex-python         |
| ---------------------------------- | ---------------------- | ---------------------- |
| `GET /notifications`               | `notifications.list`   | `notifications.list`   |
| `POST /notifications/organization` | `notifications.create` | `notifications.create` |
| `POST /notifications/system`       | —                      | —                      |

`notifications.create` maps to `/notifications/organization`, not `/notifications`. `GET /notifications` returns a null `organization_id` without a valid org context. `POST /notifications/system` is commented out backend-side, so neither SDK has a method — not a gap.

### system

| Route                          | modulex-js               | modulex-python            |
| ------------------------------ | ------------------------ | ------------------------- |
| `GET /system/health`           | **—**                    | `system.health`           |
| `GET /system/timezones`        | `system.timezones`       | `system.timezones`        |
| `GET /system/timezones/search` | `system.searchTimezones` | `system.search_timezones` |

`GET /system/health` is a **JavaScript gap**: `client.system` is documented as "health and utility" but exposes no `health()` method. Python has `system.health`. `timezones/search` requires `q` to be at least 2 characters (else `422`).

### subscriptions (Python-only)

| Route                                     | modulex-js | modulex-python                       |
| ----------------------------------------- | ---------- | ------------------------------------ |
| `GET /subscriptions/organization-plans`   | **—**      | `subscriptions.organization_plans`   |
| `GET /subscriptions/organization-billing` | **—**      | `subscriptions.organization_billing` |
| `POST /subscriptions/checkout-link`       | **—**      | `subscriptions.checkout_link`        |
| `POST /subscriptions/customer-portal`     | **—**      | `subscriptions.customer_portal`      |
| `GET /subscriptions/wallet`               | **—**      | **—**                                |
| `PATCH /subscriptions/wallet/extra-usage` | **—**      | **—**                                |
| `POST /subscriptions/wallet/topup`        | **—**      | **—**                                |
| `POST /subscriptions/transition`          | **—**      | **—**                                |
| `POST /subscriptions/transition/cancel`   | **—**      | **—**                                |

This is the largest parity gap. The four `subscriptions.*` methods exist **only in Python**; the wallet and transition routes have **no method in either SDK**. To call any of these from JavaScript — or to call the wallet/transition routes from either SDK — issue the REST request directly:

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/subscriptions/organization-plans \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: org_xxx"
  ```

  ```python Python theme={null}
  # Python: first-class method
  plans = await client.subscriptions.organization_plans()
  ```

  ```javascript JavaScript theme={null}
  // JavaScript: no subscriptions resource — call the route directly
  const res = await fetch("https://api.modulex.dev/subscriptions/organization-plans", {
    headers: {
      Authorization: "Bearer mx_live_xxx",
      "X-Organization-ID": "org_xxx",
    },
  });
  const plans = await res.json();
  ```
</CodeGroup>

See [Subscriptions and Stripe](/billing/subscription-lifecycle) and [Wallet and top-ups](/billing/wallet) for what these routes do.

## Method-name divergences

For the same backend route, these names differ by more than casing. Everywhere else, the rule is JS camelCase ↔ Python snake\_case of the same verb (`setDefault` ↔ `set_default`, `getState` ↔ `get_state`, `retryRun` ↔ `retry_run`).

| Backend route                                  | modulex-js                               | modulex-python                                |
| ---------------------------------------------- | ---------------------------------------- | --------------------------------------------- |
| `PATCH /composer/chat/{id}/focus`              | `composer.focus`                         | `composer.set_focus`                          |
| `POST /organizations/invite/preview`           | `organizations.invitePreview`            | `organizations.preview_invite`                |
| `PUT /organizations/{org}/users/{user}/role`   | `organizations.updateRole`               | `organizations.update_user_role`              |
| `GET /integrations/tools/{name}`               | `integrations.tool`                      | `integrations.tool_detail`                    |
| `GET /integrations/llm-providers/{name}`       | `integrations.llmProvider`               | `integrations.llm_provider_detail`            |
| `GET /integrations/knowledge-providers/{name}` | `integrations.knowledgeProvider`         | `integrations.knowledge_provider_detail`      |
| `GET /knowledge-bases/{id}/documents`          | `knowledge.documents`                    | `knowledge.list_documents`                    |
| `POST /knowledge-bases/search`                 | `knowledge.searchMultiple`               | `knowledge.multi_search`                      |
| `POST /credentials/mcp-server`                 | `credentials.mcpServer`                  | `credentials.create_mcp_server`               |
| `POST /credentials/{id}/refresh-discovery`     | `credentials.refreshDiscovery`           | `credentials.refresh_mcp_discovery`           |
| `GET /workflow-runs` and `/{run_pk}`           | `workflowRuns.list` / `workflowRuns.get` | `executions.list_runs` / `executions.get_run` |

## Pagination and auto-paginators

Pagination style is per-resource, and the SDKs add convenience auto-paginators over the same routes — these are not separate endpoints. See [Pagination](/api-reference/pagination).

| Resource                   | Style                 | JS auto-paginator   | Python auto-paginator  |
| -------------------------- | --------------------- | ------------------- | ---------------------- |
| workflows                  | page                  | `workflows.listAll` | `workflows.list_all`   |
| workflow-runs              | offset / `has_more`   | —                   | `executions.iter_runs` |
| composer / assistant lists | cursor                | —                   | —                      |
| chats.messages             | offset / limit        | —                   | —                      |
| chats.list                 | none (folder-grouped) | —                   | —                      |

The JavaScript SDK auto-paginates **workflows only**. Python adds `executions.iter_runs` (offset over `GET /workflow-runs`) and `workflows.list_all` (page). Both auto-paginators are async iterators that call the underlying list route repeatedly.

## Coverage gaps (explicit)

A complete accounting of where coverage is not 1:1.

### Routes with no method in either SDK

These are intentionally unexposed (webhook receivers, internal intake, ops diagnostics, super-admin console, and the wallet/transition billing routes).

<Expandable title="Whole routers absent from both SDKs">
  | Router (prefix)                                                          | What it is                                                  | In any SDK?                                      |
  | ------------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------ |
  | `stripe_webhooks` (`/stripe`)                                            | Stripe-to-server webhook receiver                           | No — not a client call                           |
  | `requests_api` (`/requests`, deprecated alias `/support`)                | support and integration-request intake                      | No SDK resource                                  |
  | `health_api` (`/system/health` diagnostics, e.g. `/system/health/oauth`) | API-key-gated OAuth diagnostic                              | No — Python has the types but no method calls it |
  | `admin_api` (`/admin`)                                                   | super-admin console (overview, credit logs, org management) | No SDK resource                                  |
</Expandable>

<Expandable title="Individual routes absent from both SDKs">
  | Route                                     | Reason                                        |
  | ----------------------------------------- | --------------------------------------------- |
  | `POST /schedules/admin/trigger-tick`      | hidden admin tick (`include_in_schema=False`) |
  | `GET /credentials/oauth2/callback`        | browser redirect target (by design)           |
  | `POST /notifications/system`              | commented out backend-side                    |
  | `GET /subscriptions/wallet`               | not surfaced by either SDK                    |
  | `PATCH /subscriptions/wallet/extra-usage` | not surfaced by either SDK                    |
  | `POST /subscriptions/wallet/topup`        | not surfaced by either SDK                    |
  | `POST /subscriptions/transition`          | not surfaced by either SDK                    |
  | `POST /subscriptions/transition/cancel`   | not surfaced by either SDK                    |
  | `GET /system/health/oauth`                | OAuth diagnostic; types-only on Python        |
</Expandable>

### Routes present in one SDK only

| Route                                     | Missing from | Present in                                    |
| ----------------------------------------- | ------------ | --------------------------------------------- |
| `GET /system/health`                      | JavaScript   | Python (`system.health`)                      |
| `GET /subscriptions/organization-plans`   | JavaScript   | Python (`subscriptions.organization_plans`)   |
| `GET /subscriptions/organization-billing` | JavaScript   | Python (`subscriptions.organization_billing`) |
| `POST /subscriptions/checkout-link`       | JavaScript   | Python (`subscriptions.checkout_link`)        |
| `POST /subscriptions/customer-portal`     | JavaScript   | Python (`subscriptions.customer_portal`)      |

### Backend-route coverage

**None.** Every documented method in both SDKs maps to a real backend route. The only "method without a 1:1 typed route" cases are convenience auto-paginators (`workflows.listAll` / `list_all`, `executions.iter_runs`) that re-call an existing list route, and the bulk-keys SSE factory that maps to `POST /credentials/bulk-modulex-keys/stream`. If a future method ships without a route, it must be flagged here.

## Behavior divergences for the same route

These are not coverage gaps, but they change how you write code against the same endpoint in each SDK.

| Behavior                        | modulex-js                                                                           | modulex-python                                                                           |
| ------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| Response shape                  | raw JSON, snake\_case, no model                                                      | typed Pydantic models, snake\_case                                                       |
| `resume.llm` (composer)         | optional in type (backend `400` without)                                             | optional in type (backend `400` without)                                                 |
| `resume.llm` (assistant)        | required in type                                                                     | required in type                                                                         |
| `credentials.list` return       | discriminated grouped/flat union                                                     | raw `Any`                                                                                |
| `apiKeys.revoke` response       | parses JSON body (`200`)                                                             | parses JSON body (`200`)                                                                 |
| Dashboard failures              | `200` + `success:false` (no throw)                                                   | `200` + `success:false` (handled by page unwrapper)                                      |
| `402` error class               | none — falls through to base `ModulexError`                                          | `PaymentRequiredError` (+ `CreditExhaustedError` / `WalletError` / `QuotaExceededError`) |
| `410` error class               | none — base `ModulexError`                                                           | none — base `ModulexError`                                                               |
| SSE retry/timeout               | `streamSSE` bypasses the `maxRetries` + `timeout` path                               | SSE uses no read timeout (`read=None`)                                                   |
| `listen` vs `stream` yield (JS) | `listen` yields unwrapped `frame.data`; `chats.stream` yields the wrapped `SSEEvent` | —                                                                                        |

The `402` / `410` typing difference matters for retry logic — the Python SDK gives you billing-specific exception classes to branch on, while JavaScript surfaces both as the base error. See [SDK errors and retries](/sdks/errors-retries) and [Errors and status codes](/api-reference/errors).

## Open questions

These items are unresolved against source and are documented rather than guessed:

<Expandable title="Unverified or unresolved parity details">
  * **`schedules.retryRun` / `retry_run` response keys.** The backend types the response as a bare `dict`. Both SDKs type it as `{message, original_run_id}`, but the exact dict contents are unconfirmed against source.
  * **organizations method-count tally (12 JS vs 11 Python).** Both SDKs cover the same 11 organization routes; the JavaScript note tallies 12. This appears to be a tally artifact, not an extra route. Treat the resource as full parity by route.
  * **Backend headline endpoint counts.** The JavaScript README's "130 endpoints" and Python's "\~120 methods" figures were not re-derived from a route sum. The matrix on this page is the authoritative coverage list regardless of either headline figure.
  * **`admin_api` full route set.** The `/admin/*` routes were sampled, not exhaustively enumerated. Confirm the complete admin route list before claiming the router is fully "no SDK" covered.
</Expandable>

## Related pages

<CardGroup cols={2}>
  <Card title="SDKs overview" icon="layer-group" href="/sdks/overview">
    Install once, use across every operation.
  </Card>

  <Card title="JavaScript SDK" icon="js" href="/sdks/javascript">
    Install, configure, and the raw-response behavior.
  </Card>

  <Card title="Python SDK" icon="python" href="/sdks/python">
    Async client, env fallback, and the subscriptions resource.
  </Card>

  <Card title="Errors and retries" icon="triangle-exclamation" href="/sdks/errors-retries">
    Error classes, retry policy, and the idempotency no-op.
  </Card>
</CardGroup>
