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.
How it works
- Start an async chat request to get a
job_id
- Open an SSE connection to stream progress
- 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 6 event types:
Progress updates during AI processing.
event: intermediate
data: {"type": "intermediate", "content": "Analyzing document structure...", "sequence": 1, "timestamp": "2026-03-07T10:00:01Z"}
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. One event is emitted per proposed change.
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
}
| 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. |
batch_id | string | null | Groups related changes. All changes in a batch share the same batch_id. |
batch_total | number | null | How many changes are in this batch. Wait for this many events before showing the review UI. |
insert_after_chunk_id | string | null | For create: where to insert the new section in the document. |
See the Human-in-the-Loop guide for the complete approval workflow.
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.
final
Job completed successfully. Contains the full result.
event: final
data: {"content": "I've made all sections more concise.", "result": {"response": "...", "document_changes": {...}, "usage": {...}}}
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"}
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