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

# Trigger a workflow run over the API

> Run a deployed ModuleX workflow programmatically with POST /workflows/run, pass typed inputs, stream the result over SSE, and handle the billing-gate 402/403/429 responses — shown in cURL, Python, and JavaScript.

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

Trigger a [workflow](/concepts/workflows-and-runs) from your own code with a single call to `POST /workflows/run`, then open a [Server-Sent Events](/realtime/sse-streaming) stream to watch the run unfold node by node. The call returns immediately with a `run_id` while the workflow executes in the background; you read its output by listening on that `run_id`. This is the programmatic equivalent of pressing **Run** in the [builder](/workflow-builder/overview), and it is the same path the official SDKs use under the hood.

This page is the exhaustive reference for the run endpoint: every request field, the full response, the event stream, the credit impact, and every error — in cURL, Python, and JavaScript. For an end-to-end narrative walkthrough, see the [Run a workflow guide](/guides/run-a-workflow). For the request lifecycle that applies to all endpoints, see the [API overview](/api-reference/overview).

<Note>
  The run endpoint mounts at the root of the API host — there is **no** `/v1` or `/api` path segment. The canonical base URL is `https://api.modulex.dev`. See [Base URLs & versioning](/api-reference/environments).
</Note>

## Before you run

Three things must be true before a run succeeds:

<Steps>
  <Step title="You have an API key and an organization id">
    Create an `mx_live_*` [API key](/api-reference/authentication) and note the [organization](/concepts/organizations-roles) the workflow belongs to. Both go on every request.
  </Step>

  <Step title="You hold the owner or admin role">
    `POST /workflows/run` requires the `owner` or `admin` [organization role](/security/roles-permissions). The `member` role is retired — a non-admin caller gets `403`.
  </Step>

  <Step title="The workflow has a live deployment">
    Running a saved workflow by `workflow_id` loads the schema from its **live deployment**. If none is active, the call returns `400` and asks you to [deploy](/workflow-builder/execution/deploy) first. (Ad-hoc runs that pass an inline definition skip this requirement.)
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-3190" type="image" caption={"Sequence diagram of the API run lifecycle: trigger then stream."} />

## Authentication

Every request carries two headers, exactly as elsewhere in the API:

| Header              | Value                 |      Required     | Notes                                                                                                                                |
| ------------------- | --------------------- | :---------------: | ------------------------------------------------------------------------------------------------------------------------------------ |
| `Authorization`     | `Bearer mx_live_…`    |        yes        | Your API key. The backend also accepts the key in an `X-API-KEY` header as an alternative, but `Authorization: Bearer` is canonical. |
| `X-Organization-ID` | the organization UUID |        yes        | Selects the org context. Missing → `400` with `` `{"detail":"X-Organization-ID header is required"}` ``.                             |
| `Content-Type`      | `application/json`    | yes (on the POST) | The request body is JSON.                                                                                                            |

Authentication failures surface before the handler runs: `401` (with `WWW-Authenticate: Bearer`) for a missing or invalid key, and `403` for a caller who is not an org admin or owner. See [Authentication](/api-reference/authentication) and [Org context & X-Organization-ID](/security/org-context).

<Warning>
  The auth header is `Authorization: Bearer`, **not** `X-Authorization`. Neither SDK nor the backend reads `X-Authorization`. See [Auth model: JWT vs API key](/security/authentication).
</Warning>

## The endpoint

<ParamField path="POST /workflows/run" type="endpoint">
  Starts a workflow run in the background and returns a `run_id` you can stream. Requires the `owner` or `admin` role. Subject to the [billing admission gate](/billing/usage-gating).
</ParamField>

### Choosing an execution mode

The body must select **exactly one** source for the workflow to run. The three modes are resolved in this order:

<ResponseField name="workflow_id" type="string (UUID)">
  **Saved-workflow mode.** Runs the schema from the workflow's **live deployment**. The request `input` overrides the deployment's default input; the request `config` is merged over the deployment config. If the workflow has no active deployment, the call returns `400`. This is the most common production mode.
</ResponseField>

<ResponseField name="workflow" type="object (WorkflowDefinition)">
  **Ad-hoc mode.** Runs an inline [`WorkflowDefinition`](/concepts/workflow-engine) graph that is never saved. The run is flagged `is_ad_hoc: true`. Pass `attribution_workflow_id` to attach the run to a saved workflow's run history without switching to saved-workflow mode.
</ResponseField>

<ResponseField name="system_workflow" type="string">
  **System mode.** Runs a named built-in workflow shipped with ModuleX. In this mode both `input` **and** `config` are **required** (`400` if either is missing).
</ResponseField>

<Warning>
  The legacy "LLM mode" — a request carrying only an `llm` config and no workflow — has been **removed** and now returns `410 Gone`. For a tool-using chat agent with no workflow, use the [Assistant](/assistant/overview) (`POST /assistant/chat`) instead.
</Warning>

### Request body

<ParamField body="workflow_id" type="string (UUID)">
  Saved-workflow mode. Mutually exclusive with `workflow` and `system_workflow`. Requires a live deployment.
</ParamField>

<ParamField body="workflow" type="object">
  Ad-hoc mode: an inline `WorkflowDefinition` (`metadata`, `config`, `state_schema`, `nodes[]`, `edges[]`, `entry_point`). See the [workflow engine](/concepts/workflow-engine) for the schema. A malformed definition surfaces as `500`, not `422` (see [Errors](#errors)).
</ParamField>

<ParamField body="system_workflow" type="string">
  System mode: the name of a built-in workflow. Requires `input` and `config`.
</ParamField>

<ParamField body="input" type="object">
  The run's input state, keyed by your [`state_schema`](/concepts/workflow-engine) fields (defaults to an empty object). Values are read by the entry node and become resolvable as `` `{{node_id.field}}` `` references in downstream nodes. In saved-workflow mode this overrides the deployment's default input.
</ParamField>

<ParamField body="config" type="object">
  Per-run execution config (defaults to an empty object). Recognized keys are below; in saved-workflow mode this is merged over the deployment config.

  <Expandable title="config keys">
    <ParamField body="config.thread_id" type="string (UUID)">
      Reuse an existing checkpoint thread instead of minting a new one. A thread persists run state across pauses and resumes.
    </ParamField>

    <ParamField body="config.recursion_limit" type="integer" default="500">
      The maximum number of graph super-steps before the run aborts — the guard against runaway loops. The workflow's own `config.recursion_limit` defaults to `500`.
    </ParamField>

    <ParamField body="config.batch_interval_ms" type="integer">
      How often, in milliseconds, the executor flushes batched state updates to the event stream.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="stream" type="boolean" default="true">
  Echoed back in the response. The run always executes in the background and is observed via the [listen stream](#stream-the-run); this flag does not toggle token streaming (that path was removed).
</ParamField>

<ParamField body="ephemeral" type="boolean" default="false">
  When `true`, no chat record is created: `chat_id` is `null`, `thread_id` is a fresh UUID, and `human_message` / `ai_message` are `null` in the response. Use this for fire-and-forget API runs you do not want to surface in chat history.
</ParamField>

<ParamField body="is_private" type="boolean" default="false">
  When `true`, the chat created for this run is private rather than organization-visible.
</ParamField>

<ParamField body="attribution_workflow_id" type="string (UUID)">
  Ad-hoc mode only. Stamps the run record with this workflow id so the run appears in that workflow's Runs panel, without loading its deployment. Used by the builder's "Run" on a live, unsaved canvas.
</ParamField>

<Note>
  The endpoint reads no `Idempotency-Key` header for run dedup — it mints its own `run_id` as the internal reservation key. The Python SDK accepts an `idempotency_key` argument and sends the header, but it is a **no-op** for run de-duplication. See [Errors & retries](/sdks/errors-retries).
</Note>

### Trigger a run

The following triggers a saved workflow by id, passing an input and a per-run `recursion_limit`. Authenticate with `Authorization: Bearer` + `X-Organization-ID`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "input": { "query": "AI trends 2026" },
      "config": { "recursion_limit": 50 }
    }'
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_xxx",
          organization_id="6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
      ) as client:
          run = await client.executions.run(
              workflow_id="550e8400-e29b-41d4-a716-446655440000",
              input={"query": "AI trends 2026"},
              config={"recursion_limit": 50},
          )
          print(run.status, run.run_id, run.thread_id)

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
  });

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

<Note>
  In the JavaScript SDK, request fields are written in camelCase (`workflowId`) and converted to snake\_case on the wire, but **responses stay snake\_case** — read `run.run_id`, never `run.runId`. The Python SDK is snake\_case in both directions. See the [SDK parity matrix](/sdks/parity).
</Note>

### Response

The call returns `200` **immediately**, before the workflow finishes — the status is `running`, and you track progress on the stream. (Resume reuses the same identifiers; it does not start a second run.)

```json Response theme={null}
{
  "status": "running",
  "run_id": "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
  "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": "...", "chat_id": "...", "role": "human", "content": {}, "run_id": "...", "created_at": "...", "updated_at": "..." },
  "ai_message": { "id": "...", "chat_id": "...", "role": "ai", "content": [], "run_id": "...", "running_status": "running", "created_at": "...", "updated_at": "..." },
  "message": "Workflow execution started in background. Use /workflows/listen/{run_id} to track progress."
}
```

<ResponseField name="status" type="string">
  Always `running` on a successful trigger — the run is asynchronous. Terminal status is observed on the [stream](#stream-the-run) (`done` / `error` / `cancelled`) or read back from [run history](#read-run-history).
</ResponseField>

<ResponseField name="run_id" type="string">
  The per-execution identifier. Use it to [stream](#stream-the-run), [cancel](#cancel-a-run), and look up history. A resume **reuses** this `run_id`. This is **not** the run record's `id` (returned by list/get) and **not** the `thread_id`. See the [three run-id identities](/concepts/workflows-and-runs).
</ResponseField>

<ResponseField name="thread_id" type="string">
  The conversation/checkpoint thread. When `ephemeral` is `false`, `thread_id == chat_id`. When `ephemeral` is `true`, `thread_id` is a fresh UUID and `chat_id` is `null`. Pass it to `GET /workflows/state/{thread_id}` to inspect a paused run, or to `POST /workflows/resume/{thread_id}`.
</ResponseField>

<ResponseField name="chat_id" type="string | null">
  The chat this run is attached to, or `null` for an ephemeral run.
</ResponseField>

<ResponseField name="ephemeral" type="boolean">
  Echoes the request `ephemeral` flag.
</ResponseField>

<ResponseField name="stream" type="boolean">
  Echoes the request `stream` flag.
</ResponseField>

<ResponseField name="workflow_name" type="string">
  The workflow's name (from the deployment, system file, or inline metadata).
</ResponseField>

<ResponseField name="workflow_version" type="string">
  The workflow's version string.
</ResponseField>

<ResponseField name="workflow_source" type="string">
  Where the schema came from: `database` (saved-workflow live deployment), `request` (ad-hoc inline), or `system:<name>` (system workflow).
</ResponseField>

<ResponseField name="elapsed_ms" type="number">
  Time spent setting up the run before responding (not the run's total duration).
</ResponseField>

<ResponseField name="human_message" type="object | null">
  The chat message envelope created for the trigger, or `null` when ephemeral (or if chat creation failed).
</ResponseField>

<ResponseField name="ai_message" type="object | null">
  The placeholder AI message envelope whose `running_status` advances as the run streams, or `null` when ephemeral.
</ResponseField>

<ResponseField name="message" type="string">
  A human-readable hint pointing at the listen endpoint.
</ResponseField>

## Stream the run

The trigger returns instantly; you observe the run by opening an SSE stream on its `run_id`.

<ParamField path="GET /workflows/listen/:run_id" type="endpoint">
  A `text/event-stream` of run events. Multiple clients can listen to one `run_id` concurrently. On (re)connect, the recent event history (a one-hour buffer) is replayed in order, then the stream tails live. Requires the same auth and `owner`/`admin` role.
</ParamField>

The stream is **data-only**: every frame is `` `data: {json}\n\n` `` with **no** SSE `event:` line. The discriminator is the `type` key inside the JSON. The SDKs normalize this so you switch on one field — `evt.type` (JavaScript) or `event.event` (Python).

<CodeGroup>
  ```bash cURL theme={null}
  curl -N https://api.modulex.dev/workflows/listen/6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Accept: text/event-stream"
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_xxx",
          organization_id="6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
      ) as client:
          run = await client.executions.run(
              workflow_id="550e8400-e29b-41d4-a716-446655440000",
              input={"query": "AI trends 2026"},
          )
          async with client.executions.listen(run.run_id) as stream:
              async for event in stream:
                  print(event.event, event.data)
                  if event.is_terminal:          # done / error / cancelled / interrupted
                      break

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
  });

  const run = await client.executions.run({
    workflowId: "550e8400-e29b-41d4-a716-446655440000",
    input: { query: "AI trends 2026" },
  });

  for await (const evt of client.executions.listen(run.run_id)) {
    if (evt.type === "node_update") console.log(evt.node, evt.output);
    if (evt.type === "done" || evt.type === "error") break;
  }
  ```
</CodeGroup>

### Run event types

The executor publishes these frames. Read the wire shapes below — within one run stream some payloads are **wrapped** (`` `{type, data:{…}}` ``) and some are **flat** (`` `{type, …fields}` ``). Consume defensively; the published wire shape, not the typed SDK models, is authoritative.

| `type`         | wrapper | key fields                                                                               | notes                                                                                                                                |
| -------------- | ------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `metadata`     | wrapped | `run_id`, `thread_id`, `workflow_name`, `workflow_version`, `workflow_type`, `timestamp` | First frame; describes the run.                                                                                                      |
| `node_update`  | flat    | `node` (the node **name**), `output`                                                     | One per node super-step; `output` is the serialized state delta. A knowledge node emits a richer `output`.                           |
| `node_started` | flat    | `node`, `name`, `timestamp`, `metadata`                                                  | Emitted by some nodes (e.g. knowledge) before work begins.                                                                           |
| `interrupt`    | wrapped | `thread_id`, `message`, `data`, `resume_schema`, `examples`                              | A workflow [interrupt node](/workflow-builder/nodes/interrupt) paused the run for a human. **Not terminal** — the stream stays open. |
| `resumed`      | wrapped | `run_id`, `thread_id`, `resume_value`, `timestamp`                                       | The paused run was resumed (published by the resume call, not the executor).                                                         |
| `done`         | wrapped | `message`                                                                                | Terminal — the run completed.                                                                                                        |
| `error`        | flat    | `message`                                                                                | Terminal — the run failed (e.g. `` `"Execution failed: …"` ``).                                                                      |
| `cancelled`    | wrapped | `run_id`, `reason`, `cancelled_at`                                                       | Terminal — the run was cancelled (`data` may be `null` if the cancel record already expired).                                        |
| `heartbeat`    | flat    | `type` only                                                                              | A keepalive injected about every 15 seconds of silence — not a workflow event.                                                       |

<Warning>
  `interrupt` does **not** close the stream — it leaves it open so you can render the human-in-the-loop prompt, then resume. Do not treat the quiet that follows an `interrupt` as completion. The server closes a run stream only on `done` or `error`. See [Human-in-the-loop (HITL) resume](/realtime/hitl) and the [interrupt node](/workflow-builder/nodes/interrupt).
</Warning>

A raw stream for a run that completes without pausing looks like this:

```text Raw SSE 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":"node_update","node":"summarize","output":{"summary":"..."}}

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

```

<Note>
  For the full SSE frame format, the heartbeat and one-hour replay model, and how the SDK `listen()` generators consume it, see [SSE run streaming](/realtime/sse-streaming) and [Streaming & human-in-the-loop in the SDKs](/sdks/streaming-hitl).
</Note>

### Resume a paused run

If a run hits an [interrupt node](/workflow-builder/nodes/interrupt), it pauses and waits for a value. Post the answer to `POST /workflows/resume/{thread_id}` with the `run_id` and a `resume_value`, then re-listen on the **same** `run_id` (workflow resume reuses the run id — unlike Composer/Assistant chat resume, which mints a new one).

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/workflows/resume/550e8400-e29b-41d4-a716-446655440000 \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "run_id": "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
      "resume_value": { "approved": true }
    }'
  ```

  ```python Python theme={null}
  await client.executions.resume(
      "550e8400-e29b-41d4-a716-446655440000",      # thread_id
      "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",      # run_id (reused)
      {"approved": True},                           # resume_value
      workflow_id="550e8400-e29b-41d4-a716-446655440000",
  )
  ```

  ```javascript JavaScript theme={null}
  await client.executions.resume({
    threadId: "550e8400-e29b-41d4-a716-446655440000",
    runId: "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",  // reused
    resumeValue: { approved: true },
    workflowId: "550e8400-e29b-41d4-a716-446655440000",
  });
  ```
</CodeGroup>

`resume_value` and `run_id` are required (`400` if missing), as is either `workflow_id` (re-supply the deployed workflow) or an inline `workflow`. Resuming does **not** charge a second credit — the gate's reservation is keyed on the reused `run_id`. See [Human-in-the-loop (HITL) resume](/realtime/hitl).

### Cancel a run

`POST /workflows/cancel/{run_id}` requests cancellation of a `running` or `interrupted` run. Cancellation is graceful — the current node finishes, then the executor stops between nodes and emits a `cancelled` frame.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/workflows/cancel/6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Content-Type: application/json" \
    -d '{"reason": "user_requested"}'
  ```

  ```python Python theme={null}
  await client.executions.cancel(
      "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
      reason="user_requested",
  )
  ```

  ```javascript JavaScript theme={null}
  await client.executions.cancel("6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", {
    reason: "user_requested",
  });
  ```
</CodeGroup>

Cancelling a run that is not `running` or `interrupted` returns `400`. Closing the SSE connection alone does **not** stop the run — you must call cancel to terminate it server-side.

### Read run history

After a run ends, its durable summary is queryable. `GET /workflow-runs` lists runs (newest first, org-scoped, filterable by `workflow_id` / `status` / `trigger_type`); `GET /workflow-runs/{run_pk}` returns the full record including `input_snapshot` and `output_summary`.

<Warning>
  `GET /workflow-runs/{run_pk}` takes the run record's `id` (returned by list/get), **not** the executor `run_id`. Passing a `run_id` returns `404`. The list rows expose both fields — `id` is the run record's id, `run_id` is the execution id. See the [three run-id identities](/concepts/workflows-and-runs).
</Warning>

```bash cURL theme={null}
curl "https://api.modulex.dev/workflow-runs?workflow_id=550e8400-e29b-41d4-a716-446655440000&limit=25" \
  -H "Authorization: Bearer mx_live_xxx" \
  -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c"
```

The durable `status` uses `succeeded` for a completed run, whereas the live SSE terminal frame is `done` — the same outcome under two names. Durable statuses are `pending`, `running`, `succeeded`, `failed`, `cancelled`, `interrupted`, and `skipped`.

## Credit impact

A workflow run consumes managed credits. Two things to know:

* **One run credit per logical run.** Each run is charged a flat `RUN_CREDIT` of **1** credit, recorded exactly once. A resume reuses the run's reservation and is **not** charged again. Token usage by managed [LLM nodes](/workflow-builder/nodes/llm) and managed [knowledge](/workflow-builder/nodes/knowledge) retrieval is metered on top of the run credit. [Bring-your-own-key](/integrations/llm-providers/overview) usage is not credited.
* **The gate runs before any work.** Admission is checked *before* the run record or background task is created (reject-before-write). A denied request creates no run and no chat rows. API-key runs additionally consume a `sync_exec` run-rate-limit slot; editor (JWT) runs do not.

For what a credit is and exactly what consumes credits, see [Credits & metering](/billing/credits). For the gate and its limits, see [Usage gating & limits](/billing/usage-gating).

## The billing gate

`POST /workflows/run` sits behind the **live billing admission gate**. When your organization is over a limit, the gate denies the run and returns a flat **`DenialEnvelope`** — `` `{code, layer, key, current, limit, reason}` `` — with **no** `detail` wrapper. The HTTP status depends on the `layer`.

<Warning>
  This flat envelope is specific to the gated run / Composer / Assistant / managed-knowledge surfaces. Plain CRUD routes (creating, listing, or deleting a workflow) instead return the FastAPI `` `{"detail": "…"}` `` shape. A `402`/`403`/`429` on the run endpoint is a `DenialEnvelope`; the same status on a CRUD route is not. See [Errors & status codes](/api-reference/errors).
</Warning>

|  HTTP | `layer`  | `code`                    | When                                                                   |
| :---: | -------- | ------------------------- | ---------------------------------------------------------------------- |
| `429` | `rate`   | `rate_limit_exceeded`     | Run-rate limit hit. Carries `Retry-After` and `X-RateLimit-*` headers. |
| `403` | `quota`  | `quota_exceeded`          | A plan quota was exceeded.                                             |
| `402` | `credit` | `credit_plan_exhausted`   | Monthly credit allowance exhausted with no wallet overage available.   |
| `402` | `wallet` | `wallet_overage_disabled` | Overage is turned off for a paid org.                                  |
| `402` | `wallet` | `wallet_insufficient`     | The prepaid [wallet](/billing/wallet) balance is too low.              |
| `402` | `credit` | `upgrade_payment_failed`  | An upgrade proration charge failed.                                    |

```json 402 DenialEnvelope theme={null}
{
  "code": "credit_plan_exhausted",
  "layer": "credit",
  "key": "credit:org:6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
  "current": 5000.0,
  "limit": 5000.0,
  "reason": "credit_plan_exhausted"
}
```

The SDKs surface these on the `run()` call. The Python SDK maps them to typed exceptions — `CreditExhaustedError` (`credit`), `WalletError` (`wallet`), `QuotaExceededError` (`quota`), and base `BillingError` for the `rate` layer — while the JavaScript SDK exposes `.code`, `.layer`, and `.reason` on a base `ModulexError` (it has no dedicated `402` class). Branch on these in your error handler. See [Errors & retries](/sdks/errors-retries).

## Errors

Beyond the billing-gate responses above, the run endpoint can return:

<ResponseField name="400 Bad request" type="error">
  Missing `X-Organization-ID`; no execution source provided (none of `workflow_id` / `workflow` / `system_workflow`); missing `input` or `config` in system mode; or **the saved workflow has no active deployment** (`` `"Workflow has no active deployment. Deploy the workflow first…"` ``). Shape: `` `{"detail": "…"}` ``.
</ResponseField>

<ResponseField name="401 Unauthorized" type="error">
  Missing or invalid API key. Carries `WWW-Authenticate: Bearer`. Shape: `` `{"detail": "…"}` ``.
</ResponseField>

<ResponseField name="403 Forbidden" type="error">
  The caller is not an `owner`/`admin`, or an org-scope mismatch on the workflow. (A `quota`-layer billing denial is also `403` but uses the `DenialEnvelope` shape.) Shape: `` `{"detail": "…"}` ``.
</ResponseField>

<ResponseField name="404 Not found" type="error">
  A `workflow_id` or `system_workflow` that does not exist, or that belongs to another organization — the same `404` covers both (no existence leak). Shape: `` `{"detail": "…"}` ``.
</ResponseField>

<ResponseField name="410 Gone" type="error">
  The removed LLM-only mode (a request with only an `llm` config). Use the [Assistant](/assistant/overview) instead. Shape: `` `{"detail": "…"}` ``.
</ResponseField>

<ResponseField name="422 / 500 Invalid workflow definition" type="error">
  A malformed inline `workflow` schema surfaces as **`500`** (`` `"Failed to start workflow: …"` ``), not the usual `422`, because the run body is parsed as an untyped object. Standard `422` validation arrays apply to typed query parameters elsewhere.
</ResponseField>

<ResponseField name="500 Internal error" type="error">
  A wrapped failure (`` `"Failed to start workflow: …"` ``) or the catch-all `` `{"detail":"An unexpected internal server error occurred."}` ``.
</ResponseField>

For the three error-envelope shapes and which surface emits each, see [Errors & status codes](/api-reference/errors). For rate limiting specifically, see [Rate limiting](/api-reference/rate-limiting).

## Worked example: trigger, stream, resume

A complete loop — trigger a run, stream it, answer an interrupt if one arrives, then read the final state. This is the shape most production integrations follow.

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

  WORKFLOW_ID = "550e8400-e29b-41d4-a716-446655440000"

  async def main():
      async with Modulex(
          api_key="mx_live_xxx",
          organization_id="6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
      ) as client:
          run = await client.executions.run(
              workflow_id=WORKFLOW_ID,
              input={"query": "AI trends 2026"},
              config={"recursion_limit": 50},
          )
          run_id, thread_id = run.run_id, run.thread_id

          paused = None
          async with client.executions.listen(run_id) as stream:
              async for event in stream:
                  if event.event == "node_update":
                      print("node:", event.data.get("node"))
                  elif event.event == "interrupt":
                      paused = event.data          # render the prompt to a human
                      break
                  elif event.is_terminal:
                      break

          if paused is not None:
              # Answer the interrupt; the SAME run_id continues
              await client.executions.resume(
                  thread_id, run_id, {"approved": True}, workflow_id=WORKFLOW_ID,
              )
              async with client.executions.listen(run_id) as stream:
                  async for event in stream:
                      if event.is_terminal:
                          break

          # Read the durable outcome from history (run_pk is the record's id, not run_id)
          history = await client.executions.list_runs(workflow_id=WORKFLOW_ID, limit=1)
          latest = history.runs[0]
          detail = await client.executions.get_run(latest.id)
          print(detail.status, detail.output_summary)

  asyncio.run(main())
  ```

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

  const WORKFLOW_ID = "550e8400-e29b-41d4-a716-446655440000";

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c",
  });

  const run = await client.executions.run({
    workflowId: WORKFLOW_ID,
    input: { query: "AI trends 2026" },
    config: { recursion_limit: 50 },
  });

  let paused = null;
  for await (const evt of client.executions.listen(run.run_id)) {
    if (evt.type === "node_update") console.log("node:", evt.node);
    else if (evt.type === "interrupt") { paused = evt.data; break; } // render to a human
    else if (evt.type === "done" || evt.type === "error") break;
  }

  if (paused) {
    // Answer the interrupt; the SAME run_id continues
    await client.executions.resume({
      threadId: run.thread_id,
      runId: run.run_id,
      resumeValue: { approved: true },
      workflowId: WORKFLOW_ID,
    });
    for await (const evt of client.executions.listen(run.run_id)) {
      if (evt.type === "done" || evt.type === "error") break;
    }
  }

  // Read the durable outcome from history (run_pk is the record's id, not run_id)
  const { runs } = await client.workflowRuns.list({ workflowId: WORKFLOW_ID, limit: 1 });
  const detail = await client.workflowRuns.get(runs[0].id);
  console.log(detail.status, detail.output_summary);
  ```

  ```bash cURL theme={null}
  # 1. Trigger
  RUN=$(curl -s -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id":"550e8400-e29b-41d4-a716-446655440000","input":{"query":"AI trends 2026"}}')
  RUN_ID=$(echo "$RUN" | jq -r .run_id)

  # 2. Stream until a terminal (done/error) or interrupt frame
  curl -N "https://api.modulex.dev/workflows/listen/$RUN_ID" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Accept: text/event-stream"

  # 3. If an interrupt arrived, resume on the same run_id (thread_id from step 1)
  curl -X POST "https://api.modulex.dev/workflows/resume/550e8400-e29b-41d4-a716-446655440000" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 6f1c2a4e-0b2d-4a8e-9c3f-1d2e3f4a5b6c" \
    -H "Content-Type: application/json" \
    -d "{\"workflow_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"run_id\":\"$RUN_ID\",\"resume_value\":{\"approved\":true}}"
  ```
</CodeGroup>

## Related

<CardGroup cols={2}>
  <Card title="Run a workflow (REST + SDK)" icon="play" href="/guides/run-a-workflow">
    The end-to-end guide: authenticate, run, and stream in three languages.
  </Card>

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

  <Card title="SSE run streaming" icon="radio" href="/realtime/sse-streaming">
    The raw frame format, full event taxonomy, heartbeat, and replay model.
  </Card>

  <Card title="Usage gating & limits" icon="gauge" href="/billing/usage-gating">
    The billing admission gate and its 402/403/429 DenialEnvelope responses.
  </Card>

  <Card title="Human-in-the-loop (HITL) resume" icon="hand" href="/realtime/hitl">
    Pause and resume semantics for the interrupt node.
  </Card>

  <Card title="Deploy & versions" icon="rocket" href="/workflow-builder/execution/deploy">
    Create the live deployment that saved-workflow runs load from.
  </Card>
</CardGroup>
