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

# SSE run streaming

> Stream workflow, Composer, and Assistant run events over Server-Sent Events: the data-only frame format, the full event-type taxonomy, the 15-second heartbeat, terminal markers, and the run lifecycle.

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

ModuleX streams the live output of a run — every node that starts, every token batch, every pause for input, and the final result — over **Server-Sent Events (SSE)**. This page is the wire-level reference for the run-event SSE transport: the frame format, the exact event types you can receive, the heartbeat, terminal markers, and the connection lifecycle.

SSE is one of two unrelated realtime transports in ModuleX. This page covers SSE only — the one-way push channel for run and agent output. For the bidirectional canvas-collaboration transport, see [Socket.io collaboration events](/realtime/socket-events). For how the two compare, start at the [realtime overview](/realtime/overview).

<Note>
  SSE here carries **run output**, server to client only. You start a run with a normal REST call (or an SDK method), then open a separate SSE connection to a listen endpoint to watch it. To cancel a run or answer a paused run, you send separate REST `POST` requests — the SSE channel never accepts data from the client.
</Note>

## What you can stream

There are three run-event SSE streams, one per run-producing surface. They share the same frame format, the same heartbeat, and most of the same event types.

<CardGroup cols={3}>
  <Card title="Workflow runs" href="/workflow-builder/execution/running" icon="diagram-project">
    Stream a workflow execution: node lifecycle, retries, output, and the final result.
  </Card>

  <Card title="Composer runs" href="/workflow-builder/composer" icon="wand-magic-sparkles">
    Stream the AI Composer editing a workflow graph: tool calls, applied changes, and workflow syncs.
  </Card>

  <Card title="Assistant runs" href="/assistant/streaming" icon="robot">
    Stream the Assistant working through a task with tools — no workflow attached.
  </Card>
</CardGroup>

A fourth named-event SSE stream, the chat-list feed (`GET /chats/stream`), uses a **different** wire convention and is not a run stream. It is noted under [The chat-list stream is different](#the-chat-list-stream-is-different) so you do not parse it with the run-stream rules.

## The frame format

Every run-event stream uses **data-only frames**. Each event is a single `data:` line carrying a JSON object, followed by a blank line. There is **no** SSE `event:` line on these streams — the event type is the `type` key **inside** the JSON payload.

```text Run-event frame (data-only) theme={null}
data: {"type":"metadata","data":{"run_id":"run_abc","workflow_type":"workflow"}}

data: {"type":"node_started","node":"node_1","name":"LLM Call"}

```

To dispatch on an event, parse the JSON in the `data:` line and switch on its `type` field. Do **not** rely on an SSE `event:` line for run streams — there isn't one.

<Warning>
  This is the single most important fact about the run-event format: the discriminator is the JSON `type` key, never an SSE `event:` line. If your client library only surfaces the SSE `event:` field, every run-event frame will look like the default unnamed event (`message`). Read `data.type` instead. The official [SDKs](/sdks/streaming-hitl) already do this for you.
</Warning>

### Transport and response headers

Run-event streams are served as a raw HTTP `StreamingResponse` with `media_type: text/event-stream`. The response sets the following headers:

<ResponseField name="Content-Type" type="string">
  Always `text/event-stream`.
</ResponseField>

<ResponseField name="Cache-Control" type="string">
  `no-cache` — the stream must not be cached or buffered.
</ResponseField>

<ResponseField name="Connection" type="string">
  `keep-alive` — the connection stays open for the run's duration.
</ResponseField>

<ResponseField name="X-Accel-Buffering" type="string">
  `no` — disables proxy response buffering so frames are delivered as they are produced.
</ResponseField>

<ResponseField name="Access-Control-Allow-Origin" type="string">
  `*` on the **workflow** run stream only. The Composer and Assistant run streams do not set this header.
</ResponseField>

### Flat vs wrapped payloads

Even within a single run stream, payloads are not uniformly shaped. Some event types put their fields **flat** at the root of the JSON alongside `type`; others **wrap** their fields under a `data` key. This is a property of the wire, so your client must handle both.

<Note>
  The wire shapes here describe what the server actually publishes. ModuleX also defines typed event models internally, and for a few events those models disagree with the wire (for example, the models name `node_id`/`status` where the wire sends `node`/`output`). **Always code against the wire shapes documented below**, not against any generated model. The SDKs deliberately keep the parsed payload tolerant for this reason.
</Note>

| Shape                               | Event types                                                                                                             | Where the fields live       |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| **Wrapped** (`{ type, data: {…} }`) | `metadata`, `interrupt`, `resumed`, `done`, `cancelled`, `user_input_request`, and most Composer/Assistant `*` events   | under `data`                |
| **Flat** (`{ type, …fields }`)      | `node_started`, `node_update`, `node_retry`, `node_error`, `error` (workflow, Composer, **and** Assistant), `heartbeat` | at the root, next to `type` |

<Warning>
  `user_input_request` nests **one level deeper** than other wrapped events: the frame is `{ "type": "user_input_request", "data": <UserInputRequest> }`, so the question payload — including its `kind` — lives at `data.data`, not `data`. A naive read of `data.kind` returns nothing. The [HITL resume](/realtime/hitl) page documents the full question/answer contract; the Python SDK ships `user_input_request_from_event()` to unwrap it for you.
</Warning>

## The listen endpoints

You open a run stream with an authenticated `GET` to the matching listen endpoint. All three require organization owner or admin — a plain `member` cannot listen (the `member` role is retired; see [roles & permissions](/security/roles-permissions)).

| Stream        | Method and route                                        | SDK method                               |
| ------------- | ------------------------------------------------------- | ---------------------------------------- |
| Workflow run  | `GET /workflows/listen/{run_id}`                        | `executions.listen(runId)`               |
| Composer run  | `GET /composer/chat/{composer_chat_id}/listen/{run_id}` | `composer.listen(composerChatId, runId)` |
| Assistant run | `GET /assistant/chat/{chat_id}/listen/{run_id}`         | `assistant.listen(chatId, runId)`        |

<ParamField path="run_id" type="string" required>
  The per-execution run identifier returned when you start the run. Each Composer/Assistant resume mints a **new** `run_id` — re-listen on the new one. See [workflows & runs](/concepts/workflows-and-runs) for the distinct run-id identities.
</ParamField>

<ParamField path="composer_chat_id" type="string" required>
  Composer streams only. The Composer chat (thread) the run belongs to.
</ParamField>

<ParamField path="chat_id" type="string" required>
  Assistant streams only. The Assistant chat (thread) the run belongs to.
</ParamField>

### Authentication

Every request authenticates with the standard ModuleX scheme: an `Authorization: Bearer` header carrying your API key, plus the `X-Organization-ID` header selecting the organization context. See [authentication](/api-reference/authentication) for the full scheme and the API base URL (no `/v1` segment — see [environments](/get-started/environments)).

<Note>
  Browser `EventSource` cannot send custom request headers, so it cannot satisfy the `Authorization` and `X-Organization-ID` requirements. The official [SDKs](/sdks/streaming-hitl) and the ModuleX app use `fetch`-based stream readers instead. Plan your client accordingly — raw `EventSource` against these endpoints will fail the auth check.
</Note>

### Opening a stream

The example below opens the workflow run stream. Swap the route and SDK method for Composer or Assistant. The auth and parsing are identical.

<CodeGroup>
  ```bash cURL theme={null}
  curl -N https://api.modulex.dev/workflows/listen/RUN_ID \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: ORG_UUID" \
    -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="ORG_UUID") as client:
          async with client.executions.listen("RUN_ID") as stream:
              async for event in stream:
                  print(event.event, event.data)
                  if event.is_terminal:
                      break

  asyncio.run(main())
  ```

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

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

  for await (const event of client.executions.listen("RUN_ID")) {
    console.log(event.type, event.data);
    if (event.type === "done" || event.type === "error") break;
  }
  ```
</CodeGroup>

<Note>
  SSE streams in the SDKs are **not** subject to the client's `maxRetries` or request `timeout` — those apply to ordinary requests only. A stream stays open until the run reaches a terminal state, you cancel it, or the connection drops. The heartbeat (below) keeps an idle connection warm; the SDKs do not auto-reconnect.
</Note>

<MediaEmbed id="MX-MEDIA-2070" type="image" caption={"Diagram of the SSE run lifecycle and frame format."} />

## The event-type taxonomy

Dispatch on the JSON `type`. The tables below give the wire shape and key fields for each event you can receive on each stream. Field names are exactly as they appear on the wire (snake\_case).

### Workflow run events

These are the events emitted on `GET /workflows/listen/{run_id}`.

<ResponseField name="metadata" type="wrapped">
  First frame of the run. `data` carries `run_id`, `thread_id`, `workflow_name`, `workflow_version`, `workflow_type` (`workflow` or `llm`), and `timestamp`.
</ResponseField>

<ResponseField name="node_started" type="flat">
  A node began executing. Fields: `node` (the node id), `name`, `timestamp`. Knowledge nodes also include a `metadata` object describing the provider.
</ResponseField>

<ResponseField name="node_update" type="flat">
  A node produced output. Fields: `node` and `output`. For LLM nodes, output is streamed as a token batch — `output.messages` is an array of `{ type: "ai", content: <full response so far> }`, republished on each batch interval (default 500 ms) as the response grows.

  <Warning>
    The wire fields are `node` and `output` — **not** `node_id`, `node_type`, or `status`. Code against `node`/`output`.
  </Warning>
</ResponseField>

<ResponseField name="node_retry" type="flat">
  A node failed and is being retried. Fields: `node`, `name`, `attempt`, `max_attempts`, `error_type`, `error_message`, `next_retry_in`, `timestamp`. See [error handling & retries](/workflow-builder/error-handling-retries).
</ResponseField>

<ResponseField name="node_error" type="flat">
  A node failed. Fields: `node`, `name`, `error_type`, `error_message`, `reason` (a stable error code, or `null`), `attempt`, `max_attempts`. A `node_error` does **not** end the run by itself — the run continues per its error-handling configuration and ends with `done` or `error`.
</ResponseField>

<ResponseField name="interrupt" type="wrapped">
  The run paused at an [interrupt node](/workflow-builder/nodes/interrupt) to await a resume value. `data` carries `message`, `data`, and optionally `resume_schema` and `examples`. **Not terminal** — the stream goes quiet but stays open. Resume the run with `executions.resume(thread_id, run_id, resume_value)` (`POST /workflows/resume/{thread_id}`), which **reuses the same `run_id`**. This is distinct from Composer/Assistant HITL — see [HITL resume](/realtime/hitl).
</ResponseField>

<ResponseField name="resumed" type="wrapped">
  Published when a paused workflow run continues. `data` carries `run_id`, `thread_id`, `resume_value`, and `timestamp`.
</ResponseField>

<ResponseField name="done" type="wrapped">
  Terminal. The run completed successfully. `data` carries `message` (for example, `"Workflow completed successfully"`). The full result is read from the run record, not from this frame.
</ResponseField>

<ResponseField name="cancelled" type="wrapped">
  Terminal. The run was cancelled. `data` carries the cancel info — `run_id`, `reason`, `cancelled_at` — or may be `null` if the cancel record has expired.
</ResponseField>

<ResponseField name="error" type="flat">
  Terminal. The run failed, or the server hit a streaming error. Shape varies; commonly includes `message` and optionally `error_type`. If the server hits an exception mid-stream, it emits a final `error` frame rather than dropping the HTTP response.
</ResponseField>

<ResponseField name="heartbeat" type="flat">
  A keepalive. The exact frame is `{"type":"heartbeat"}` with no other fields. Injected on idle; see [the heartbeat](#the-heartbeat). Ignore it in your dispatch.
</ResponseField>

### Composer run events

Emitted on `GET /composer/chat/{composer_chat_id}/listen/{run_id}`. Most Composer events are wrapped (`{ type, data }`) — the exceptions are `error` and `heartbeat`, which are flat (fields at the root next to `type`).

<ResponseField name="metadata" type="wrapped">
  First frame. `data` carries `run_id`, `thread_id`, `workflow_id`, `workflow_type` (`"composer"`), and `timestamp`.
</ResponseField>

<ResponseField name="response_chunk" type="wrapped">
  A streamed slice of the agent's natural-language response. `data.chunk` is the text fragment.
</ResponseField>

<ResponseField name="tool_call" type="wrapped">
  The agent invoked a tool. `data` carries `tool`, `input`, `tool_call_id`, and `timestamp`.
</ResponseField>

<ResponseField name="tool_result" type="wrapped">
  A tool returned. `data` carries `tool`, `tool_call_id`, `output`, and `timestamp`. On failure, `output` is `{ error, success: false }`.
</ResponseField>

<ResponseField name="subagent_start" type="wrapped">
  A Composer subagent started. `data.subagent` names it.
</ResponseField>

<ResponseField name="guidance" type="wrapped">
  An in-band guidance/warning message (for example, repeated failures). `data` carries `message` and `consecutive_failures`.
</ResponseField>

<ResponseField name="workflow_change" type="wrapped">
  The Composer applied an edit to the workflow graph. `data` carries `type` (for example, `"applied"`), `tool`, `changes_made` (array), and `has_pending_changes`.
</ResponseField>

<ResponseField name="workflow_sync" type="wrapped">
  A full sync of the edited graph. `data` carries `workflow_id`, `source` (`"composer"`), `edit_version`, `changes_made`, and the complete `workflow` (`nodes`, `edges`, …).
</ResponseField>

<ResponseField name="user_input_request" type="wrapped (nested)">
  The agent paused to ask you a structured question (HITL). `data` is the `UserInputRequest` — discriminate on `data.data.kind`. **Not terminal**; the stream goes quiet. Answer with `composer.resume(...)`, which returns a **new** `run_id` to re-listen on. Full contract: [HITL resume](/realtime/hitl).
</ResponseField>

<ResponseField name="done" type="wrapped">
  Terminal. `data` carries `response`, `has_workflow_changes`, `tool_calls`, `workflow_tool`, an optional `workflow_changes`, and `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `llm_calls`).
</ResponseField>

<ResponseField name="cancelled" type="wrapped">
  Terminal. The run was cancelled.
</ResponseField>

<ResponseField name="error" type="flat">
  Terminal. The run failed. Fields are at the **root** next to `type`: `message`, and optionally `error_type`. The frame is `{"type":"error","message":…}`, **not** wrapped under `data`.
</ResponseField>

<ResponseField name="heartbeat" type="flat">
  Keepalive, `{"type":"heartbeat"}`. Ignore it.
</ResponseField>

<Note>
  `interrupted` exists as a **history-only** marker (it records a past HITL pause for replay) and is **never published live**. Do not wait for it on a live stream — a live HITL pause arrives as `user_input_request`, after which the stream simply goes quiet with no terminal frame until you resume or reconnect.
</Note>

### Assistant run events

Emitted on `GET /assistant/chat/{chat_id}/listen/{run_id}`. The Assistant shares the Composer executor but has **no workflow**, so `workflow_change` and `workflow_sync` never fire. The declared event set is: `metadata`, `response_chunk`, `tool_call`, `tool_result`, `user_input_request`, `run_resumed`, `guidance`, `done`, `error`, `cancelled`, `heartbeat`. Shapes match the Composer events of the same name.

<Warning>
  Two Assistant-specific traps:

  * The `metadata` frame reports `workflow_type: "composer"` even for Assistant runs — that field is **not** a reliable surface discriminator. Treat the stream as an Assistant stream because you opened the Assistant endpoint, not because of `workflow_type`.
  * The Assistant emits **`run_resumed`** after a resume, whereas workflow runs emit **`resumed`**. They are different literals on different streams — you cannot listen for one name across both.
</Warning>

<Note>
  Like the Composer `error`, the Assistant `error` event is **flat**: `{"type":"error","message":…}` with `message` (and optional `error_type`) at the root, not under `data`. The `error` event is flat on all three run streams — workflow, Composer, and Assistant.
</Note>

### The chat-list stream is different

`GET /chats/stream` is the only ModuleX SSE stream that uses **named SSE events** — real `event:` lines (`event: connected`, `event: chat_list_updated`) — instead of the data-only `type`-discriminator convention. It carries chat-list invalidation notices, **not** run output (in particular, it never carries `response_chunk` token output). Its keepalive is an SSE comment line (`: keepalive`) every 30 seconds, not a `heartbeat` data frame. Parse it with named-event rules, not the run-stream rules above.

```text Chat-list frame (named events — different convention) theme={null}
event: connected
data: {"status":"connected","timestamp":"2026-06-20T10:00:00Z"}

: keepalive

event: chat_list_updated
data: {"event":"chat_list_updated","type":"public","chat_ids":["c1"],"event_types":["message_add"]}
```

## The heartbeat

To keep an idle connection alive (for example, while an agent is "thinking" and producing no output), the server injects a heartbeat frame on the run-event streams.

<ResponseField name="Heartbeat frame" type="flat JSON">
  Exactly `{"type":"heartbeat"}` — no `data`, no other fields.
</ResponseField>

<ResponseField name="Interval" type="fixed: 15 seconds">
  Emitted after 15 seconds of socket idle (`HEARTBEAT_TIMEOUT_SECONDS = 15`). **This interval is not configurable.**
</ResponseField>

Ignore heartbeats in your own dispatch logic — they carry no run information. The SDKs handle them for you:

* **Python** filters `heartbeat` out by default; pass `include_heartbeats=True` to receive them.
* **JavaScript** yields the `{ "type": "heartbeat" }` data frame as a normal event, so your consumer must skip `type === "heartbeat"`.

<Note>
  The `: keepalive` comment on `/chats/stream` (every 30 seconds) is a separate mechanism from the 15-second run-stream `heartbeat` data frame. Don't conflate the two intervals or shapes.
</Note>

## Terminal markers and the run lifecycle

A run stream ends when a terminal event arrives. The terminal set is `done`, `error`, `cancelled`, and `interrupted` — though `interrupted` is history-only (see above), so on a **live** stream you will see `done`, `error`, or `cancelled`.

<Warning>
  The terminal-break behavior is asymmetric across layers, and it matters for how you write your loop:

  * The **workflow** listen endpoint server-side breaks the stream only on `done` and `error`.
  * The **Composer/Assistant** listen endpoints have **no** server-side terminal break — they rely on the publisher closing the channel.
  * The **SDKs** treat the full set `{done, error, cancelled, interrupted}` as terminal and stop iterating on any of them.

  So an SDK may stop iterating on `cancelled` while the underlying workflow HTTP stream is technically still open. Treat `done`, `error`, and `cancelled` as terminal in your own code regardless of which SDK you use.
</Warning>

A typical workflow run, on the wire:

```text Workflow run (happy path) theme={null}
data: {"type":"metadata","data":{"run_id":"r2","thread_id":"t2","workflow_name":"My WF","workflow_version":"1.0","workflow_type":"workflow","timestamp":"2026-06-20T10:00:00Z"}}

data: {"type":"node_started","node":"node_1","name":"LLM Call","timestamp":1718877600.0}

data: {"type":"node_update","node":"node_1","output":{"messages":[{"type":"ai","content":"Hello"}]}}

data: {"type":"heartbeat"}

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

A Composer run that pauses for input, then resumes:

```text Composer run with HITL pause theme={null}
data: {"type":"metadata","data":{"run_id":"r1","thread_id":"c1","workflow_id":"w1","workflow_type":"composer","timestamp":"2026-06-20T10:00:00Z"}}

data: {"type":"response_chunk","data":{"chunk":"Let me confirm before I continue."}}

data: {"type":"user_input_request","data":{"kind":"yes_no","request_id":"req_42","message":"Send the welcome email now?","required":true,"allow_free_text":false,"yes_label":"Send it","no_label":"Not yet"}}

```

After `user_input_request` the stream goes quiet — there is **no** `done` frame. You answer with `composer.resume(...)`, which returns a **new** `run_id`; you then re-`listen()` on that new id to watch the run finish. The full pause/answer contract lives on [HITL resume](/realtime/hitl).

### Reconnecting and missed events

On reconnect, the server replays the run's buffered event history (held for 1 hour) in order, then tails live — so a brief disconnect does not lose events. If a replayed event is already terminal, the stream returns immediately rather than hanging on a finished run.

<Warning>
  **Replay via `Last-Event-ID` is an open question.** ModuleX frames do not currently carry an SSE `id:` field, and there is no verified server code path that replays from a client-supplied `Last-Event-ID` header. Reconnection recovery is handled by the server-side 1-hour history replay described above, **not** by SSE event ids. Do not build a client that depends on `Last-Event-ID`-based resumption until this is confirmed — see [open questions](#open-questions).
</Warning>

## Errors on connect

A non-2xx status when you open the stream is raised **before the first frame** — the SDKs throw a typed exception synchronously as you begin iterating, never as an in-stream event.

| Status | Meaning                                                                                                       | SDK error             |
| ------ | ------------------------------------------------------------------------------------------------------------- | --------------------- |
| 401    | Missing or invalid API key                                                                                    | `AuthenticationError` |
| 403    | Caller lacks organization owner/admin                                                                         | `PermissionError`     |
| 404    | Run or chat does not exist, **or** is not in your organization (identical 404 either way — no existence leak) | `NotFoundError`       |
| 429    | Rate limit hit                                                                                                | `RateLimitError`      |

Run-producing surfaces (workflow, Composer, Assistant) are gated by the billing admission check, so starting a run can return `402`/`403`/`429` with a flat `DenialEnvelope` (`{code, layer, key, current, limit, reason}`). The listen endpoint itself authorizes and streams; the admission denial happens when you start the run, not on the listen. For the full error model and all three envelope shapes, see [errors & status codes](/api-reference/errors), [rate limiting](/api-reference/rate-limiting), and [usage gating & limits](/billing/usage-gating).

A `404` for a foreign or unknown run is returned by an ownership guard that runs before the stream subscribes — you never get a stream for a run outside your organization. See the run-id model in [workflows & runs](/concepts/workflows-and-runs).

### Errors mid-stream

A mid-stream failure does **not** throw. The server emits a final `error` frame (`{"type":"error", …}`) and closes the connection. Your consumer must handle the `error` event type as a terminal outcome, exactly as it handles `done`.

## Cancelling a run

You cannot cancel over the SSE channel — it is one-way. Send a separate REST `POST` (or call the SDK `cancel` method), and the open stream will emit a `cancelled` terminal frame.

| Surface       | Cancel route                                    | SDK method                        |
| ------------- | ----------------------------------------------- | --------------------------------- |
| Workflow run  | `POST /workflows/cancel/{run_id}`               | `executions.cancel(runId)`        |
| Composer run  | `POST /composer/chat/{composer_chat_id}/cancel` | `composer.cancel(composerChatId)` |
| Assistant run | `POST /assistant/chat/{chat_id}/cancel`         | `assistant.cancel(chatId)`        |

In a client, you can also stop **listening** without cancelling the run by aborting the request. In JavaScript, pass an `AbortSignal`; in Python, exit the stream's `async with` block. Both stop iteration and release the connection cleanly while the run keeps executing server-side.

<CodeGroup>
  ```javascript JavaScript theme={null}
  const controller = new AbortController();
  const stream = client.executions.listen(runId, { signal: controller.signal });
  setTimeout(() => controller.abort(), 5_000); // stop listening after 5s; run keeps going
  for await (const event of stream) {
    // ...
  }
  ```

  ```python Python theme={null}
  async with client.executions.listen(run_id) as stream:
      async for event in stream:
          if event.event == "node_update":
              break  # leaving the context manager stops listening; the run keeps going
  ```
</CodeGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Streaming & HITL in the SDKs" href="/sdks/streaming-hitl" icon="code">
    Consume these streams and answer paused runs in JavaScript and Python.
  </Card>

  <Card title="Human-in-the-loop resume" href="/realtime/hitl" icon="hand">
    The full `user_input_request` / resume contract, request and response kinds, and the new-run-id rule.
  </Card>

  <Card title="Realtime overview" href="/realtime/overview" icon="signal-stream">
    How SSE run streaming and Socket.io collaboration fit together.
  </Card>

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

## Open questions

These items are unverified in the source and are intentionally not documented as fact:

* **`Last-Event-ID` replay semantics.** ModuleX run-event frames do not carry an SSE `id:` field, and no server path replays from a `Last-Event-ID` header was confirmed. Reconnect recovery is via the 1-hour server-side history replay, not SSE ids. Whether `Last-Event-ID`-based resume is ever supported is unresolved.
* **The `/workflows/{workflow_id}/changes` collaboration stream.** A separate workflow-definition-change SSE stream exists (events `connected`, `workflow_updated`, `user_joined`, `user_left`), but its backend wire convention and emit sites are not fully pinned. It is not a run stream and is out of scope for this page.
* **Infrastructure idle timeouts.** Proxy or gateway idle timeouts (relative to the 15-second heartbeat) are deployment configuration, not part of the documented API contract.
