Skip to main content

SSE Streaming

For long-running AI operations, use Server-Sent Events (SSE) to receive real-time progress updates instead of waiting for the full response.
SSE is one option, not the only option. For non-browser consumers (server-side workers, batch jobs, AI agent tools), the synchronous /v1/chat endpoint or polling on /v1/jobs/{id} are simpler and equally valid. See Server Integration for the patterns.
Revert + active SSE. If your UI lets users revert a session 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.

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

# 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": "job_abc123", "session_id": "my-session", ... }

# 2. Stream progress (auth via query parameter)
curl -N "https://api.superdocs.app/v1/chat/my-session/stream?job_id=job_abc123&api_key=sk_YOUR_API_KEY"
SSE uses EventSource, which doesn’t support custom headers. Pass your API key as the api_key query parameter.

Event types

The stream emits 11 event types:

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):
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):
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

In HITL mode (approval_mode: "ask_every_time"), the AI proposes a document change for review. Single, isolated changes still arrive as one proposed_change event; multi-change turns are delivered via proposed_change_batch instead (a single event carrying every change in the batch).
event: proposed_change
data: {"type": "proposed_change", "content": "{\"change_id\": \"ch_1\", ...}", "sequence": 2}
proposed_change.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. In JavaScript:
const data = JSON.parse(event.data);          // first parse — event envelope
const change = JSON.parse(data.content);      // second parse — change details
This is inconsistent with final.result, which is already an object after a single parse. Until this normalises in a future API version, double-parse proposed_change.content. Missing the second parse is the single most common reason integrators see empty diff cards — the UI renders but every field reads as undefined.
The content field is a JSON string. Parse it to get the change details:
{
  "change_id": "ch_1",
  "operation": "edit",
  "chunk_id": "550e8400-e29b-41d4-a716-446655440000",
  "old_html": "<p>Original section content...</p>",
  "new_html": "<p>Updated content with GDPR compliance...</p>",
  "ai_explanation": "Added GDPR data processing requirements",
  "batch_id": "ch_1",
  "batch_total": 3,
  "insert_after_chunk_id": null
}
FieldTypeDescription
change_idstringUnique ID for this change. Use when calling /approve.
operationstring"edit", "create", or "delete".
chunk_idstring | nullThe document section being modified or deleted. Null for creates.
old_htmlstring | nullCurrent HTML content. Present for updates and deletes. Null for creates.
new_htmlstring | nullProposed new HTML content. Present for updates and creates. Null for deletes.
ai_explanationstringWhy the AI proposed this change. Show this to the user.
batch_idstring | nullGroups related changes. All changes in a batch share the same batch_id.
batch_totalnumber | nullHow many changes are in this batch. Wait for this many events before showing the review UI.
insert_after_chunk_idstring | nullFor create: where to insert the new section in the document.
See the Human-in-the-Loop guide for the complete approval workflow.

proposed_change_batch

When the AI proposes multiple changes in a single turn (common for sweeping edits across many sections of a document), the entire batch arrives as one SSE event instead of a fan-out of N proposed_change events. This keeps the wire load proportional to turns, not changes, and lets HITL UIs render the whole approval card at once.
event: proposed_change_batch
data: {"type": "proposed_change_batch", "content": "{\"batch_id\": \"ch_1\", \"batch_total\": 32, \"changes\": [...]}", "sequence": 2}
Like proposed_change.content, the content field is a JSON-stringified string that must be parsed once more:
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[*]
});
In sessions with multiple open documents, every change in changes[] also carries the document_id it belongs to, so your UI can group the review list per document. The parsed payload:
{
  "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
    }
  ]
}
FieldTypeDescription
typestring"batch_approval" for multi-change turns, "single_approval" for one-change turns delivered through this event for uniformity.
batch_idstringShared identifier across the batch — equals the first change’s change_id.
batch_totalnumberNumber of entries in changes.
changesarrayPer-change objects with the same fields as proposed_change (change_id, operation, chunk_id, old_html, new_html, ai_explanation, insert_after_chunk_id).
Register both listeners. If your client only subscribes to proposed_change, you will silently miss every batched HITL turn (the common path for multi-section edits). The two events are mutually exclusive within a single turn — one or the other fires, not both — but you need both listeners registered to cover both cases.

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.
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.
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 proposed_change 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>..."}
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 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 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. 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). Single-document edit turns don’t emit it. 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"}

ui_pointer

Side-channel hint that the user’s last message expressed intent for a specific UI affordance — most commonly export / download / “open in Word”. Use it to surface the relevant button in your UI (e.g., glow the Export button) instead of having the AI describe its location in prose. Fires alongside final, not before it.
event: ui_pointer
data: {"type": "ui_pointer", "ui_pointer": {"target": "export_button", "reason": "User asked how to download the document"}}
Payload shape:
  • target — the affordance the user wants. Current values: export_button. Treat as forward-compatible — new targets may be added without changing the event-type contract; ignore values you don’t recognise.
  • reason — short human-readable note describing the inferred intent. Surface in telemetry; never to end users.
When it fires: only when the AI recognises a UI-affordance intent in the user’s message. Skipped for content-edit turns and for ambiguous “where’s my file” queries (those get handled in the final content). Multilingual — recognition is intent-based, not keyword-based. Rendering pattern (recommended):
eventSource.addEventListener("ui_pointer", (event) => {
  const data = JSON.parse(event.data);
  const target = data.ui_pointer?.target;
  if (target === "export_button") {
    // Briefly highlight your Export button so the user can find it.
    document.querySelector("#export-button")?.classList.add("glow");
    setTimeout(() => {
      document.querySelector("#export-button")?.classList.remove("glow");
    }, 3000);
  }
  // Unknown targets: ignore silently. The `final` event's content
  // already covers the affordance in prose as a safety net.
});
Don’t show end users the raw reason text — it’s a trace string for your telemetry, not a user-facing message.

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:
curl -N "https://api.superdocs.app/v1/chat/my-session/stream?job_id=job_abc123&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

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

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