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

# Expose a workflow to chat

> Expose a deployed workflow as a chat-triggered run: send inputs as a chat message, persist the turn to a chat thread, and stream node events back over SSE — from the app, REST, or the SDKs.

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

When you run a workflow from chat, ModuleX records the run as a turn in a persistent chat thread: your workflow `input` becomes a `human` message, an `ai` placeholder message tracks progress, and the live node stream renders inline. This is the same `POST /workflows/run` execution path used by [Run via API](/workflow-builder/execution/api-endpoint) — the difference is the `ephemeral` flag. With `ephemeral` left at its default (`false`), the run is attached to a chat thread; with `ephemeral: true`, no chat row is created.

This page is the technical reference for that chat-attached path: the exact request body, how inputs flow from chat into run state, the chat-message envelopes you get back, the SSE event stream you read to watch the run, and every error you can hit. For the end-user walkthrough of the same feature in the app, see [Run a workflow from chat](/platform/chat/workflow-run).

<Note>
  This page covers running a **workflow** as a chat turn. It is distinct from the agentic [Assistant](/assistant/overview), which is a separate surface (`POST /assistant/chat`) that reasons and calls tools without a workflow. A request that carries only an `llm` config and no workflow returns **410 Gone** with a pointer to the Assistant — see [Errors](#errors-and-status-codes) below.
</Note>

## How a chat-triggered run works

A chat-triggered run is one `POST /workflows/run` call that resolves a workflow definition, creates or reuses a chat thread, writes two messages, charges one run credit, and starts background execution. You then open the SSE stream to watch it.

<Steps>
  <Step title="Resolve the workflow definition">
    The request resolves a definition in one of three modes (see [Run modes](#run-modes)). For a chat-triggered run you almost always use **database mode** — pass a `workflow_id` and the run loads the workflow's **live deployment**. A workflow with no live deployment returns **400**; deploy it first (see [Deploy & versions](/workflow-builder/execution/deploy)).
  </Step>

  <Step title="Pass the billing gate">
    Before any rows are written, the run passes the billing admission gate. On denial it returns a `402` / `403` / `429` `DenialEnvelope` and **nothing is created** — no chat, no messages, no run record, no background task. See [credit impact](#credit-impact) and [Usage gating & limits](/billing/usage-gating).
  </Step>

  <Step title="Create or reuse the chat thread">
    With `ephemeral: false` (the default), the run creates a new chat or reuses an existing one when you pass a `thread_id` (see [Inputs from chat](#inputs-from-chat)). The chat title defaults to the workflow name, optionally suffixed with a preview of the input.
  </Step>

  <Step title="Write the human and AI messages">
    Your `input` is written verbatim as a `human` message. An `ai` placeholder message is created with `running_status: "running"`, carrying a `metadata` content block (the run identity) and a snapshot of the workflow `schema` + `input` when small enough to inline.
  </Step>

  <Step title="Stream the run">
    The handler returns immediately with `run_id`, `thread_id`, `chat_id`, and both message envelopes. Open `GET /workflows/listen/{run_id}` to receive `node_update`, `interrupt`, `done`, `error`, and `cancelled` events as the run executes in the background. See [Streaming back](#streaming-back).
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-3200" type="image" caption={"Sequence diagram of a chat-triggered run."} />

### Identities you get back

A chat-triggered run produces three distinct identifiers. Keep them straight — they are not interchangeable. For the full model, see [Workflows & runs](/concepts/workflows-and-runs).

<ResponseField name="run_id" type="string (UUID)">
  The execution identity, minted fresh on every run. Use it to stream (`GET /workflows/listen/{run_id}`) and to cancel (`POST /workflows/cancel/{run_id}`). A resume reuses the **same** `run_id`, so the run is charged once.
</ResponseField>

<ResponseField name="thread_id" type="string (UUID)">
  The conversation/checkpoint thread. For a chat-triggered run, `thread_id == chat_id` (the same UUID). Use it to read checkpoint state (`GET /workflows/state/{thread_id}`) and to resume from an interrupt (`POST /workflows/resume/{thread_id}`). Stable across the whole conversation.
</ResponseField>

<ResponseField name="chat_id" type="string (UUID) | null">
  The chat thread the run is attached to. `null` only when `ephemeral: true`. Pass it back as the next request's `thread_id` to continue the same conversation.
</ResponseField>

## Run modes

`POST /workflows/run` accepts one of three mutually exclusive definition sources, resolved in this order. Chat-triggered runs use database or ad-hoc mode.

<Tabs>
  <Tab title="Database (recommended)">
    Pass `workflow_id`. The run loads the workflow's **live deployment** snapshot. Your request `input` overrides the deployment default input; your request `config` merges over the deployment config. If the workflow has no live deployment, the run returns **400** — deploy it first via [Deploy & versions](/workflow-builder/execution/deploy).

    ```json Database mode body theme={null}
    {
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "input": { "query": "Q2 churn drivers" },
      "config": { "recursion_limit": 50 }
    }
    ```
  </Tab>

  <Tab title="Ad-hoc (inline)">
    Pass an inline `workflow` definition to run a canvas without saving it (this is what the builder's "Run" button does on an unsaved canvas). Sets `is_ad_hoc: true` on the run record. Pass `attribution_workflow_id` to stamp the run against a saved workflow's run history without switching to database mode.

    ```json Ad-hoc mode body (abridged) theme={null}
    {
      "workflow": { "metadata": { "name": "Research", "version": "1.0" }, "state_schema": { "fields": {} }, "nodes": [], "edges": [] },
      "input": { "query": "AI trends" },
      "attribution_workflow_id": "550e8400-e29b-41d4-a716-446655440000"
    }
    ```
  </Tab>

  <Tab title="System">
    Pass `system_workflow: "<name>"` (a built-in workflow shipped with the platform). **Both** `input` and `config` are required — missing either returns **400**. System workflows are rarely used for chat-triggered runs.
  </Tab>
</Tabs>

## Inputs from chat

The `input` object is the bridge between the chat turn and run state. It is the workflow's entry-node state values, and it is also what gets persisted as the `human` message.

### What `input` becomes

* **Run state.** `input` is passed to the workflow's entry node as the initial state. Field names must match the workflow's `state_schema`.
* **The human message.** When `ephemeral: false`, the `input` object is written verbatim as a `human` message (`{role: "human", content: <input>}`). It is the visible "what the user asked" turn in the chat.
* **The chat title.** A new chat is titled with the workflow name, optionally suffixed with up to \~30 characters of the input preview (for example, `Research: {'query': 'AI trends'}`).

### Flat vs nested input

The wire format for `input` is **flat** — field name to value:

```json Flat input (what you send) theme={null}
{ "input": { "query": "AI trends", "max_results": 5 } }
```

When a database-mode run falls back to the deployment's stored default input (you did not send `input`), ModuleX normalizes the stored **nested** form to the flat form automatically:

```json Stored nested default (normalized for you) theme={null}
{ "query": { "type": "string", "value": "AI trends" } }
```

becomes `{ "query": "AI trends" }` before execution. You never send the nested shape — always send flat.

### References inside input

Workflow nodes resolve `{{node_id.field}}` references against run state at execution time, not in the chat `input` itself. The `input` you pass seeds the initial state; downstream nodes then read it with references like `{{__start__.query}}` and chain results with `{{plan.output}}`. For the full reference and array-spread model, see [Variables & references](/workflow-builder/variables-and-references).

<Warning>
  A pure `{{ref}}` value keeps its resolved type; a mixed string (text plus a reference) becomes a templated string; an unresolved reference is left intact in the string rather than erroring. Keep `input` field names aligned with the workflow's `state_schema` or the entry node will not see them.
</Warning>

## Continuing a conversation

To run a workflow as the next turn in an existing chat, send that chat's id as `thread_id` inside `config`. The run finds the existing chat, appends your `input` as a new `human` message, adds a fresh `ai` placeholder, and reuses the thread for checkpointing.

```json Continue an existing chat thread theme={null}
{
  "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
  "input": { "query": "Now compare against last quarter" },
  "config": { "thread_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```

If the `thread_id` does not resolve to a chat you can access, the run logs a warning and creates a new chat instead — it does not fail.

### Private and ephemeral runs

<ParamField path="is_private" type="boolean" default="false">
  When `true`, the created chat (and its messages) is visible only to the creator, not to the rest of the organization. Applies only when `ephemeral` is `false`.
</ParamField>

<ParamField path="ephemeral" type="boolean" default="false">
  When `true`, no chat row, no messages, and no `chat_id` are created — the run still executes and still streams over `run_id`, but it is not a chat turn. A fresh `thread_id` is minted for the checkpointer. Use this for test/preview runs you do not want in chat history. Note: ephemeral runs are still billed (only the admin/system organization is exempt).
</ParamField>

## Request reference

`POST /workflows/run`

### Headers

Every request authenticates with a bearer token and an organization context header. See [Authentication](/api-reference/authentication).

| Header              | Required | Value                                                                                                |
| ------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `Authorization`     | yes      | `Bearer mx_live_…` (API key) or a Clerk JWT. API keys may instead be sent as `X-API-KEY: mx_live_…`. |
| `X-Organization-ID` | yes      | Your organization UUID. Missing → **400** `{"detail":"X-Organization-ID header is required"}`.       |
| `Content-Type`      | yes      | `application/json`                                                                                   |

<Note>
  Every route in this subsystem requires the **owner** or **admin** organization role; the `member` role is retired. A caller without an admin/owner role gets **403**. See [Roles & permissions](/security/roles-permissions).
</Note>

### Body parameters

The request body is a JSON object. Provide exactly one definition source (`workflow_id`, `workflow`, or `system_workflow`).

<ParamField body="workflow_id" type="string (UUID)">
  Database mode. Runs the workflow's live deployment. Mutually exclusive with `workflow` and `system_workflow`. Requires a live deployment or returns **400**.
</ParamField>

<ParamField body="workflow" type="object (WorkflowDefinition)">
  Ad-hoc mode. An inline workflow definition to run without saving. Sets `is_ad_hoc: true`.
</ParamField>

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

<ParamField body="input" type="object" default="{}">
  Flat state values for the workflow's entry node. In a non-ephemeral run, also persisted verbatim as the `human` message. Required for system mode.
</ParamField>

<ParamField body="config" type="object" default="{}">
  Runtime overrides. Recognized keys:

  <Expandable title="config keys">
    <ParamField body="config.thread_id" type="string (UUID)">
      Reuse an existing chat thread (continue a conversation). When omitted on a non-ephemeral run, a new chat is created and its id becomes the `thread_id`.
    </ParamField>

    <ParamField body="config.recursion_limit" type="integer" default="500">
      Maximum graph steps. Falls back to the workflow's `config.recursion_limit` (default `500`) when omitted.
    </ParamField>

    <ParamField body="config.batch_interval_ms" type="integer" default="500">
      Stream chunk-batching interval in milliseconds.
    </ParamField>
  </Expandable>
</ParamField>

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

<ParamField body="ephemeral" type="boolean" default="false">
  Skip chat-record creation. See [Private and ephemeral runs](#private-and-ephemeral-runs).
</ParamField>

<ParamField body="is_private" type="boolean" default="false">
  Make the created chat visible only to the creator.
</ParamField>

<ParamField body="stream" type="boolean" default="true">
  Echoed back in the response. Token-level streaming was removed; node-event streaming is always available via `GET /workflows/listen/{run_id}` regardless of this value.
</ParamField>

### Response (200)

The handler returns immediately — the workflow runs in the background. `status` is always `"running"` for a started run; terminal status is observed on the stream, not here.

<ResponseField name="status" type="string">
  Always `"running"` on success.
</ResponseField>

<ResponseField name="run_id" type="string (UUID)">
  The execution identity. Stream with `GET /workflows/listen/{run_id}`.
</ResponseField>

<ResponseField name="thread_id" type="string (UUID)">
  The checkpoint thread. Equals `chat_id` for non-ephemeral runs.
</ResponseField>

<ResponseField name="chat_id" type="string (UUID) | null">
  The chat thread. `null` when `ephemeral: true`.
</ResponseField>

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

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

<ResponseField name="workflow_name" type="string">
  The resolved workflow's name.
</ResponseField>

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

<ResponseField name="workflow_source" type="string">
  One of `"database"`, `"request"`, or `"system:<name>"`.
</ResponseField>

<ResponseField name="elapsed_ms" type="number">
  Wall-clock duration of the synchronous portion (setup), in milliseconds.
</ResponseField>

<ResponseField name="human_message" type="object | null">
  The persisted human message (your `input`). `null` when `ephemeral: true`.

  <Expandable title="message envelope">
    <ResponseField name="id" type="string (UUID)">Message id.</ResponseField>
    <ResponseField name="chat_id" type="string (UUID)">Owning chat.</ResponseField>
    <ResponseField name="role" type="string">`"human"` here.</ResponseField>
    <ResponseField name="content" type="any">The `input` object verbatim.</ResponseField>
    <ResponseField name="workflow" type="object | null">Workflow snapshot, `null` on the human message.</ResponseField>
    <ResponseField name="run_id" type="string">The run id.</ResponseField>
    <ResponseField name="running_status" type="string | null">`null` on the human message.</ResponseField>
    <ResponseField name="created_at" type="string (ISO-8601)">Creation timestamp.</ResponseField>
    <ResponseField name="updated_at" type="string (ISO-8601)">Update timestamp.</ResponseField>
    <ResponseField name="deleted_at" type="string (ISO-8601) | null">Soft-delete timestamp.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="ai_message" type="object | null">
  The AI placeholder message. `null` when `ephemeral: true`. Its `content` starts as a single `metadata` block (`{"type":"metadata","data":{run_id, thread_id, workflow_name, workflow_version, workflow_type:"workflow", timestamp}}`); `running_status` is `"running"`; `workflow` carries `{schema, input}` when the snapshot is small enough to inline.
</ResponseField>

<ResponseField name="message" type="string">
  A human-readable hint: `"Workflow execution started in background. Use /workflows/listen/{run_id} to track progress."`
</ResponseField>

## Worked example

Run a deployed workflow as a chat turn, then stream the result. The auth headers follow [Authentication](/api-reference/authentication) — `Authorization: Bearer mx_live_…` plus `X-Organization-ID`.

<CodeGroup>
  ```bash cURL theme={null}
  # 1. Start the run as a chat turn (ephemeral defaults to false).
  curl -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
          "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
          "input": { "query": "Summarize Q2 support tickets" },
          "config": { "recursion_limit": 50 }
        }'
  # -> {"status":"running","run_id":"6a7b8c9d-...","thread_id":"550e8400-...",
  #     "chat_id":"550e8400-...","ephemeral":false, ... }

  # 2. Stream the run by run_id (data-only SSE; ends on done/error/cancelled).
  curl -N https://api.modulex.dev/workflows/listen/6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_xxx",
          organization_id="11111111-1111-1111-1111-111111111111",
      ) as client:
          # 1. Start the run as a chat turn (ephemeral defaults to False).
          run = await client.executions.run(
              workflow_id="550e8400-e29b-41d4-a716-446655440000",
              input={"query": "Summarize Q2 support tickets"},
              config={"recursion_limit": 50},
          )
          print(run.run_id, run.thread_id, run.chat_id)

          # 2. Stream node events until the run finishes.
          async for event in client.executions.listen(run.run_id):
              print(event.event, event.data)

  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',
  });

  // 1. Start the run as a chat turn (ephemeral defaults to false).
  const run = await client.executions.run({
    workflowId: '550e8400-e29b-41d4-a716-446655440000',
    input: { query: 'Summarize Q2 support tickets' },
    config: { recursion_limit: 50 },
  });
  console.log(run.run_id, run.thread_id, run.chat_id);

  // 2. Stream node events until the run finishes.
  for await (const event of client.executions.listen(run.run_id)) {
    console.log(event.type, event);
  }
  ```
</CodeGroup>

<Note>
  Both SDKs expose this as `executions.run(...)` and `executions.listen(run_id)`. The continue-a-conversation pattern is identical — pass `config: { thread_id: "<chat_id>" }` (Python: `config={"thread_id": "..."}`). For deeper stream consumption, see [Streaming & HITL](/sdks/streaming-hitl) and the [SDK ⇄ API parity matrix](/sdks/parity).
</Note>

## Streaming back

Watch a chat-triggered run with `GET /workflows/listen/{run_id}`. This is a **data-only** SSE stream: every frame is a bare `data: {json}\n\n` with no SSE `event:` field — the discriminator is the JSON `type` key. Multiple clients can listen to one `run_id` at once, and the stream replays buffered history (1-hour TTL) before going live, so a late or reconnecting listener still sees earlier events.

For the complete event reference (frame format, history replay, reconnect), see [SSE run streaming](/realtime/sse-streaming). The events you will see on a chat-triggered workflow run:

| `type`         | Wrapper    | Meaning                                                                                                                                                                |
| -------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `metadata`     | `data:{…}` | First frame: `data:{run_id, thread_id, workflow_name, workflow_version, workflow_type:"workflow", timestamp}`.                                                         |
| `node_started` | flat       | A node began (emitted by some nodes, e.g. knowledge): `{type, node, name, timestamp, metadata}`.                                                                       |
| `node_update`  | flat       | A node produced output: `{type, node, output}`. The `node` key is the node **name**; `output` is a serialized state delta or messages.                                 |
| `interrupt`    | `data:{…}` | The run paused at an [interrupt node](/workflow-builder/nodes/interrupt): `data:{thread_id, message, data, resume_schema?, examples?}`. Does **not** close the stream. |
| `resumed`      | `data:{…}` | A paused run continued: `data:{run_id, thread_id, resume_value, timestamp}`. Same `run_id`.                                                                            |
| `heartbeat`    | flat       | `{type:"heartbeat"}` keep-alive, emitted after \~15s of silence. Not a workflow event.                                                                                 |
| `done`         | `data:{…}` | Terminal success: `data:{message:"Workflow completed successfully"}`. Closes the stream.                                                                               |
| `error`        | flat       | Terminal failure: `{type:"error", message:"Execution failed: …"}`. Closes the stream.                                                                                  |
| `cancelled`    | `data:{…}` | Terminal cancellation: `data:{run_id, reason, cancelled_at}`. Closes the stream.                                                                                       |

```text Raw frame trace (run that interrupts, resumes, completes) 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"}}
```

<Warning>
  The live wire payloads above are what a client actually receives from the executor. They differ from the typed event models the platform also publishes (for example, `node_update` carries `node` — a name — not `node_id`, and has no `status` or `execution_time_ms`). Document and parse against the wire shapes shown here, not the typed models. The in-memory store's "completed" status maps to the durable run status `succeeded`, so the SSE `done` event corresponds to a durable status of `succeeded`, not `done`.
</Warning>

### Pausing and resuming (human-in-the-loop)

If the workflow contains an [interrupt node](/workflow-builder/nodes/interrupt), the run pauses, emits an `interrupt` frame (the stream stays open), and waits. Resume it with `POST /workflows/resume/{thread_id}`, passing the `run_id` and a `resume_value` that satisfies the interrupt's `resume_schema`. Resume reuses the **same** `run_id` — re-listen on it to see the rest of the run. No new credit is charged on resume. For the full pause/resume contract, see [Human-in-the-loop (HITL) resume](/realtime/hitl).

### Cancelling

Cancel an in-flight chat-triggered run with `POST /workflows/cancel/{run_id}`. Cancellation is graceful (the current node finishes first) and only `running` or `interrupted` runs are cancellable. The stream then emits a terminal `cancelled` frame.

## Credit impact

A chat-triggered run is charged exactly **one run credit** per run, regardless of how many nodes execute. Details:

* The billing gate runs **before** any rows are written (reject-before-write). On denial, no chat, messages, run record, or background task are created.
* The charge is durable and idempotent on `run_id`. A **resume** reuses the same `run_id`, so it does **not** charge again — one logical run, charged once.
* **Ephemeral runs are still billed.** Test and editor runs cost credits; only the admin/system organization is exempt.
* API-key-triggered runs additionally consume the `sync_exec` run-rate class; editor/manual JWT runs are not rate-counted at the run layer.

See [Credits & metering](/billing/credits) and [Usage gating & limits](/billing/usage-gating) for the full model.

## Errors and status codes

`POST /workflows/run` returns standard FastAPI `{"detail": "…"}` envelopes for non-billing errors and a flat `DenialEnvelope` for billing denials. For the three error-envelope shapes across the platform, see [Errors & status codes](/api-reference/errors).

| Status              | Condition                                                   | Envelope                                                                                                                   |
| ------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **400**             | `X-Organization-ID` header missing                          | `{"detail":"X-Organization-ID header is required"}`                                                                        |
| **400**             | Database mode, no live deployment                           | `{"detail":"Workflow has no active deployment. Deploy the workflow first using POST /workflows/{workflow_id}/deploy"}`     |
| **400**             | System mode, missing `input` or `config`                    | `{"detail":"Missing required 'input' field for system workflow"}` (or `'config'`)                                          |
| **400**             | No definition source provided                               | `{"detail":"Must provide one of: 'workflow_id', 'workflow' schema, or 'llm' config"}`                                      |
| **401**             | No or invalid token                                         | `{"detail":"…"}` with `WWW-Authenticate: Bearer`                                                                           |
| **403**             | Caller is not org owner/admin                               | `{"detail":"…"}`                                                                                                           |
| **404**             | System workflow file not found, or workflow not in your org | `{"detail":"…"}`                                                                                                           |
| **410**             | `llm`-only request (legacy LLM mode removed)                | `{"detail":"LLM mode on /workflows/run has been removed. Use the agentic assistant chat instead: POST /assistant/chat …"}` |
| **500**             | Malformed `workflow_schema`, or other failure               | `{"detail":"Failed to start workflow: …"}`                                                                                 |
| **402 / 403 / 429** | Billing denial                                              | flat `DenialEnvelope` — see below                                                                                          |

<Note>
  Because the request body is an untyped JSON object, a malformed `workflow_schema` surfaces as **500** (`"Failed to start workflow: …"`), not FastAPI's usual 422.
</Note>

The billing `DenialEnvelope` is a flat object — `{code, layer, key, current, limit, reason}` — with no `detail` key:

```json 402 credit exhaustion theme={null}
{
  "code": "credit_plan_exhausted",
  "layer": "credit",
  "key": "credit:org:11111111-...",
  "current": 1000.0,
  "limit": 1000.0,
  "reason": "credit_plan_exhausted"
}
```

| Layer    | Status  | `code`                                                                 |
| -------- | ------- | ---------------------------------------------------------------------- |
| `rate`   | **429** | `rate_limit_exceeded` (plus `Retry-After` and `X-RateLimit-*` headers) |
| `quota`  | **403** | `quota_exceeded`                                                       |
| `credit` | **402** | `credit_plan_exhausted`                                                |
| `wallet` | **402** | `wallet_overage_disabled`                                              |
| `wallet` | **402** | `wallet_insufficient`                                                  |
| `credit` | **402** | `upgrade_payment_failed`                                               |

Errors that arrive on the **stream** instead of the HTTP response: a 404 on connecting to `GET /workflows/listen/{run_id}` (the run is unknown or not in your org) is thrown before any frame; an in-stream failure emits a terminal `error` frame (`{type:"error", message:"…"}`). See [SSE run streaming](/realtime/sse-streaming).

## Related

<CardGroup cols={2}>
  <Card title="Run a workflow from chat (app)" icon="messages-square" href="/platform/chat/workflow-run">
    The end-user view of this same feature inside the ModuleX app.
  </Card>

  <Card title="Run via API" icon="terminal" href="/workflow-builder/execution/api-endpoint">
    Trigger a workflow programmatically with `ephemeral` runs and no chat thread.
  </Card>

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

  <Card title="Variables & references" icon="braces" href="/workflow-builder/variables-and-references">
    How `{{node_id.field}}` references resolve against run state.
  </Card>

  <Card title="Human-in-the-loop resume" icon="hand" href="/realtime/hitl">
    Pause and resume semantics for runs that hit an interrupt node.
  </Card>

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