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

# Python SDK (modulex-python)

> Install, configure, and use the async modulex-python client: the Modulex() async client, environment-variable fallbacks, Authorization Bearer auth, the Python-only subscriptions resource, typed Pydantic responses, retries, and structured billing errors.

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

The Python SDK (PyPI package `modulex-python`, import name `modulex`) is the official async client for the ModuleX REST API. It is **async-only**: there is one client class, `Modulex`, backed by an `httpx.AsyncClient`, and every resource method is a coroutine you `await`. This page covers installation, client construction, authentication, environment-variable fallbacks, the request lifecycle, and the two facts that make the Python SDK different from the [JavaScript SDK](/sdks/javascript) — its **environment-variable fallbacks** and its **Python-only `subscriptions` resource**.

For the full route-to-method coverage table and every cross-SDK naming difference, see the [SDK parity matrix](/sdks/parity). For consuming run streams and answering human-in-the-loop prompts, see [Streaming & HITL](/sdks/streaming-hitl). For the exception tree and retry policy, see [Errors & retries](/sdks/errors-retries).

<Note>
  The Python SDK speaks snake\_case on the wire in both directions — request bodies and query parameters are snake\_case, and responses are snake\_case Pydantic models. Unlike the JavaScript SDK, it does **no** camelCase-to-snake\_case translation. Keys such as `workflow_id`, `top_k`, and `cron_expression` are passed through verbatim.
</Note>

## Requirements

| Requirement            | Value                                            |
| ---------------------- | ------------------------------------------------ |
| Python                 | `>=3.9`                                          |
| Runtime dependencies   | `httpx>=0.27`, `httpx-sse>=0.4`, `pydantic>=2.7` |
| Current SDK version    | `1.0.0`                                          |
| API base URL (default) | `https://api.modulex.dev`                        |

The SDK is built on `asyncio`. All examples below run inside an `async def` entered with `asyncio.run(...)`.

## Install

<CodeGroup>
  ```bash pip theme={null}
  pip install modulex-python
  ```

  ```bash uv theme={null}
  uv add modulex-python
  ```

  ```bash poetry theme={null}
  poetry add modulex-python
  ```
</CodeGroup>

The import name is `modulex`, not `modulex-python`:

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

<Warning>
  ModuleX install extras such as `[all]` or per-integration extras are currently empty. Installing them emits a pip warning and pulls in only the core package, so omit extras until they ship. Integrations are loaded server-side and do not require client-side extras. See [Installing & using integrations](/integrations/install) and [Known limitations](/reference/known-limitations).
</Warning>

## Authenticate

Every request authenticates with two headers, set automatically by the client from your constructor arguments or environment:

* `Authorization: Bearer <mx_live_*>` — your API key as a Bearer token. This is the auth header. There is **no** `X-Authorization` header.
* `X-Organization-ID: <org_id>` — the [organization](/concepts/organizations-roles) the request runs in. It is sent only when an organization is resolved (constructor, environment, or per-request override).

Create an API key in the app under settings, then construct the client with it. The same auth scheme applies to cURL and the [JavaScript SDK](/sdks/javascript) — see [API authentication](/api-reference/authentication) and the [auth model](/security/authentication) for the full picture.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/auth/me \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_7f3a2b10"
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_your_api_key",
          organization_id="org_7f3a2b10",
      ) as client:
          me = await client.auth.me()
          print(me.id, me.email, me.primary_organization_id)

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_your_api_key",
    organizationId: "org_7f3a2b10",
  });

  const me = await client.auth.me();
  console.log(me.id, me.email);
  ```
</CodeGroup>

<Note>
  The backend also accepts `X-API-KEY` as an alternative to `Authorization: Bearer`, but the Python SDK never sends it. You can add arbitrary headers through `default_headers`, but the SDK will not let you override `Authorization` or `Content-Type` (see [Header construction](#header-construction)).
</Note>

## Construct the client

`Modulex` is the single entry point. Only `api_key` is positional; everything else is keyword-only.

```python signature theme={null}
Modulex(
    api_key: str | None = None,
    *,
    organization_id: str | None = None,
    base_url: str | None = None,
    timeout: float = 30.0,
    max_retries: int = 3,
    default_headers: dict[str, str] | None = None,
)
```

<ParamField path="api_key" type="str | None" default="None">
  Your `mx_live_*` API key, sent as `Authorization: Bearer <api_key>`. Required via this argument **or** the `MODULEX_API_KEY` environment variable. If neither is set, the constructor raises `ValueError` immediately: `api_key is required: pass api_key=... or set the MODULEX_API_KEY environment variable`.
</ParamField>

<ParamField path="organization_id" type="str | None" default="None">
  The default [organization](/concepts/organizations-roles) context, sent as the `X-Organization-ID` header. Falls back to the `MODULEX_ORGANIZATION_ID` environment variable. When unset, the header is omitted; org-scoped routes then return `400` with `X-Organization-ID header is required`. Every org-scoped method also accepts a per-request `organization_id=` keyword that overrides this default for that one call.
</ParamField>

<ParamField path="base_url" type="str | None" default="https://api.modulex.dev">
  The API base URL. Falls back to the `MODULEX_BASE_URL` environment variable, then to the default. A trailing slash is stripped. See [Base URLs & versioning](/api-reference/environments) — ModuleX has no `/v1` path segment.
</ParamField>

<ParamField path="timeout" type="float" default="30.0">
  Per-request httpx timeout in seconds, applied on every call. **No** environment fallback (constructor-only). The read timeout is disabled for SSE streams.
</ParamField>

<ParamField path="max_retries" type="int" default="3">
  Retry budget for transient failures on idempotent (`GET`/`HEAD`) requests. **No** environment fallback. See [Retries, timeouts & backoff](#retries-timeouts-and-backoff) and [Errors & retries](/sdks/errors-retries).
</ParamField>

<ParamField path="default_headers" type="dict[str, str] | None" default="empty">
  Extra headers merged into every request. They are merged **before** the auth and content-type headers, so they can override `User-Agent` but **cannot** override `Authorization` or `Content-Type`. **No** environment fallback.
</ParamField>

### Environment-variable fallbacks

The Python SDK reads three environment variables. This is a deliberate difference from the JavaScript SDK, which reads **none** and requires every value in the constructor. The resolution order is **explicit argument → environment variable → default**.

| Constructor argument | Environment variable      | Default                   | If missing          |
| -------------------- | ------------------------- | ------------------------- | ------------------- |
| `api_key`            | `MODULEX_API_KEY`         | none                      | raises `ValueError` |
| `organization_id`    | `MODULEX_ORGANIZATION_ID` | `None`                    | header omitted      |
| `base_url`           | `MODULEX_BASE_URL`        | `https://api.modulex.dev` | uses default        |
| `timeout`            | — (none)                  | `30.0`                    | uses default        |
| `max_retries`        | — (none)                  | `3`                       | uses default        |
| `default_headers`    | — (none)                  | empty                     | uses default        |

With the three environment variables set, the client takes no arguments at all:

```python env-only theme={null}
import asyncio
from modulex import Modulex

# MODULEX_API_KEY, MODULEX_ORGANIZATION_ID, MODULEX_BASE_URL read from the environment.
async def main():
    async with Modulex() as client:
        workflows = await client.workflows.list()
        print(workflows.total)

asyncio.run(main())
```

<Note>
  Only `api_key`, `organization_id`, and `base_url` have environment fallbacks. `timeout`, `max_retries`, and `default_headers` are constructor-only. Cross-reference this divergence on the [JavaScript SDK page](/sdks/javascript) and the [parity matrix](/sdks/parity).
</Note>

## Async client lifecycle

The client owns a shared `httpx.AsyncClient` connection pool. Use it as an **async context manager** so the pool is closed for you on exit.

<CodeGroup>
  ```python Context manager (recommended) theme={null}
  import asyncio
  from modulex import Modulex

  async def main():
      async with Modulex(api_key="mx_live_your_api_key", organization_id="org_7f3a2b10") as client:
          me = await client.auth.me()
          print(me.username)
      # connection pool is closed here

  asyncio.run(main())
  ```

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

  async def main():
      client = Modulex(api_key="mx_live_your_api_key", organization_id="org_7f3a2b10")
      try:
          me = await client.auth.me()
          print(me.username)
      finally:
          await client.close()

  asyncio.run(main())
  ```
</CodeGroup>

The context manager methods are `__aenter__` (returns the client) and `__aexit__` (calls `close()`). `close()` awaits `httpx.AsyncClient.aclose()` on the shared pool. Construct the client once per process and reuse it across calls; do not create a new client per request.

<MediaEmbed id="MX-MEDIA-2020" type="image" caption={"the async client lifecycle for the Python SDK"} />

## Wire conventions and typed responses

### snake\_case both ways

Request bodies and query parameters are built as snake\_case dictionaries and sent verbatim — there is no casing-translation layer. Responses are snake\_case as well. Pass and read keys exactly as the API defines them: `workflow_id`, `knowledge_base_id`, `top_k`, `cron_expression`, `make_default`.

### Pydantic v2 models with a dict-compatibility shim

Every response is a Pydantic v2 model. You can read fields with typed attribute access **or** legacy dict access on the same object:

```python access theme={null}
res = await client.executions.run(workflow_id="wf_7f3a2b10", input={"q": "hi"})

res.run_id            # typed attribute access (preferred)
res["run_id"]         # dict-style access
res.get("status")     # .get() with optional default
"thread_id" in res    # membership test (declared fields + extra fields)
res.to_dict()         # model_dump(mode="json", by_alias=True, exclude_none=True)
```

Models are configured with `extra="allow"`, so unknown fields returned by a newer backend are preserved (forward-compatible) and reachable through the same shim. `to_dict()` serializes with aliases applied and `None` values dropped. The model base type `ModulexModel` and the pagination wrapper `AsyncPage` are importable from `modulex.types`; `SSEEvent` is importable from the `modulex` package root.

## Header construction

Headers are built once per request. The base set, in merge order, is:

```python headers theme={null}
{
    "User-Agent": "modulex-python/1.0.0",   # overridable via default_headers
    # ...your default_headers merged here...
    "Authorization": "Bearer <api_key>",    # NOT overridable
    "Content-Type": "application/json",      # NOT overridable
}
# + "X-Organization-ID": "<org_id>"   when an organization is resolved
# + "Idempotency-Key": "<key>"        when idempotency_key= is passed on a mutating call
```

* `default_headers` are merged **before** the auth and content-type keys, so they can override `User-Agent` but never `Authorization` or `Content-Type`.
* `X-Organization-ID` is added only when an organization resolves. Precedence: per-request `organization_id=` argument → client default → omitted.
* `Content-Type` is dropped on `GET`-style SSE streams (no body) and on multipart uploads (so httpx sets the multipart boundary).
* `Idempotency-Key` is attached only when you pass `idempotency_key=` to a mutating method (currently `executions.run`).

<Warning>
  `Idempotency-Key` is **not** a run-deduplication mechanism. The backend run route assigns its own run identifier, so a caller-supplied key does not de-duplicate runs. It is also not used by the automatic retry path, which only retries `GET`/`HEAD`. See [Errors & retries](/sdks/errors-retries).
</Warning>

## Resource groups

The client exposes **17 resource groups** as lazy properties (instantiated on first access). Each maps to a backend router; methods are async coroutines unless noted as SSE/paginator factories. The full route-to-method mapping is in the [parity matrix](/sdks/parity).

| `client.<attr>` | Purpose                                    | Canonical reference                                        |
| --------------- | ------------------------------------------ | ---------------------------------------------------------- |
| `auth`          | Current user, organizations, invitations   | [Organizations & roles](/concepts/organizations-roles)     |
| `workflows`     | Workflow CRUD, builder details, change SSE | [Run via API](/workflow-builder/execution/api-endpoint)    |
| `executions`    | Run, listen, resume, cancel, run history   | [SSE run streaming](/realtime/sse-streaming)               |
| `deployments`   | Deploy and version workflows               | [Deploy & versions](/workflow-builder/execution/deploy)    |
| `chats`         | Chat list, messages, stream                | [Chat overview](/platform/chat/overview)                   |
| `credentials`   | Integration credentials, OAuth2, MCP       | [Managing credentials](/integrations/managing-credentials) |
| `integrations`  | Browse the catalog and providers           | [Integrations overview](/integrations/overview)            |
| `knowledge`     | Knowledge bases, documents, search         | [Knowledge & RAG](/concepts/knowledge-rag)                 |
| `schedules`     | Cron and interval schedules                | [Schedules](/workflow-builder/execution/schedule)          |
| `composer`      | AI Composer chats, HITL, save/revert       | [AI Composer](/concepts/ai-composer)                       |
| `assistant`     | Assistant chats, HITL, streaming           | [Assistant overview](/assistant/overview)                  |
| `dashboard`     | Logs and analytics                         | [Data model reference](/reference/data-model)              |
| `subscriptions` | Plans, billing, checkout, customer portal  | [Subscriptions & Stripe](/billing/subscription-lifecycle)  |
| `notifications` | List and create notifications              | —                                                          |
| `api_keys`      | Create, list, revoke API keys              | [API authentication](/api-reference/authentication)        |
| `system`        | Health, timezones                          | [System status](/reference/status)                         |
| `organizations` | Org settings, members, invites             | [Roles & permissions](/security/roles-permissions)         |

### The subscriptions resource is Python-only

`client.subscriptions` exists **only** in the Python SDK. The JavaScript SDK has no subscriptions resource, so these four methods have no JavaScript counterpart — to read plans or open a Stripe checkout from JavaScript you call the REST routes directly. See the [parity matrix](/sdks/parity) and [Subscriptions & Stripe](/billing/subscription-lifecycle).

| Method                 | Signature                                                      | Returns                     | Route                                     |
| ---------------------- | -------------------------------------------------------------- | --------------------------- | ----------------------------------------- |
| `organization_plans`   | `(*, organization_id=None)`                                    | `OrganizationPlansResponse` | `GET /subscriptions/organization-plans`   |
| `organization_billing` | `(*, organization_id=None)`                                    | `BillingResponse`           | `GET /subscriptions/organization-billing` |
| `checkout_link`        | `(plan_slug, interval, *, plan_id=None, organization_id=None)` | `CheckoutResponse`          | `POST /subscriptions/checkout-link`       |
| `customer_portal`      | `(*, organization_id=None)`                                    | `CheckoutResponse`          | `POST /subscriptions/customer-portal`     |

<ParamField path="plan_slug" type="str" required>
  The target plan slug, for example `"pro"` or `"max"`. Preferred over the legacy `plan_id`. See [Plans & pricing](/billing/plans).
</ParamField>

<ParamField path="interval" type="str" required>
  The billing interval, for example `"month"` or `"year"`.
</ParamField>

<ParamField path="plan_id" type="str | None" default="None">
  Legacy plan identifier, deprecated in favor of `plan_slug`.
</ParamField>

<ParamField path="organization_id" type="str | None" default="None">
  Per-request organization override; defaults to the client/environment organization.
</ParamField>

<Note>
  `checkout_link` and `customer_portal` send their parameters as **query parameters**, not a JSON body. Both return a `CheckoutResponse` whose `url` field is the link to open in a browser. The catalog `PlanPrice` model exposes the field `amount`; the billing `BillingPlanPrice` model exposes the field `price` — they are two different shapes for two different routes.
</Note>

<CodeGroup>
  ```bash cURL theme={null}
  # JavaScript has no subscriptions resource — call the REST route directly.
  curl https://api.modulex.dev/subscriptions/organization-plans \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_7f3a2b10"

  curl -X POST "https://api.modulex.dev/subscriptions/checkout-link?plan_slug=pro&interval=month" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_7f3a2b10"
  ```

  ```python Python theme={null}
  async with Modulex(api_key="mx_live_your_api_key", organization_id="org_7f3a2b10") as client:
      plans = await client.subscriptions.organization_plans()
      for plan in plans.plans:
          print(plan.plan_slug, [(price.interval, price.amount) for price in plan.prices])

      billing = await client.subscriptions.organization_billing()
      print(billing.has_subscription)

      checkout = await client.subscriptions.checkout_link("pro", "month")
      print(checkout.url)  # open this URL in a browser

      portal = await client.subscriptions.customer_portal()
      print(portal.url)
  ```

  ```javascript JavaScript theme={null}
  // No subscriptions resource exists in modulex-js. Call the REST routes with fetch.
  const res = await fetch("https://api.modulex.dev/subscriptions/organization-plans", {
    headers: {
      Authorization: "Bearer mx_live_your_api_key",
      "X-Organization-ID": "org_7f3a2b10",
    },
  });
  const plans = await res.json();
  ```
</CodeGroup>

## Run a workflow

`executions.run` triggers a run and returns a `RunResponse` carrying the `run_id` and `thread_id`. Provide exactly one of `workflow_id` (a saved workflow), `workflow` (an ad-hoc definition), or `system_workflow` (a named system workflow). To consume the live event stream, pass `run_id` to `executions.listen` — covered in [Streaming & HITL](/sdks/streaming-hitl) and [SSE run streaming](/realtime/sse-streaming).

<ParamField path="workflow_id" type="str | None" default="None">
  Run a saved workflow by id. Provide exactly one of `workflow_id`, `workflow`, or `system_workflow`.
</ParamField>

<ParamField path="workflow" type="dict | None" default="None">
  Run an ad-hoc [workflow definition](/concepts/workflow-engine) without saving it.
</ParamField>

<ParamField path="system_workflow" type="str | None" default="None">
  Run a named system workflow.
</ParamField>

<ParamField path="input" type="dict | None" default="None">
  Run inputs, resolved by the engine's `{{node_id.field}}` reference model. See [Variables & references](/workflow-builder/variables-and-references).
</ParamField>

<ParamField path="config" type="dict | None" default="None">
  Per-run configuration overrides.
</ParamField>

<ParamField path="stream" type="bool" default="true">
  Always sent. When `true`, the run streams events you consume with `executions.listen`.
</ParamField>

<ParamField path="ephemeral" type="bool" default="false">
  Always sent. When `true`, the run is not persisted to run history.
</ParamField>

<ParamField path="is_private" type="bool" default="false">
  Always sent. Scopes the run's chat to you rather than the organization.
</ParamField>

<ParamField path="attribution_workflow_id" type="str | None" default="None">
  Links an ad-hoc run to a saved workflow's Runs panel.
</ParamField>

<ParamField path="idempotency_key" type="str | None" default="None">
  Sent as the `Idempotency-Key` header. Note the no-op caveat in [Header construction](#header-construction).
</ParamField>

<ParamField path="organization_id" type="str | None" default="None">
  Per-request organization override.
</ParamField>

<ResponseField name="RunResponse" type="object">
  <Expandable title="fields">
    <ResponseField name="status" type="str" />

    <ResponseField name="run_id" type="str">The per-execution identifier used for SSE listen, status, and history. Distinct from the `id` field (returned by list/get) used by `executions.get_run`.</ResponseField>
    <ResponseField name="thread_id" type="str">The conversation thread id; pass it to `executions.resume` and `executions.get_state`.</ResponseField>

    <ResponseField name="chat_id" type="str" />

    <ResponseField name="ephemeral" type="bool" />

    <ResponseField name="stream" type="bool" />

    <ResponseField name="workflow_name" type="str" />

    <ResponseField name="workflow_version" type="str" />

    <ResponseField name="workflow_source" type="str" />

    <ResponseField name="elapsed_ms" type="float" />

    <ResponseField name="human_message" type="object" />

    <ResponseField name="ai_message" type="object" />

    <ResponseField name="message" type="str" />
  </Expandable>
</ResponseField>

Running a workflow goes through the live billing gate, so this operation can return the `402`/`403`/`429` `DenialEnvelope` described in [Errors](#errors-and-billing-denials). Calling the run route requires the owner or admin [role](/security/roles-permissions); the `member` role has been retired.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_7f3a2b10" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id": "wf_7f3a2b10", "input": {"q": "summarize Q3"}, "stream": true}'
  ```

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

  async def main():
      async with Modulex(api_key="mx_live_your_api_key", organization_id="org_7f3a2b10") as client:
          run = await client.executions.run(
              workflow_id="wf_7f3a2b10",
              input={"q": "summarize Q3"},
              stream=True,
          )
          print(run.run_id, run.thread_id, run.status)

          # Stream live events (see Streaming & HITL).
          async with client.executions.listen(run.run_id) as stream:
              async for event in stream:
                  print(event.event, event.data)
                  if event.is_terminal:
                      break

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_your_api_key",
    organizationId: "org_7f3a2b10",
  });

  const run = await client.executions.run({
    workflowId: "wf_7f3a2b10",
    input: { q: "summarize Q3" },
    stream: true,
  });
  console.log(run.run_id, run.thread_id);
  ```
</CodeGroup>

<Warning>
  `executions.get_run(run_pk)` takes the `id` field on a `list_runs` item — **not** the `run_id` field that `executions.run` returns. Both fields appear on the same list item, so it is easy to confuse them. The `run_id` is for streaming, status, and `resume`; the `id` is for history lookups. See [Workflows & runs](/concepts/workflows-and-runs).
</Warning>

## Pagination

List methods come in two forms: single-page methods that return a typed `*ListResponse` model, and two auto-paginating helpers that return an `AsyncPage[Model]` you iterate with `async for`. The two auto-paginators are `workflows.list_all` (page style) and `executions.iter_runs` (offset style). `AsyncPage` validates each item into its Pydantic model lazily as you iterate. For cursor-paginated lists (`composer.list`, `assistant.list`), loop on `next_cursor` yourself. See [Pagination](/api-reference/pagination).

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.modulex.dev/workflows?page=1&page_size=20" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_7f3a2b10"
  ```

  ```python Python theme={null}
  # Single page
  res = await client.workflows.list(status="active", page=1, page_size=20)
  print(res.total, res.total_pages)
  for wf in res.workflows:
      print(wf.id, wf.name)

  # Auto-paginated (page style) — iterate every page transparently
  async for wf in client.workflows.list_all(status="active", search="email"):
      print(wf.id, wf.name)

  # Auto-paginated run history (offset style)
  async for run in client.executions.iter_runs(status="succeeded"):
      print(run.run_id, run.status)
  ```

  ```javascript JavaScript theme={null}
  // Single page
  const res = await client.workflows.list({ status: "active", page: 1, pageSize: 20 });
  for (const wf of res.workflows) console.log(wf.id, wf.name);

  // Auto-paginated
  for await (const wf of client.workflows.listAll({ status: "active" })) {
    console.log(wf.id, wf.name);
  }
  ```
</CodeGroup>

## Retries, timeouts and backoff

The SDK retries automatically, but only conservatively. Mutating requests are never retried, so they cannot double-execute.

| Aspect           | Behavior                                                                       |
| ---------------- | ------------------------------------------------------------------------------ |
| Retried methods  | `GET` and `HEAD` only — never `POST`/`PUT`/`PATCH`/`DELETE`                    |
| Retried statuses | `429`, `500`, `502`, `503`                                                     |
| Max attempts     | `max_retries` retries after the first try (default `3`)                        |
| Backoff          | exponential with jitter: `min(0.5 * 2**attempt + random*0.5, 30.0)` seconds    |
| `Retry-After`    | honored on `429` (overrides computed backoff)                                  |
| Timeouts         | `GET`/`HEAD` timeouts are retried within budget, then raised as `TimeoutError` |
| `204 No Content` | returns `None`                                                                 |

See [Errors & retries](/sdks/errors-retries) for the idempotency caveats and [Rate limiting](/api-reference/rate-limiting) for the `429` headers.

## Errors and billing denials

Any response with status `>= 400` is mapped to a typed exception. All SDK exceptions inherit from `ModulexError`, which carries `message`, `status_code`, `response`, and `body`.

| Status         | Exception                     | Notes                                                      |
| -------------- | ----------------------------- | ---------------------------------------------------------- |
| `400`          | `BadRequestError`             |                                                            |
| `401`          | `AuthenticationError`         | bad or missing API key                                     |
| `402`          | `PaymentRequiredError`        | a `BillingError` subclass                                  |
| `403`          | `PermissionError`             | also the quota-denial layer                                |
| `404`          | `NotFoundError`               | not found **or** not owned by your org (no existence leak) |
| `409`          | `ConflictError`               | e.g. a pending HITL question                               |
| `422`          | `ValidationError`             | FastAPI validation; messages are joined                    |
| `429`          | `RateLimitError`              | carries `retry_after`, `limit`, `remaining`, `reset`       |
| `500`          | `InternalError`               |                                                            |
| `502`          | `ExternalServiceError`        |                                                            |
| `503`          | `ServiceUnavailableError`     |                                                            |
| other `>= 400` | `ModulexError`                | base fallback (e.g. `410`)                                 |
| —              | `StreamError`, `TimeoutError` | non-HTTP failures                                          |

On `402`/`403`/`429`, if the response body carries the flat denial envelope `{code, layer, key, current, limit, reason}` (top-level, under `detail`, or a bare `{reason}`), the SDK raises a `BillingError` subclass keyed by `layer`:

* `layer="credit"` → `CreditExhaustedError` (`402`)
* `layer="wallet"` → `WalletError` (`402`)
* `layer="quota"` → `QuotaExceededError` (`403`)
* otherwise (including `layer="rate"`) → base `BillingError`

This is the same `DenialEnvelope` the [usage gate](/billing/usage-gating) returns on the run, composer, assistant, and managed-knowledge surfaces. A header-based `429` (the active rate-limit path) raises `RateLimitError` instead, so handle both `429` shapes. The unified envelope reference is on the [Errors page](/api-reference/errors).

<Warning>
  `PermissionError` and `TimeoutError` shadow the Python builtins of the same name. After `from modulex import TimeoutError`, an `except TimeoutError` clause catches the ModuleX exception, not `asyncio.TimeoutError`. Import them explicitly and be deliberate about which one a handler should catch.
</Warning>

```python error-handling theme={null}
from modulex import (
    Modulex,
    ModulexError,
    NotFoundError,
    RateLimitError,
    BillingError,
    CreditExhaustedError,
    ValidationError,
)

async with Modulex(api_key="mx_live_your_api_key", organization_id="org_7f3a2b10") as client:
    try:
        await client.executions.run(workflow_id="wf_7f3a2b10", input={"q": "hi"})
    except CreditExhaustedError as e:          # 402, layer="credit"
        print(f"Out of credits: {e.current}/{e.limit} (code={e.code})")
    except BillingError as e:                  # any quota/credit/wallet/base denial
        print(f"Denied [{e.layer}/{e.code}]: {e.reason}")
    except RateLimitError as e:                # header-based 429
        print(f"Backoff {e.retry_after}s (remaining={e.remaining}, reset={e.reset})")
    except NotFoundError:
        print("Workflow or run not found (or not owned by your org)")
    except ValidationError as e:
        print(f"422: {e.message}")
    except ModulexError as e:                  # catch-all, incl. unmapped statuses
        print(f"API error ({e.status_code}): {e.message}")
```

## Streaming and human-in-the-loop

SSE methods (`executions.listen`, `workflows.listen_changes`, `composer.listen`, `assistant.listen`, `chats.stream`, `credentials.bulk_modulex_keys_stream`) return an `EventSourceStream` you use as an async iterator and async context manager. Each yielded `SSEEvent` exposes `event`, `data`, `id`, `retry`, and `is_terminal`. Terminal event types (`done`, `error`, `cancelled`, `interrupted`) stop iteration; heartbeats are filtered unless you opt in. An HTTP error on connect (for example a `404`, billing denial, or rate limit) raises the same typed exception as a REST call rather than an opaque stream error.

For the full event taxonomy, resume contract, and worked HITL examples, see [Streaming & HITL](/sdks/streaming-hitl), [SSE run streaming](/realtime/sse-streaming), and [HITL resume](/realtime/hitl).

<CodeGroup>
  ```python Stream a run theme={null}
  async with client.executions.listen(run.run_id) as stream:
      async for event in stream:
          if event.event == "interrupt":
              # Pause for a human answer, then resume the run.
              await client.executions.resume(run.thread_id, run.run_id, {"answer": "yes"})
          if event.is_terminal:
              break
  ```

  ```python Stream the Assistant theme={null}
  async with client.assistant.listen(chat_id, run_id) as stream:
      async for event in stream:
          print(event.event, event.data)
          if event.is_terminal:
              break
  ```
</CodeGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="SDKs overview" icon="layer-group" href="/sdks/overview">
    How both SDKs map onto the REST surface, installed once and used across every operation.
  </Card>

  <Card title="SDK parity matrix" icon="table-columns" href="/sdks/parity">
    Route-to-method coverage and every cross-SDK naming difference, including the Python-only subscriptions resource.
  </Card>

  <Card title="Streaming & HITL" icon="bolt" href="/sdks/streaming-hitl">
    Consume SSE run streams and answer human-in-the-loop prompts.
  </Card>

  <Card title="Errors & retries" icon="triangle-exclamation" href="/sdks/errors-retries">
    The exception tree, retry policy, and idempotency behavior in depth.
  </Card>
</CardGroup>
