> ## Documentation Index
> Fetch the complete documentation index at: https://docs.superdocs.app/llms.txt
> Use this file to discover all available pages before exploring further.

# SSE Streaming

> Stream real-time progress updates from the AI using Server-Sent Events (SSE).

# SSE Streaming

**Quick path:** new to the API? Go from an API key to your first successful call in the [Quickstart](/introduction/quickstart) (\~5 minutes), then come back here to add streaming.

For long-running AI operations, use Server-Sent Events (SSE) to receive real-time progress updates instead of waiting for the full response.

<Note>
  SSE is one option, not the only option. For non-browser consumers (server-side workers, batch jobs, AI agent tools), the synchronous [`/v1/chat`](/examples/curl) endpoint or [polling on `/v1/jobs/{id}`](/guides/async-jobs) are simpler and equally valid. See [Server Integration](/guides/server-integration) for the patterns.
</Note>

<Note>
  **Revert + active SSE.** If your UI lets users [revert a session](/concepts/sessions#revert-a-session-to-a-previous-message) while an SSE stream is still open, close the `EventSource` first (or wait for the `final` event) before sending the revert call. Revert returns `409` while a chat job is in flight on the session, and any updates that arrive after revert from the original branch are stale.
</Note>

## How it works

1. Start an async chat request to get a `job_id`
2. Open an SSE connection to stream progress
3. Receive events as the AI processes your request

## Setup

```bash theme={null}
# 1. Start an async request
curl -X POST https://api.superdocs.app/v1/chat/async \
  -H "Authorization: Bearer sk_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Rewrite all sections to be more concise",
    "session_id": "my-session",
    "document_html": "..."
  }'

# Response: { "job_id": "550e8400-e29b-41d4-a716-446655440000", "session_id": "my-session", ... }

# 2. Stream progress (auth via query parameter)
curl -N "https://api.superdocs.app/v1/chat/my-session/stream?job_id=550e8400-e29b-41d4-a716-446655440000&api_key=sk_YOUR_API_KEY"
```

<Note>
  SSE uses EventSource, which doesn't support custom headers. Pass your API key as the `api_key` query parameter.
</Note>

## Event types

The stream emits nine event types: [`intermediate`](#intermediate), [`proposed_change_batch`](#proposed_change_batch), [`document_sync`](#document_sync), [`continue_prompt`](#continue_prompt), [`documents_changed`](#documents_changed), [`model_fallback`](#model_fallback), [`final`](#final), [`usage`](#usage), and [`error`](#error).

### `intermediate`

Progress updates during AI processing.

```
event: intermediate
data: {"type": "intermediate", "content": "Analyzing document structure...", "sequence": 1, "timestamp": "2026-03-07T10:00:01Z"}
```

#### Rendering intermediate events in your UI

Show every `intermediate` event to users in real time. Operations on large documents (or with `model_tier: "max"` + `thinking_depth: "deep"`) can take 30 seconds to several minutes — without visible progress, your UI looks frozen and indistinguishable from a crash.

**Pattern 1 — Append-and-update an in-flight chat bubble (recommended for chat-style UIs):**

```javascript theme={null} theme={null}
let inFlightBubble = null;

eventSource.addEventListener("intermediate", (event) => {
  const data = JSON.parse(event.data);

  if (!inFlightBubble) {
    // First intermediate of this turn — create a new bubble in the in-flight state.
    inFlightBubble = {
      id: "progress-" + Date.now(),
      role: "assistant",
      content: data.content,
      status: "in_flight",
    };
    chatLog.push(inFlightBubble);
    renderChatLog();
  } else {
    // Subsequent intermediates — replace the bubble's content with the latest update.
    inFlightBubble.content = data.content;
    renderChatLog();
  }
});

eventSource.addEventListener("final", (event) => {
  const data = JSON.parse(event.data);
  if (inFlightBubble) {
    // Promote the in-flight bubble to the final response.
    inFlightBubble.content = data.content;
    inFlightBubble.status = "complete";
    inFlightBubble = null;
  }
  renderChatLog();
});
```

**Pattern 2 — Streaming text indicator (compact toolbar / status bar):**

```javascript theme={null} theme={null}
const statusEl = document.querySelector("#ai-status");
let lastTs = Date.now();

eventSource.addEventListener("intermediate", (event) => {
  const data = JSON.parse(event.data);
  statusEl.textContent = data.content;
  statusEl.classList.add("active");
  lastTs = Date.now();
});

// Surface a "still processing" hint if no event arrives for 30s.
setInterval(() => {
  if (statusEl.classList.contains("active") && Date.now() - lastTs > 30000) {
    statusEl.textContent += " (still working — large operations can take a few minutes)";
  }
}, 5000);

eventSource.addEventListener("final", () => {
  statusEl.classList.remove("active");
  statusEl.textContent = "";
});
```

**Cadence expectations.** Intermediate events typically arrive every 1–5 seconds during active processing. A gap of 30+ seconds usually means the AI is in a deep reasoning phase — surface a "still processing" hint rather than a "failed" indicator. Use the `timestamp` field to detect stalls programmatically.

### `proposed_change_batch`

In HITL mode (`approval_mode: "ask_every_time"`), the AI proposes document changes for review. **`proposed_change_batch` is the only event that carries proposed changes** — every HITL turn arrives through it, whether the AI proposes one change or hundreds. The whole turn comes as **one** SSE event carrying a `changes` array, so the wire load stays proportional to turns, not changes, and HITL UIs can render the entire approval card at once.

<Note>
  Older clients may still register a separate `proposed_change` listener. That's harmless — keep it for safety if you have it — but it never fires: single-change turns are delivered through `proposed_change_batch` too (with `type: "single_approval"`, a `changes` array of length 1). Don't build logic that waits for or branches on a standalone `proposed_change` event; it will never arrive.
</Note>

```
event: proposed_change_batch
data: {"type": "proposed_change_batch", "content": "{\"batch_id\": \"ch_1\", \"batch_total\": 32, \"changes\": [...]}", "sequence": 2}
```

The `content` field is a JSON-stringified string that must be parsed once more:

```javascript theme={null} theme={null}
eventSource.addEventListener("proposed_change_batch", (event) => {
  const data = JSON.parse(event.data);
  const batch = JSON.parse(data.content);
  // batch.batch_id, batch.batch_total, batch.changes[*]
});
```

<Warning>
  `proposed_change_batch.content` is delivered as a **JSON-stringified string**, not a parsed object. The event data itself is JSON, and the `content` field inside it is a second layer of JSON that must be parsed again (see the double-parse above). This is inconsistent with `final.result`, which is already an object after a single parse. Missing the second parse is the single most common reason integrators see empty diff cards — the UI renders but every field reads as `undefined`.
</Warning>

In sessions with multiple open documents, every change in `changes[]` also carries the `document_id` it belongs to (see the field table below), so your UI can group the review list per document.

Edits to page headers, footers, footnote/endnote bodies, and comments arrive through this same event, each as its own ordinary entry in `changes[]` (the part's block is its `chunk_id`). There is no separate event or approval track for them; approve or deny them like any other change.

The parsed payload:

```json theme={null}
{
  "type": "batch_approval",
  "batch_id": "ch_1",
  "batch_total": 32,
  "changes": [
    {
      "change_id": "ch_1",
      "operation": "edit",
      "chunk_id": "550e8400-e29b-41d4-a716-446655440000",
      "old_html": "<p>Original...</p>",
      "new_html": "<p>Updated...</p>",
      "ai_explanation": "Tightened phrasing",
      "insert_after_chunk_id": null
    },
    {
      "change_id": "ch_2",
      "operation": "edit",
      "chunk_id": "660f9511-...",
      "old_html": "...",
      "new_html": "...",
      "ai_explanation": "...",
      "insert_after_chunk_id": null
    }
  ]
}
```

**Top-level payload:**

| Field         | Type   | Description                                                                                                                      |
| ------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `type`        | string | `"batch_approval"` for multi-change turns, `"single_approval"` for one-change turns. Both shapes arrive through this same event. |
| `batch_id`    | string | Shared identifier across the batch — equals the first change's `change_id`.                                                      |
| `batch_total` | number | Number of entries in `changes`.                                                                                                  |
| `changes`     | array  | The proposed changes — one entry per change (see fields below).                                                                  |

**Each entry in `changes[]`:**

| Field                   | Type           | Description                                                                                         |
| ----------------------- | -------------- | --------------------------------------------------------------------------------------------------- |
| `change_id`             | string         | Unique ID for this change. Use when calling `/approve`.                                             |
| `operation`             | string         | `"edit"`, `"create"`, or `"delete"`.                                                                |
| `chunk_id`              | string \| null | The document section being modified or deleted. Null for creates.                                   |
| `old_html`              | string \| null | Current HTML content. Present for updates and deletes. Null for creates.                            |
| `new_html`              | string \| null | Proposed new HTML content. Present for updates and creates. Null for deletes.                       |
| `ai_explanation`        | string         | Why the AI proposed this change. Show this to the user.                                             |
| `insert_after_chunk_id` | string \| null | For `create`: where to insert the new section in the document.                                      |
| `document_id`           | string         | In multi-document sessions, the open document this change belongs to — group the review list by it. |

See the [Human-in-the-Loop guide](/guides/human-in-the-loop) for the complete approval workflow.

### `continue_prompt`

For a very large edit, the AI completes as much as it can in one turn, keeps that work, and asks whether to continue with the rest. This can happen in either approval mode. Resume (or stop) by calling [`POST /v1/chat/{session_id}/continue`](/guides/async-jobs#continuing-a-large-edit).

```
event: continue_prompt
data: {"type": "continue_prompt", "content": "{\"message\": \"I've updated 500 of 864 sections so far. 364 remain. Want me to continue with the rest?\", \"done\": 500, \"total\": 864, \"remaining\": 364}"}
```

The `content` field is a JSON string — parse it for:

* `message` — a ready-to-display prompt, already in the user's language.
* `done` / `total` / `remaining` — progress counts you can show alongside it.

```javascript theme={null} theme={null}
eventSource.addEventListener("continue_prompt", (event) => {
  const payload = JSON.parse(JSON.parse(event.data).content);
  // Show payload.message and a Continue / Stop choice, then POST the decision:
  //   POST /v1/chat/{session_id}/continue  { "job_id": "...", "continue": true }
});
```

Send `continue: true` to keep going or `false` to stop and keep what's done. The job resumes and may emit another `continue_prompt` for the next segment — handle it in a loop, the same way you handle repeated HITL approval rounds.

### `document_sync`

Emitted before the AI begins processing, after the backend has prepared your document for editing. The event carries the prepared HTML containing the section identifiers the AI will reference when proposing changes.

```
event: document_sync
data: {"type": "document_sync", "content": "<p data-chunk-id=\"...\">Section 1</p>...", "focused_document_id": "d1"}
```

**When it fires**: Only when you provide `document_html` in the request. The event arrives once at the start of the stream, before any `intermediate` or `proposed_change_batch` events.

**What to do with it**: Apply the HTML to your editor immediately so the editor's section IDs match the IDs the AI will reference in subsequent `proposed_change_batch` events. This is essential for HITL diff highlights to render correctly on freshly pasted or uploaded documents — without it, the editor and the AI may disagree on which section a change targets.

<Note>
  **Multi-document sessions.** When several documents are open, `document_sync` carries `focused_document_id` — the document this prepared HTML belongs to. Apply it only to the matching (focused) tab, not whichever document happens to be on screen. Single-document sessions can ignore the field.
</Note>

If you do not render diffs in an editor (e.g., you only display the final response), you can ignore this event.

### `documents_changed`

Emitted when a turn touched **more than one open document** on auto-apply, or **created a new document** (creation applies immediately in either approval mode). Tells your UI which documents changed so non-focused tabs can badge and newly created tabs appear — for review-mode *edits*, `proposed_change_batch` already carries per-change `document_id`, so this event isn't needed there.

```
event: documents_changed
data: {"type": "documents_changed", "content": "{\"documents\": [{\"document_id\": \"d1\", \"title\": \"Invoice\", \"change_count\": 3, \"changed_chunk_ids\": [\"c1\", \"c2\", \"c7\"], \"created\": false}], \"focused_document_id\": \"d1\"}"}
```

**Payload shape** (JSON-encoded in `content`):

* `documents[]` — one entry per changed document: `document_id`, `title`, `change_count`, `changed_chunk_ids` (sections to flash on tab switch), and `created` (`true` when the AI opened this as a brand-new document this turn — add a tab for it; its `change_count` is `0`).
* `focused_document_id` — the document currently in focus after the turn.

**When it fires**: on auto-apply (`approval_mode: "approve_all"`) turns that changed 2+ documents, and on any turn — **either approval mode** — where the AI created a new document (creation applies immediately even in review mode). Standard single-document auto-apply edit turns don't emit it (the focused doc flashes inline); it may still fire for multi-document assistant/sub-agent turns. Ignore it if your integration only ever works with one document per session.

### `final`

Job completed successfully. Contains the full result.

```
event: final
data: {"content": "I've made all sections more concise.", "result": {"response": "...", "document_changes": {...}, "usage": {...}}}
```

In multi-document sessions, `result` also includes `focused_document_id` so you know which open document the turn finished on.

### `usage`

Emitted after `final` with usage consumption data.

```
event: usage
data: {"monthly_used": 43, "monthly_limit": 500, "monthly_remaining": 457, "was_billable": true, "subscription_tier": "free"}
```

### `error`

Job failed, was cancelled, or an auth error occurred.

```
event: error
data: {"error": "Request timed out"}
```

### `model_fallback`

Emitted only when the AI tier you requested is temporarily unresponsive upstream and SuperDocs **automatically completed your request on the `pro` tier instead**. Your request still succeeds — this event just tells you (and lets you tell your users) that a different tier served it.

```
event: model_fallback
data: {"type": "model_fallback", "content": "{\"from_tier\": \"core\", \"to_tier\": \"pro\"}"}
```

**What to do with it**: optional. Show a small notice ("the default model is busy — this response used the Pro tier") or just log it. Billing is unchanged — the operation counts exactly as it normally would. During healthy operation this event never fires.

## Reconnect & resume

Every event carries a monotonically increasing `sequence` number. If your EventSource connection drops mid-job, reconnect with the `last_sequence` query parameter set to the highest sequence you already processed — the stream replays **only newer events**, never the full history:

```bash theme={null}
curl -N "https://api.superdocs.app/v1/chat/my-session/stream?job_id=550e8400-e29b-41d4-a716-446655440000&last_sequence=17&api_key=sk_YOUR_API_KEY"
```

Omit `last_sequence` (or pass `0`) to receive the full event history for the job — useful when a fresh client attaches to an already-running job. This also makes reconnecting after `approve_change` safe: already-rendered `proposed_change_batch` / `document_sync` events are not re-delivered.

## JavaScript example

```javascript theme={null}
const eventSource = new EventSource(
  `https://api.superdocs.app/v1/chat/my-session/stream?job_id=${jobId}&api_key=${apiKey}`
);

eventSource.addEventListener("document_sync", (event) => {
  const data = JSON.parse(event.data);
  // Apply data.content to your editor so its section IDs match
  // the IDs the AI will reference in proposed_change_batch events.
  editor.commands.setContent(data.content);
});

eventSource.addEventListener("intermediate", (event) => {
  const data = JSON.parse(event.data);
  console.log("Progress:", data.content);
});

eventSource.addEventListener("final", (event) => {
  const data = JSON.parse(event.data);
  console.log("Done:", data.result.response);
  // Update your editor with data.result.document_changes.updated_html
  eventSource.close();
});

eventSource.addEventListener("usage", (event) => {
  const data = JSON.parse(event.data);
  console.log(`Used ${data.monthly_used}/${data.monthly_limit} operations`);
});

eventSource.addEventListener("error", (event) => {
  if (event.data) {
    const data = JSON.parse(event.data);
    console.error("Error:", data.error);
  }
  eventSource.close();
});
```

## Python example

```python theme={null}
import json
import requests

url = f"https://api.superdocs.app/v1/chat/my-session/stream?job_id={job_id}&api_key={api_key}"

with requests.get(url, stream=True) as response:
    for line in response.iter_lines():
        if not line:
            continue
        decoded = line.decode("utf-8")
        if decoded.startswith("data: "):
            data = json.loads(decoded[6:])
            if "content" in data:
                print(f"Progress: {data['content']}")
            elif "error" in data:
                print(f"Error: {data['error']}")
                break
```
