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

# Stream the Assistant over SSE

> Stream the Assistant's actions and output live with Server-Sent Events: the action-frame wire format, every event type, listen() in the JavaScript and Python SDKs, how to cancel a turn, and how app streaming differs from 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>;
};

Every Assistant turn streams. When you start a turn, the response does not arrive in one block — the Assistant pushes a series of small JSON frames over a Server-Sent Events (SSE) connection as it thinks, calls tools, reads results, pauses for your input, and finishes. This page is the technical reference for that stream: the frame format on the wire, every event `type` the Assistant emits, how to consume the stream with `listen()` in the SDKs, how to cancel a turn, and how streaming in the app differs from streaming from your own code.

The Assistant stream is one specific case of ModuleX run streaming. For the transport-level reference that also covers workflow runs, see [SSE run streaming](/realtime/sse-streaming). For the SDK helpers that pair streaming with answering pause-for-input questions, see [streaming and HITL](/sdks/streaming-hitl).

## How an Assistant turn streams

A turn is a request-then-listen pattern. You never receive events on the same request that starts the turn — starting a turn and listening to it are two separate calls.

<Steps>
  <Step title="Start the turn">
    Send `POST /assistant/chat` with your message. The response is a small JSON body (not a stream) that contains the `chat_id`, a freshly minted `run_id`, the `thread_id`, and a `stream_url`. See [how the Assistant works](/assistant/how-it-works) for the full request shape.
  </Step>

  <Step title="Open the stream">
    Open `GET /assistant/chat/{chat_id}/listen/{run_id}` (this is exactly the `stream_url` from step 1). The connection stays open and the server pushes frames as the turn progresses.
  </Step>

  <Step title="Consume frames until terminal">
    Read frames and switch on each frame's `type`. Keep reading until you receive a terminal frame (`done`, `error`, or `cancelled`), or until the turn pauses for [human-in-the-loop](/assistant/human-in-the-loop) input.
  </Step>

  <Step title="Resume or finish">
    If the turn paused with a `user_input_request`, answer it with `POST /assistant/chat/{chat_id}/resume`. Resume mints a **new** `run_id` and a new `stream_url`, so you open a fresh stream on the new id. Otherwise the turn is done.
  </Step>
</Steps>

<Note>
  The stream is server-to-client only. There is no way to send data back to the Assistant over the open SSE connection — answering a paused turn, cancelling, and starting the next turn are all separate REST calls. Resume and cancel are covered below and in [human-in-the-loop](/assistant/human-in-the-loop).
</Note>

## The stream endpoint

<ParamField path="GET /assistant/chat/{chat_id}/listen/{run_id}" type="SSE endpoint">
  Opens the live event stream for one Assistant run. Returns a `text/event-stream` response with the headers `Cache-Control: no-cache`, `Connection: keep-alive`, and `X-Accel-Buffering: no`.
</ParamField>

<ParamField path="chat_id" type="string (UUID)" required>
  The Assistant chat to stream. Must be a chat you own in the current organization, with `kind` set to `assistant`.
</ParamField>

<ParamField path="run_id" type="string (UUID)" required>
  The run to stream, as returned by `POST /assistant/chat` or `POST /assistant/chat/{chat_id}/resume`. A new `run_id` is minted on every turn and on every resume, so the id is per-turn, not per-conversation. The stable per-conversation id is the `thread_id`, which equals the `chat_id`.
</ParamField>

### Authentication and authorization

Every call uses the standard ModuleX headers — see [authentication](/api-reference/authentication).

* `Authorization: Bearer mx_live_…` — your API key (a Clerk JWT is also accepted; the backend additionally accepts the key in `X-API-KEY`).
* `X-Organization-ID: org_…` — the organization context. Missing this header returns `400`.

The Assistant endpoints require the **owner** or **admin** role in that organization, enforced by `organization_admin_required`. The retired `member` role cannot use the Assistant. See [roles and permissions](/security/roles-permissions) and [permissions and limits](/assistant/permissions-and-limits).

Before the server subscribes you to the stream, it runs an ownership check that binds the `run_id` to your `chat_id` and organization. A `run_id` that is not in your chat — including one that belongs to another tenant — returns **`404`** with `Run not found`, never `403`. There is no existence leak: "does not exist" and "not yours" return the same `404`.

<Note>
  A non-2xx status on connect is delivered as an error **before the first frame**, not as a frame in the stream. In the SDKs this surfaces as a thrown typed exception the moment you start iterating (for example `NotFoundError` for `404`). See [error frames vs connection errors](#error-frames-vs-connection-errors).
</Note>

## Action frames: the wire format

The Assistant stream is a **data-only** SSE stream. Each event is written as a single `data:` line whose value is a JSON object, terminated by a blank line:

```text theme={null}
data: {"type":"response_chunk","data":{"chunk":"Let me check that for you."}}

```

There is **no** SSE `event:` line. The discriminator you switch on is the `type` field **inside** the JSON, not the SSE event name. This matters if you write your own parser: do not look for a named SSE event; parse the `data:` payload and read `type`.

<Warning>
  The chat-list sidebar stream (`GET /chats/stream`) uses the opposite convention — real SSE `event:` lines such as `event: chat_list_updated`. That is a different stream for a different purpose and is not the Assistant turn stream. Do not reuse a parser written for one on the other. The full taxonomy is in [SSE run streaming](/realtime/sse-streaming).
</Warning>

### Flat vs wrapped payloads

Within a single Assistant stream, payloads are inconsistent about nesting. Most Assistant frames are **wrapped**: the real payload sits under a `data` key alongside `type`. The keepalive `heartbeat` frame is **flat** — it carries only `type` and nothing else.

```text theme={null}
data: {"type":"tool_call","data":{"tool":"get_available_integrations","input":{},"tool_call_id":"tc_1","timestamp":"2026-06-20T10:00:02Z"}}

data: {"type":"heartbeat"}

```

One frame nests **one level deeper** than the rest: `user_input_request`. Its frame is `{"type":"user_input_request","data":{…the question…}}`, so the actual question object is at `data.data`, not `data`. A naive read of `frame.data.kind` returns nothing — read `frame.data.data.kind`. The Python SDK ships `user_input_request_from_event()` specifically to hide this; see [the HITL frame](#the-hitl-frame-user_input_request).

### Responses are snake\_case

Frame payloads are the backend's raw JSON, which is snake\_case (`run_id`, `tool_call_id`, `request_id`). The SDKs do **not** convert streamed response fields to camelCase, so the typed event models you consume use snake\_case field names. (The JavaScript SDK does convert request bodies camelCase to snake\_case on the way out, but never response payloads.)

## Event types

These are the frame types the Assistant emits, as actually published by the executor. Switch on `type`. Every payload field below lives under the frame's `data` key unless noted.

<ResponseField name="metadata" type="object (wrapped)">
  First frame of the run. Carries `run_id`, `thread_id`, `workflow_id` (always `null` for the Assistant), `workflow_type`, and `timestamp`.

  <Warning>
    `workflow_type` is **always `"composer"`** on an Assistant run — even though no workflow is involved. It is not a reliable surface discriminator. Do not branch on it to detect "this is an Assistant run." The reliable surface tag is the request metadata `surface` field (`assistant`).
  </Warning>
</ResponseField>

<ResponseField name="response_chunk" type="object (wrapped)">
  A streamed slice of the Assistant's natural-language reply. The payload is `{ chunk }`, where `chunk` is a text fragment. Concatenate `chunk` values in order to build the running reply text.
</ResponseField>

<ResponseField name="tool_call" type="object (wrapped)">
  The Assistant has decided to call a tool. Payload: `tool` (the tool name), `input` (the arguments object), `tool_call_id` (a stable id for this call), and `timestamp`. The Assistant calls one tool at a time within a step. See [using tools](/assistant/using-tools).
</ResponseField>

<ResponseField name="tool_result" type="object (wrapped)">
  The result of a `tool_call`. Payload: `tool`, `tool_call_id` (matches the originating `tool_call`), `output`, and `timestamp`. On a tool error the `output` carries an error shape such as `{error, success: false}`. Pair a result to its call by `tool_call_id`.
</ResponseField>

<ResponseField name="guidance" type="object (wrapped)">
  An internal progress signal the agent emits after repeated tool failures or at language-model-call milestones. Payload is `{message, consecutive_failures}` or `{message, total_llm_calls}`. Informational; safe to ignore in most clients.
</ResponseField>

<ResponseField name="user_input_request" type="object (double-wrapped)">
  The turn paused to ask you something — a choice, a yes/no, free text, or a credential connection. The question object is at `data.data` (one level deeper than every other frame). After this frame the stream goes **quiet** with no terminal frame; the run is paused server-side awaiting your answer. See [the HITL frame](#the-hitl-frame-user_input_request) and [human-in-the-loop](/assistant/human-in-the-loop).
</ResponseField>

<ResponseField name="run_resumed" type="object">
  Published on the **old** run's channel when an OAuth credential connection auto-resumes the turn without an explicit resume call. Payload is `{new_run_id}`. When you see it, switch your stream to the new `run_id`. See [OAuth auto-resume](#oauth-auto-resume).
</ResponseField>

<ResponseField name="done" type="object (wrapped) · terminal">
  The turn finished successfully. Payload carries `response` (the final reply text), `tool_calls` (the calls made this turn), `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `llm_calls`), and the composer-shaped keys `has_workflow_changes` (always `false` for the Assistant) and `workflow_tool` (always `null`). This frame ends the stream.
</ResponseField>

<ResponseField name="error" type="object (flat) · terminal">
  The turn failed. Payload carries a `message`. This frame ends the stream. A backend exception mid-turn is emitted as a final `error` frame rather than dropping the connection. See [error frames vs connection errors](#error-frames-vs-connection-errors).
</ResponseField>

<ResponseField name="cancelled" type="object · terminal">
  The turn was cancelled, typically by `POST /assistant/chat/{chat_id}/cancel`. Payload carries a `reason`. This frame ends the stream. See [cancel a turn](#cancel-a-turn).
</ResponseField>

<ResponseField name="interrupted" type="object · history-only · terminal marker">
  A **history-only** marker written when a turn pauses for input. It is appended to the run's history but **never published live**, so an actively-connected client does not receive it — the live stream simply goes quiet after `user_input_request`. On a later reconnect the replay uses `interrupted` to stop cleanly. Treat it as terminal only when replaying history.
</ResponseField>

<ResponseField name="heartbeat" type="object (flat) · keepalive">
  A `{"type":"heartbeat"}` keepalive injected roughly every 15 seconds of idle time to keep the connection and any intermediary proxies alive. It carries no payload. Ignore it. The Python SDK filters heartbeats out by default; the JavaScript SDK yields them and you skip them yourself. See [SDK heartbeat handling](#heartbeats-and-keepalives).
</ResponseField>

<Note>
  The Assistant has **no workflow**, so the composer-only frames `workflow_change` and `workflow_sync` **never fire** on an Assistant stream. If you are writing one consumer for both the Assistant and the [Composer](/concepts/ai-composer), you can ignore those two types on the Assistant path.
</Note>

### Terminal frames and when the stream ends

A stream ends after one of these terminal frames: `done`, `error`, or `cancelled`. The `interrupted` marker is also terminal, but only on a history **replay** — it is never sent live.

There is one case where the stream does **not** end with a terminal frame: a live pause for input. After a `user_input_request`, the connection stays open and idle (with periodic heartbeats) but no `done`/`error`/`cancelled` arrives, because the run is parked waiting for your answer. Your consumer should stop reading on `user_input_request`, answer it, and open a fresh stream on the new `run_id` that resume returns.

### Heartbeats, history, and reconnection

Each published frame is also appended to a per-run history list with a **1-hour TTL**. When you (re)connect to a run that already has history, the server first replays the stored frames in order, then tails live. If the run already reached a terminal state, the replay yields up to and including the terminal frame and then closes — there is no live producer to wait for. This makes reconnecting within the hour replay-safe. The SDKs do not auto-reconnect; reconnection (and de-duplicating replayed `tool_call`/`tool_result` frames by `tool_call_id`) is the consumer's job.

## The HITL frame: `user_input_request`

When the Assistant needs a decision, it pauses and emits a `user_input_request`. The question is at `data.data` and is discriminated on its `kind`. The wire contract is shared with the Composer and is verified field-for-field across both SDKs.

```text theme={null}
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"}}

```

<Expandable title="Question kinds and their fields">
  All kinds share the base fields `request_id`, `message` (markdown), `required` (default `true`), `allow_free_text` (default `false`), `context` (optional), and `timeout_hint_seconds` (optional).

  * `single_choice` — `options` (1 to 10 `ChoiceOption` entries).
  * `multi_choice` — `options` (1 to 20), `min_selections`, `max_selections` (optional).
  * `yes_no` — `yes_label` (default `Yes`), `no_label` (default `No`).
  * `free_text` — `placeholder` (optional), `multiline`, `min_length`, `max_length` (optional).
  * `credential_request` — `integration_name`, `integration_display_name`, `integration_logo` (optional), `auth_options` (a list of `CredentialAuthOption` whose `auth_type` is one of `oauth2`, `api_key`, `bearer_token`, `modulex_key`, `custom`), and `pending_node_name` (optional).
</Expandable>

You answer with `POST /assistant/chat/{chat_id}/resume`, sending the `request_id`, a `response` discriminated on its own `kind`, and the `llm` config. Resume mints a new `run_id` and `stream_url`. Only the user who triggered the question may answer it. The complete request/response contract, error codes, and worked examples are in [human-in-the-loop](/assistant/human-in-the-loop) and [streaming and HITL](/sdks/streaming-hitl).

### OAuth auto-resume

When a `credential_request` opens an OAuth connection and you complete it, the OAuth callback resumes the turn for you — you do not call resume manually. The callback publishes a `run_resumed` frame **on the old run's channel** carrying `{new_run_id}`. Your client should react by switching its stream to `new_run_id`. This is why Assistant chats share the Composer's storage and resume machinery.

## `listen()` in the SDKs

Both official SDKs wrap the stream behind a single `listen()` method that returns an async iterator of parsed frames. The auth headers, the data-only parsing, the snake\_case payloads, and the connect-time error handling are all handled for you.

<Note>
  SDK streams are **not** subject to the client's retry policy or request timeout. `maxRetries` and `timeout` apply to ordinary requests only; `listen()` opens the connection directly and relies on heartbeats to keep it alive. Cancellation is explicit — see [cancel a turn](#cancel-a-turn).
</Note>

The example below starts a turn, opens the stream, and prints text and tool activity until a terminal frame. Authenticate with `Authorization: Bearer mx_live_…` plus `X-Organization-ID` — see [authentication](/api-reference/authentication).

<CodeGroup>
  ```bash cURL theme={null}
  # 1. Start the turn — returns chat_id, run_id, and stream_url.
  curl -sN -X POST https://api.modulex.dev/assistant/chat \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_your_organization_id" \
    -H "Content-Type: application/json" \
    -d '{
      "message": "List my GitHub repositories",
      "llm": { "integration_name": "openai", "provider_id": "openai", "model_id": "gpt-4o" }
    }'
  # => {"status":"running","chat_id":"...","run_id":"...","thread_id":"...","stream_url":"/assistant/chat/.../listen/..."}

  # 2. Open the stream (substitute the stream_url from step 1).
  #    Each event arrives as a single `data:` line; the type is inside the JSON.
  curl -sN https://api.modulex.dev/assistant/chat/{CHAT_ID}/listen/{RUN_ID} \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_your_organization_id" \
    -H "Accept: text/event-stream"
  ```

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

  client = Modulex(api_key="mx_live_your_api_key", organization_id="org_your_organization_id")

  async def main():
      # 1. Start the turn.
      started = await client.assistant.chat(
          "List my GitHub repositories",
          llm={"integration_name": "openai", "provider_id": "openai", "model_id": "gpt-4o"},
      )

      # 2. Open the stream. Heartbeats are filtered out by default.
      async with client.assistant.listen(started.chat_id, started.run_id) as stream:
          async for event in stream:
              if event.event == "response_chunk":
                  print(event.data["data"]["chunk"], end="", flush=True)
              elif event.event == "tool_call":
                  print(f"\n[calling {event.data['data']['tool']}]")
              elif event.event == "user_input_request":
                  print("\n[paused — needs your input; answer with assistant.resume]")
                  break
              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_your_api_key",
    organizationId: "org_your_organization_id",
  });

  // 1. Start the turn.
  const started = await client.assistant.chat({
    message: "List my GitHub repositories",
    llm: { integration_name: "openai", provider_id: "openai", model_id: "gpt-4o" },
  });

  // 2. Open the stream. Frames are yielded already discriminated on `type`.
  for await (const evt of client.assistant.listen(started.chat_id, started.run_id)) {
    switch (evt.type) {
      case "response_chunk":
        process.stdout.write(evt.data.chunk ?? "");
        break;
      case "tool_call":
        console.log(`\n[calling ${evt.data.tool}]`);
        break;
      case "user_input_request":
        console.log("\n[paused — answer with assistant.resume]");
        break; // stop reading; go answer it
      case "heartbeat":
        break; // ignore keepalives
      case "done":
      case "error":
      case "cancelled":
        break;
    }
  }
  ```
</CodeGroup>

<Note>
  The SDK surfaces differ in shape. The Python SDK yields a normalized `SSEEvent` whose logical name is on `event.event` and whose raw payload is on `event.data`; it exposes `event.is_terminal`. The JavaScript SDK casts each frame to a typed union and yields the payload object directly, discriminated on `evt.type`. In both, a `user_input_request` payload is nested — Python at `event.data["data"]`, JavaScript at `evt.data`.
</Note>

### Heartbeats and keepalives

The two SDKs treat the `heartbeat` keepalive differently:

* **Python** filters `heartbeat` (and `keepalive`) frames out — they are never yielded unless you pass `include_heartbeats=True`. Its read timeout is disabled on streams, so a long, idle pause-for-input does not time out.
* **JavaScript** skips SSE comment lines automatically, but a `{"type":"heartbeat"}` data frame is yielded as a normal event — your consumer must ignore it (as the example does).

### Error frames vs connection errors

There are two distinct error surfaces, and you handle them in different places:

1. **Connection errors** happen on connect, before any frame. A non-2xx status throws a typed exception synchronously as you begin iterating — `401` AuthenticationError, `403` PermissionError, `404` NotFoundError, `429` RateLimitError. Wrap the start of iteration in a try/catch.
2. **Mid-stream errors** arrive as a normal `error` frame inside the stream. The parser does not throw on them — your switch must handle `type === "error"` and treat it as terminal.

For the full SDK error model — classes, retries, and the `410` gap on resume — see [errors and retries](/sdks/errors-retries) and [streaming and HITL](/sdks/streaming-hitl).

## Cancel a turn

There is no in-band cancel over the stream. To stop a running turn, make a separate REST call to `POST /assistant/chat/{chat_id}/cancel`. The server sets a cancel flag, publishes a `cancelled` frame to the stream, sets the run status to `cancelled`, and — if the turn was paused on a HITL question — clears the pending question so it is not re-presented. If there is no running turn, the call returns `400` with `No active execution to cancel`.

In the SDKs you also stop **reading** locally. Closing the iterator (via an `AbortController` in JavaScript, or by exiting the `async with` / breaking the loop in Python) stops consuming frames; calling `cancel()` stops the turn on the server. Do both: stop reading to free the client, and call cancel to stop the work and its metering.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/assistant/chat/{CHAT_ID}/cancel \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_your_organization_id"
  # => {"status":"cancelled","chat_id":"...","run_id":"..."}
  ```

  ```python Python theme={null}
  # Stop consuming locally (exit the `async with`), then cancel the turn server-side.
  await client.assistant.cancel(chat_id)
  # => AssistantCancelResponse(status="cancelled", chat_id=..., run_id=...)
  ```

  ```javascript JavaScript theme={null}
  // Abort the local stream...
  const controller = new AbortController();
  const stream = client.assistant.listen(chatId, runId, { signal: controller.signal });
  setTimeout(() => controller.abort(), 5_000); // stop reading after 5s

  // ...and cancel the turn on the server.
  await client.assistant.cancel(chatId);
  // => { status: "cancelled", chat_id: "...", run_id: "..." }
  ```
</CodeGroup>

<Note>
  Aborting the local stream in JavaScript stops iteration cleanly and releases the underlying reader, but it does **not** stop the turn on the server by itself — the work keeps running and keeps metering until you call `cancel()`. Always pair a local abort with a server-side `cancel()` when you want the turn to actually stop.
</Note>

## App streaming vs SDK streaming

Both the ModuleX app and the SDKs consume the same backend stream and the same frame types, but the transport mechanics differ.

|            | In the app                                                                                                                                                              | From the SDKs                                                                         |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Transport  | Browser `EventSource` through a Next.js proxy route                                                                                                                     | `fetch`-based reader (JavaScript) / `httpx` SSE (Python)                              |
| Auth       | The proxy injects `Authorization` + `X-Organization-ID`; the browser passes the org id as an `organization_id` **query param** because `EventSource` cannot set headers | You send `Authorization: Bearer mx_live_…` + `X-Organization-ID` **headers** directly |
| Reconnect  | Retries the same `run_id` with `1s, 2s, 4s` backoff, up to 3 attempts, de-duplicating replayed frames by `tool_call_id`                                                 | No auto-reconnect — you reconnect and de-duplicate yourself                           |
| Heartbeats | Treated as no-op keepalives                                                                                                                                             | Python filters them; JavaScript yields them for you to ignore                         |
| Rendering  | Frames drive live UI: streaming text, tool-call chips, an approval card on a paused turn                                                                                | You receive raw typed frames and do what you want with them                           |

<MediaEmbed id="MX-MEDIA-3260" type="app_video" caption={"A single Assistant turn streaming live in the app, with the matching wire frames shown alongside."} />

The app's chat experience is covered in [chat overview](/platform/chat/overview); the proxy and reconnection behavior described here is an implementation detail you do not configure.

## A full worked example: a tool turn from start to finish

The trace below is a complete Assistant turn that calls a discovery tool, calls an integration action, and finishes. Note the data-only frames, the wrapped payloads, and the heartbeat keepalive.

```text theme={null}
data: {"type":"metadata","data":{"run_id":"a1b2c3d4-aaaa-bbbb-cccc-dddddddddddd","thread_id":"f2b1c0de-1111-2222-3333-444455556666","workflow_id":null,"workflow_type":"composer","timestamp":"2026-06-20T10:00:01Z"}}

data: {"type":"response_chunk","data":{"chunk":"Let me look that up."}}

data: {"type":"tool_call","data":{"tool":"get_available_integrations","input":{},"tool_call_id":"tc_1","timestamp":"2026-06-20T10:00:02Z"}}

data: {"type":"tool_result","data":{"tool":"get_available_integrations","tool_call_id":"tc_1","output":{"success":true,"integrations":["github"]},"timestamp":"2026-06-20T10:00:03Z"}}

data: {"type":"heartbeat"}

data: {"type":"tool_call","data":{"tool":"execute_integration_tool","input":{"integration":"github","action":"list_repositories"},"tool_call_id":"tc_2","timestamp":"2026-06-20T10:00:04Z"}}

data: {"type":"tool_result","data":{"tool":"execute_integration_tool","tool_call_id":"tc_2","output":{"status":"ok","repos":["acme/api","acme/web"]},"timestamp":"2026-06-20T10:00:05Z"}}

data: {"type":"response_chunk","data":{"chunk":"Here are your repos: acme/api, acme/web."}}

data: {"type":"done","data":{"response":"Here are your repos: acme/api, acme/web.","has_workflow_changes":false,"tool_calls":[{"tool":"execute_integration_tool","tool_call_id":"tc_2"}],"workflow_tool":null,"usage":{"input_tokens":1200,"output_tokens":340,"total_tokens":1540,"llm_calls":2}}}
```

If the same turn needed a credential you had not connected, the `execute_integration_tool` step would instead produce a `user_input_request` of kind `credential_request`; the stream would go quiet, and you would connect the credential (which auto-resumes via `run_resumed`) or answer with resume. See [using tools](/assistant/using-tools) and [human-in-the-loop](/assistant/human-in-the-loop).

## Credit impact

Streaming a turn does not add cost on top of the turn itself. The turn is charged once when it starts, and language-model token usage is recorded separately as the turn runs — these are the same charges described in [permissions and limits](/assistant/permissions-and-limits) and [credits and metering](/billing/credits). Opening the `listen` stream, receiving frames, reconnecting within the history window, and cancelling do not incur additional run charges. A turn that is cancelled mid-flight still records the language-model tokens it consumed before cancellation.

### When the stream returns a billing denial

A turn is admitted by the billing gate **before** it starts, on `POST /assistant/chat` — not on the `listen` call. If your organization's allowance is exhausted (or a rate or quota limit is hit), that POST is rejected before any run is created, so you never get a `stream_url` to open. The denial uses the flat `DenialEnvelope`:

```json theme={null}
{ "code": "credit_plan_exhausted", "layer": "credit", "key": null, "current": null, "limit": null, "reason": "credit_plan_exhausted" }
```

The `layer` maps to the HTTP status: `rate` is `429` (with `Retry-After` and `X-RateLimit-*` headers), `quota` is `403`, and `credit` or `wallet` is `402`. This is distinct from the ordinary `{"detail": "…"}` shape used by validation and ownership errors — the Assistant endpoints can emit either. For the complete error catalog and all three envelope shapes, see [errors and status codes](/api-reference/errors) and [usage gating and limits](/billing/usage-gating).

## Next steps

<CardGroup cols={2}>
  <Card title="Human-in-the-loop" icon="hand" href="/assistant/human-in-the-loop">
    Answer a paused turn: the question and response kinds, the resume call, and the new-run\_id contract.
  </Card>

  <Card title="Using tools" icon="wrench" href="/assistant/using-tools">
    How the Assistant discovers, calls, and reports on integration tools — the `tool_call` and `tool_result` frames in context.
  </Card>

  <Card title="SSE run streaming" icon="radio" href="/realtime/sse-streaming">
    The transport-level reference for ModuleX run streaming, including workflow runs and the full event taxonomy.
  </Card>

  <Card title="Streaming and HITL in the SDKs" icon="code" href="/sdks/streaming-hitl">
    Consume streams and answer pause-for-input prompts in the JavaScript and Python SDKs.
  </Card>

  <Card title="Permissions and limits" icon="shield" href="/assistant/permissions-and-limits">
    Who can use the Assistant and the billing and usage limits that apply to each turn.
  </Card>

  <Card title="How the Assistant works" icon="cog" href="/assistant/how-it-works">
    The agentic loop behind the frames: how the Assistant reasons, acts, and decides when it is done.
  </Card>
</CardGroup>
