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

# Run a workflow (REST + SDK)

> Authenticate, trigger a workflow run, and stream its result over SSE with cURL, the Python SDK, and the JavaScript SDK — including the live billing-gate responses.

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

This guide takes you end to end: authenticate a request, start a workflow run with `POST /workflows/run`, and stream its events over Server-Sent Events (SSE). Every operation is shown three ways — cURL, Python, and JavaScript — so you can wire it into a script or a server in minutes.

A run is asynchronous. The run endpoint returns immediately with a `run_id` while the workflow executes in the background; you then open an SSE stream on that `run_id` to watch nodes execute and receive the final result. For the full event taxonomy and frame format, see [SSE run streaming](/realtime/sse-streaming). For base URLs, the request lifecycle, and how operations map across surfaces, see the [API overview](/api-reference/overview).

<Note>
  **Before you start**, you need three things: an `mx_live_*` API key, your organization ID, and a workflow with an active deployment (or an inline workflow definition). API keys are created in the dashboard at `https://app.modulex.dev`; see the [Quickstart](/get-started/quickstart) and [Authentication](/api-reference/authentication).
</Note>

## Prerequisites

| Requirement           | Where it comes from                                 | Notes                                                                                                                                        |
| --------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| API key (`mx_live_*`) | Dashboard → API keys                                | Sent as `Authorization: Bearer mx_live_…`. See [Authentication](/api-reference/authentication).                                              |
| Organization ID       | Dashboard, or `GET /organizations`                  | Sent as the `X-Organization-ID` header. Required on every org-scoped route. See [Org context](/security/org-context).                        |
| A runnable workflow   | A deployed workflow ID, **or** an inline definition | A saved `workflow_id` must have an active deployment, or the run returns `400`. See [Deploy & versions](/workflow-builder/execution/deploy). |
| Role                  | `owner` or `admin`                                  | All `/workflows` routes require an org owner or admin. The `member` role is retired. See [Roles & permissions](/security/roles-permissions). |

<MediaEmbed id="MX-MEDIA-4440" type="screenshot" caption={"The dashboard API keys screen with a freshly created `mx_live_` key and the organization ID visible."} />

## Step 1 — Authenticate

Every request carries two headers. This is the same scheme across REST, the Python SDK, and the JavaScript SDK — there is no `X-Authorization` header.

<ParamField header="Authorization" type="string" required>
  `Bearer mx_live_…` — your API key. The backend also accepts the key in an `X-API-KEY` header, but `Authorization: Bearer` is the documented form. A missing or invalid token returns `401` with a `WWW-Authenticate: Bearer` header.
</ParamField>

<ParamField header="X-Organization-ID" type="string" required>
  The organization (tenant) the run belongs to. Required on every org-scoped route. If it is missing, the request returns `400` with `{"detail": "X-Organization-ID header is required"}`.
</ParamField>

<ParamField header="Content-Type" type="string">
  `application/json` for the `POST /workflows/run` body.
</ParamField>

The SDKs take the key and organization ID once, at construction, and attach both headers to every call. The Python SDK additionally falls back to the `MODULEX_API_KEY`, `MODULEX_BASE_URL`, and `MODULEX_ORGANIZATION_ID` environment variables; the JavaScript SDK has **no** environment-variable fallback — you must pass the values to the constructor. See the [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python) pages for the full configuration surface.

<CodeGroup>
  ```bash cURL theme={null}
  # Reused by every request below. The base URL has no /v1 path segment.
  export MODULEX_API_KEY="mx_live_xxx"
  export MODULEX_ORG_ID="11111111-1111-1111-1111-111111111111"
  export MODULEX_BASE_URL="https://api.modulex.dev"

  # Sanity check: confirm the key + org resolve.
  curl "$MODULEX_BASE_URL/auth/me" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

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

  client = Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
      # base_url defaults to https://api.modulex.dev
  )

  async def main():
      me = await client.auth.me()
      print(me["user_id"])

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: 'mx_live_xxx',
    organizationId: '11111111-1111-1111-1111-111111111111',
    // baseUrl defaults to https://api.modulex.dev
  });

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

## Step 2 — Start the run

Send `POST /workflows/run`. The endpoint admits the run through the billing gate, returns a `200` with run metadata, and executes the workflow in a background task. Pick exactly one execution mode.

### Execution modes

<ParamField body="workflow_id" type="string">
  Run a saved workflow. The backend loads the schema from the workflow's **active deployment** snapshot. If the workflow has no live deployment, the run returns `400` (`Workflow has no active deployment. Deploy the workflow first…`). Request `input` overrides the deployment default; request `config` merges over the deployment config.
</ParamField>

<ParamField body="workflow" type="object">
  Run an inline, ad-hoc [`WorkflowDefinition`](/concepts/workflow-engine) without saving it. Sets `is_ad_hoc: true` on the run record. Use `attribution_workflow_id` to make an ad-hoc run appear in a saved workflow's Runs panel without switching to deployment-load mode.
</ParamField>

<ParamField body="system_workflow" type="string">
  Run a built-in system workflow by name. In this mode, both `input` and `config` are **required** (otherwise `400`). Exposed by the Python SDK and cURL; the JavaScript SDK does not surface this mode.
</ParamField>

<Warning>
  There is no LLM-only run mode. A request that carries only an `llm` config (no `workflow`, `workflow_id`, or `system_workflow`) returns `410 Gone`. Use the [Assistant](/assistant/overview) instead — `POST /assistant/chat`, documented in [Streaming responses](/assistant/streaming).
</Warning>

### Other body fields

<ParamField body="input" type="object" default="{}">
  State input values passed to the workflow's entry node. Keys match your workflow's `state_schema`. Required for `system_workflow` mode.
</ParamField>

<ParamField body="config" type="object" default="{}">
  Runtime overrides for this execution. Recognized keys: `thread_id`, `recursion_limit`, and `batch_interval_ms`. Required for `system_workflow` mode.
</ParamField>

<ParamField body="stream" type="boolean" default={true}>
  Echoed back in the response. Run events are always consumed by opening the SSE stream in Step 3; this flag does not change that.
</ParamField>

<ParamField body="ephemeral" type="boolean" default={false}>
  When `true`, no chat record is created, `chat_id` is `null`, and `thread_id` is a fresh value. When `false`, `thread_id` equals `chat_id` (the same UUID).
</ParamField>

<ParamField body="is_private" type="boolean" default={false}>
  When `true`, the run and its messages are visible only to the creator.
</ParamField>

<ParamField body="attribution_workflow_id" type="string">
  For ad-hoc (inline) runs, the saved workflow to attribute this run to in run history.
</ParamField>

<Note>
  The Python SDK accepts an `idempotency_key` argument on `executions.run(...)`. It is sent as an `Idempotency-Key` header, but `POST /workflows/run` assigns its own `run_id`, so it does not de-duplicate runs. The JavaScript SDK does not send it. See [SDK errors & retries](/sdks/errors-retries).
</Note>

### Request

<CodeGroup>
  ```bash cURL theme={null}
  # Run a saved, deployed workflow.
  curl -X POST "$MODULEX_BASE_URL/workflows/run" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID" \
    -H "Content-Type: application/json" \
    -d '{
          "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
          "input": { "query": "AI trends" },
          "config": { "recursion_limit": 50 }
        }'
  ```

  ```python Python theme={null}
  run = await client.executions.run(
      workflow_id="550e8400-e29b-41d4-a716-446655440000",
      input={"query": "AI trends"},
      config={"recursion_limit": 50},
  )
  print(run.status, run.run_id)  # "running" 6a7b8c9d-...
  ```

  ```javascript JavaScript theme={null}
  const run = await client.executions.run({
    workflowId: '550e8400-e29b-41d4-a716-446655440000',
    input: { query: 'AI trends' },
    config: { recursion_limit: 50 },
  });
  console.log(run.status, run.run_id); // "running" 6a7b8c9d-...
  ```
</CodeGroup>

<Note>
  The JavaScript SDK takes camelCase parameter names (for example `workflowId`) and converts them to snake\_case on the wire — but **responses stay snake\_case** (`run.run_id`, `run.thread_id`). The Python SDK is snake\_case in both directions.
</Note>

### Response

A `200` is returned immediately while the workflow runs in the background. The status here reflects only the synchronous portion — it is `running`, never a terminal status. Terminal outcomes arrive on the SSE stream (Step 3) or via run history (Step 5).

```json 200 — run started theme={null}
{
  "status": "running",
  "run_id": "6a7b8c9d-1e2f-3g4h-5i6j-7k8l9m0n1o2p",
  "thread_id": "550e8400-e29b-41d4-a716-446655440000",
  "chat_id": "550e8400-e29b-41d4-a716-446655440000",
  "ephemeral": false,
  "stream": true,
  "workflow_name": "Research",
  "workflow_version": "1.0",
  "workflow_source": "database",
  "elapsed_ms": 142.7,
  "human_message": { "id": "...", "role": "human", "run_id": "...", "running_status": null },
  "ai_message": { "id": "...", "role": "ai", "run_id": "...", "running_status": "running" },
  "message": "Workflow execution started in background. Use /workflows/listen/{run_id} to track progress."
}
```

<ResponseField name="status" type="string">
  Always `running` for a started run. Terminal status is observed via the stream or run history.
</ResponseField>

<ResponseField name="run_id" type="string">
  The per-execution identifier you pass to `GET /workflows/listen/{run_id}`. This is also the durable run identifier reused on resume. It is **not** the run record's `id` (returned by list/get) — see [Workflows & runs](/concepts/workflows-and-runs) for the three distinct run-id identities.
</ResponseField>

<ResponseField name="thread_id" type="string">
  The conversation/checkpoint thread. Equals `chat_id` when `ephemeral` is `false`. Used by `GET /workflows/state/{thread_id}` and `POST /workflows/resume/{thread_id}`.
</ResponseField>

<ResponseField name="chat_id" type="string | null">
  The chat record for this run, or `null` when `ephemeral` is `true`.
</ResponseField>

<ResponseField name="workflow_source" type="string">
  Origin of the executed definition: `database`, `request`, or `system:<name>`.
</ResponseField>

<ResponseField name="human_message" type="object | null">
  The persisted human message, or `null` for ephemeral runs (or if chat creation failed).
</ResponseField>

<ResponseField name="ai_message" type="object | null">
  The persisted AI message stub (`running_status: "running"`), or `null` for ephemeral runs.
</ResponseField>

### Run errors

<ResponseField name="400 Bad Request" type="error">
  Missing `input`/`config` for `system_workflow` mode; a `workflow_id` with no active deployment; or no execution mode supplied. Shape: `{"detail": "<message>"}`.
</ResponseField>

<ResponseField name="404 Not Found" type="error">
  The system workflow file does not exist, or the `workflow_id` is not in your organization. Cross-tenant IDs return `404` (not `403`), so they do not leak existence. Shape: `{"detail": "..."}`.
</ResponseField>

<ResponseField name="410 Gone" type="error">
  An `llm`-only request — LLM mode was removed. Use `POST /assistant/chat`.
</ResponseField>

<ResponseField name="402 / 403 / 429" type="error">
  Billing gate denial. See [Handle the billing gate](#step-4-handle-the-billing-gate) below — these responses are live on this route.
</ResponseField>

<ResponseField name="500 Internal Server Error" type="error">
  Wrapped failure (`{"detail": "Failed to start workflow: …"}`). Because the run body is an untyped object, a malformed inline `workflow` schema also surfaces as `500` rather than `422`.
</ResponseField>

## Step 3 — Stream the run

Open `GET /workflows/listen/{run_id}` to receive run events over SSE. The stream is **data-only**: each frame is `data: <json>\n\n` with **no** `event:` line. The event type is the `type` key inside the JSON. On (re)connect, the buffered run history (1-hour TTL) is replayed in order first, then the live tail — so reconnecting mid-run is replay-safe.

An ownership guard runs before the stream opens; an unknown or cross-tenant `run_id` returns `404` `{"detail": "Run not found"}` as a normal HTTP response (not an in-stream frame). The full frame format, reconnection, and heartbeat mechanics live on [SSE run streaming](/realtime/sse-streaming).

<CodeGroup>
  ```bash cURL theme={null}
  # -N disables buffering so frames arrive as they are emitted.
  curl -N "$MODULEX_BASE_URL/workflows/listen/6a7b8c9d-1e2f-3g4h-5i6j-7k8l9m0n1o2p" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

  ```python Python theme={null}
  # executions.listen() yields the parsed data object of each frame.
  async for event in client.executions.listen(run.run_id):
      etype = event.get("type")
      if etype == "node_update":
          print("node:", event.get("node"))
      elif etype == "done":
          print("done:", event["data"]["message"])
          break
      elif etype == "error":
          print("error:", event.get("message"))
          break
  ```

  ```javascript JavaScript theme={null}
  // listen() is an async generator yielding each frame's parsed data.
  for await (const event of client.executions.listen(run.run_id)) {
    if (event.type === 'node_update') {
      console.log('node:', event.node);
    } else if (event.type === 'done') {
      console.log('done:', event.data.message);
      break;
    } else if (event.type === 'error') {
      console.log('error:', event.message);
      break;
    }
  }
  ```
</CodeGroup>

### Event types on this stream

Document the **wire** shapes below — they are what a live client receives. The typed event models and generated TypeScript types describe a different, stale shape (for example, a typed `node_update` uses `node_id`/`status`, but the wire frame uses `node`/`output`).

<ResponseField name="metadata" type="event">
  First frame. `data: {run_id, thread_id, workflow_name, workflow_version, workflow_type, timestamp}`.
</ResponseField>

<ResponseField name="node_update" type="event">
  Emitted as each node produces output. Flat shape: `{type, node, output}` where `node` is the node **name** and `output` is the serialized state delta.
</ResponseField>

<ResponseField name="node_started" type="event">
  Emitted by some nodes (for example, the knowledge node) before they run: `{type, node, name, timestamp, metadata}`.
</ResponseField>

<ResponseField name="interrupt" type="event">
  The workflow hit an interrupt (HITL) node and is awaiting input. The stream stays **open**. `data: {thread_id, message, data, resume_schema?}`. Resume with `POST /workflows/resume/{thread_id}` — see [Step 5](#step-5-optional-resume-an-interrupt-and-read-history) and [Human-in-the-loop resume](/realtime/hitl).
</ResponseField>

<ResponseField name="resumed" type="event">
  Published after a resume; the run continues under the **same** `run_id`. `data: {run_id, thread_id, resume_value, timestamp}`.
</ResponseField>

<ResponseField name="heartbeat" type="event">
  A `{"type": "heartbeat"}` keepalive emitted after 15 seconds of silence so a long interrupt pause does not idle-close the connection. Treat it as a no-op.
</ResponseField>

<ResponseField name="done" type="event">
  Terminal. `data: {message: "Workflow completed successfully"}`. Closes the stream.
</ResponseField>

<ResponseField name="error" type="event">
  Terminal. Flat shape: `{type, message}` (for example, `Execution failed: …`). Closes the stream.
</ResponseField>

<ResponseField name="cancelled" type="event">
  Terminal. `data: {run_id, reason, cancelled_at}` (or `data: null` if the cancel blob already expired). Closes the stream.
</ResponseField>

<Note>
  The durable status string differs from the live event. The SSE `done` event corresponds to a durable run status of `succeeded` (not `completed` or `done`) when you read it back from run history in Step 5.
</Note>

### Raw frame trace

A run that hits an interrupt, is resumed, then completes looks like this on the wire (no `event:` lines):

```text Wire frames theme={null}
data: {"type":"metadata","data":{"run_id":"6a7b...","thread_id":"550e...","workflow_name":"Research","workflow_version":"1.0","workflow_type":"workflow","timestamp":"2026-06-21T10:00:00.000000+00:00"}}

data: {"type":"node_update","node":"plan","output":{"messages":[{"type":"ai","content":"..."}]}}

data: {"type":"heartbeat"}

data: {"type":"interrupt","data":{"thread_id":"550e...","message":"Approve this plan?","data":{},"resume_schema":{"type":"object","properties":{"approved":{"type":"boolean"}}}}}

data: {"type":"resumed","data":{"run_id":"6a7b...","thread_id":"550e...","resume_value":{"approved":true},"timestamp":"2026-06-21T10:00:30.000000+00:00"}}

data: {"type":"node_update","node":"summarize","output":{"summary":"..."}}

data: {"type":"done","data":{"message":"Workflow completed successfully"}}
```

## Step 4 — Handle the billing gate

Managed runs pass through a billing admission gate **before** any database write. On denial, the run is rejected with no run record and no background task, and the response is a flat `DenialEnvelope` — **not** the `{"detail": …}` shape. The envelope has no `detail` wrapper:

```json DenialEnvelope (402 example) theme={null}
{
  "code": "credit_plan_exhausted",
  "layer": "credit",
  "key": "credit:org:...",
  "current": 1000.0,
  "limit": 1000.0,
  "reason": "credit_plan_exhausted"
}
```

The `layer` field maps to the HTTP status:

| `layer`  | Status | `code` values                                     | Meaning                                                                              |
| -------- | ------ | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `rate`   | `429`  | `rate_limit_exceeded`                             | Run rate limit hit. Carries `Retry-After` and `X-RateLimit-*` headers.               |
| `quota`  | `403`  | `quota_exceeded`                                  | A plan quota was exceeded.                                                           |
| `credit` | `402`  | `credit_plan_exhausted`, `upgrade_payment_failed` | Plan credit allowance exhausted with no wallet overage, or an upgrade charge failed. |
| `wallet` | `402`  | `wallet_overage_disabled`, `wallet_insufficient`  | Overage is off, or the prepaid wallet has insufficient balance.                      |

<Warning>
  These billing responses are **live** on the run, composer, assistant, and managed-knowledge surfaces. They are **absent** on plain CRUD and org-settings routes, which return the `{"detail": "<string>"}` `HTTPException` shape instead. For the complete picture of all error-envelope shapes and which surface emits each, see [Errors & status codes](/api-reference/errors); for credit mechanics and the gate, see [Usage gating & limits](/billing/usage-gating).
</Warning>

The two SDKs map these differently. The Python SDK raises `PaymentRequiredError` for `402` (with subclasses `CreditExhaustedError`, `WalletError`, `QuotaExceededError`), `PermissionError`-style for `403`, and `RateLimitError` for `429`. The JavaScript SDK has no payment error class — `402` falls through to the base `ModulexError`; `429` maps to its rate-limit error. See [SDK errors & retries](/sdks/errors-retries).

<CodeGroup>
  ```bash cURL theme={null}
  # A 429 includes a Retry-After header; back off and retry.
  curl -i -X POST "$MODULEX_BASE_URL/workflows/run" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id":"550e8400-e29b-41d4-a716-446655440000","input":{"query":"AI trends"}}'
  # HTTP/1.1 402 Payment Required
  # {"code":"credit_plan_exhausted","layer":"credit",...}
  ```

  ```python Python theme={null}
  from modulex import (
      PaymentRequiredError,
      QuotaExceededError,
      RateLimitError,
  )

  try:
      run = await client.executions.run(
          workflow_id="550e8400-e29b-41d4-a716-446655440000",
          input={"query": "AI trends"},
      )
  except RateLimitError as exc:        # 429
      print("rate limited; back off", exc)
  except QuotaExceededError as exc:    # 403
      print("quota exceeded", exc)
  except PaymentRequiredError as exc:  # 402 (credit/wallet)
      print("out of credits", exc)
  ```

  ```javascript JavaScript theme={null}
  import { ModulexError, RateLimitError } from 'modulex-js';

  try {
    const run = await client.executions.run({
      workflowId: '550e8400-e29b-41d4-a716-446655440000',
      input: { query: 'AI trends' },
    });
  } catch (err) {
    if (err instanceof RateLimitError) {
      console.log('rate limited; back off', err); // 429
    } else if (err instanceof ModulexError && err.status === 402) {
      console.log('out of credits', err); // 402 — no dedicated payment class
    } else {
      throw err;
    }
  }
  ```
</CodeGroup>

## Step 5 (optional) — Resume an interrupt and read history

If your workflow contains an [interrupt node](/workflow-builder/nodes/interrupt), the stream emits an `interrupt` event and waits. Inspect the checkpoint with `GET /workflows/state/{thread_id}`, then resume with `POST /workflows/resume/{thread_id}`, passing `resume_value`, the same `run_id`, and a schema source (`workflow_id` or inline `workflow`). Resume reuses the **same** `run_id`, so the run is charged only once — re-open the stream on the same `run_id` to keep watching.

```bash cURL — resume theme={null}
curl -X POST "$MODULEX_BASE_URL/workflows/resume/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $MODULEX_API_KEY" \
  -H "X-Organization-ID: $MODULEX_ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
        "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
        "run_id": "6a7b8c9d-1e2f-3g4h-5i6j-7k8l9m0n1o2p",
        "resume_value": { "approved": true }
      }'
```

After a run finishes, fetch durable history with `GET /workflow-runs` (list) and `GET /workflow-runs/{run_pk}` (detail, including `input_snapshot` and `output_summary`).

<Warning>
  The detail route's path parameter `run_pk` is the run record's `id` (returned by list/get), **not** the execution `run_id` you streamed. They are different identifiers — see [Workflows & runs](/concepts/workflows-and-runs).
</Warning>

<CodeGroup>
  ```bash cURL — history theme={null}
  curl "$MODULEX_BASE_URL/workflow-runs?workflow_id=550e8400-e29b-41d4-a716-446655440000&limit=20" \
    -H "Authorization: Bearer $MODULEX_API_KEY" \
    -H "X-Organization-ID: $MODULEX_ORG_ID"
  ```

  ```python Python theme={null}
  runs = await client.executions.list_runs(
      workflow_id="550e8400-e29b-41d4-a716-446655440000",
      limit=20,
  )
  for row in runs["runs"]:
      print(row["run_id"], row["status"])  # e.g. "succeeded"
  ```

  ```javascript JavaScript theme={null}
  const runs = await client.workflowRuns.list({
    workflowId: '550e8400-e29b-41d4-a716-446655440000',
    limit: 20,
  });
  for (const row of runs.runs) {
    console.log(row.run_id, row.status); // e.g. "succeeded"
  }
  ```
</CodeGroup>

<Note>
  Run history is grouped differently across the SDKs: the JavaScript SDK exposes it as `client.workflowRuns`, while the Python SDK folds it into `client.executions` (`list_runs` / `iter_runs` / `get_run`). Both call the same `GET /workflow-runs` routes. See the [SDK parity matrix](/sdks/parity).
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="SSE run streaming" icon="radio" href="/realtime/sse-streaming">
    The full event taxonomy, frame format, heartbeats, and reconnect semantics.
  </Card>

  <Card title="API overview" icon="book-open" href="/api-reference/overview">
    Base URLs, the request lifecycle, and how every operation is shown three ways.
  </Card>

  <Card title="Human-in-the-loop resume" icon="hand" href="/realtime/hitl">
    Pause and resume runs that ask a human for input or approval.
  </Card>

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