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

# Human-in-the-Loop

> Review and approve AI-proposed document changes before they're applied, using the HITL approval workflow.

# Human-in-the-Loop

**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 for the approval workflow.

By default, the AI applies changes immediately. Set `approval_mode` to `"ask_every_time"` to review changes before they take effect.

HITL requires the async workflow (`/v1/chat/async`) because the job pauses to wait for your approval.

<Tip>
  **Maximum precision on high-stakes documents** (contracts, regulatory filings, compliance language) — set `model_tier: "max"` in your request body. The default `core` tier is fast and accurate for everyday edits, but `max` gives you the most capable model for nuanced edits where one wrong word matters. See [Model Selection](/guides/model-selection) for the full matrix.
</Tip>

<Note>
  This guide covers **UI-driven approval** (a human reviewing in your product's interface). If your AI agent is the one deciding whether to approve proposed changes (no human in the loop), see [Agent Tool Integration → Approval modes](/guides/agent-tool-integration#approval-modes-choose-based-on-who-decides). For batch / server-side workflows that auto-approve every change, see [Server Integration](/guides/server-integration) and pass `approval_mode: "approve_all"` instead.
</Note>

<Note>
  **Revert vs. pending approval.** While a session is `awaiting_approval` (one or more proposed changes pending review), calling [revert](/concepts/sessions#revert-a-session-to-a-previous-message) returns `409`. Resolve the pending approvals first — accept or deny each change, or cancel the in-flight job via `POST /v1/jobs/{job_id}/cancel` — then retry revert.
</Note>

## Recommended UX pattern

**Default your UI to auto-apply; expose Review Mode as an opt-in toggle.**

The simplest, least-friction integration is to send `approval_mode: "approve_all"` by default and put a small in-UI toggle — one control, clearly labelled — that the user can flip on when they want to review each change before it lands. When the toggle is on, your code switches to `approval_mode: "ask_every_time"` and renders the proposed-change UI (chat-side card, inline editor overlay, or native track-changes — see below).

This is the pattern the SuperDocs web app at [use.superdocs.app](https://use.superdocs.app) uses, and it's what most users expect:

* **Auto-apply on by default** — for 90% of edits (rewording, formatting, small additions), users don't want to approve each change. They want the AI to act, then Cmd-Z if they disagree.
* **Review Mode as an explicit opt-in** — when the user is working on something high-stakes (a contract clause, a regulatory filing, a legal letter) they flip the toggle ON and approve each change explicitly. Persist the toggle state in `localStorage` so it survives page reload.
* **No hidden state** — both modes should be plainly visible in the UI. Don't bury Review Mode in a settings modal.

A common concrete shape: a two-segment slider or segmented control above the chat input, with "Auto Approve" on one end and "Review Mode" on the other. The active segment is filled, the inactive segment is muted. One glance tells the user which mode they're in.

Going straight to `ask_every_time` without a toggle is valid for high-stakes products (contracts, medical records, court filings) where every change must be reviewed, but expect higher friction in casual editing flows. If in doubt, start with auto-apply by default and a toggle — you can always reverse it later.

## End-to-end workflow

### 1. Send a request with approval mode

```bash theme={null}
curl -X POST https://api.superdocs.app/v1/chat/async \
  -H "Authorization: Bearer sk_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Update section 3 to include GDPR compliance language",
    "session_id": "contract-review",
    "document_html": "...",
    "approval_mode": "ask_every_time"
  }'
```

### 2. Poll for approval status

```bash theme={null}
curl https://api.superdocs.app/v1/jobs/JOB_ID \
  -H "Authorization: Bearer sk_YOUR_API_KEY"
```

When `status` is `"awaiting_approval"`, check `metadata.pending_changes`:

<Warning>
  **`awaiting_approval` has two flavours — continue vs. approve.** The same status is also used for the [large-edit continue prompt](/guides/async-jobs#continuing-a-large-edit), where a big edit applies as much as it can, keeps that work, and pauses to ask whether to keep going. Tell them apart with `metadata.awaiting_kind`:

  * `"continue_prompt"` → a large edit paused. There are **no** `pending_changes` (the SSE side sent a [`continue_prompt`](/guides/streaming#continue_prompt) event, not `proposed_change_batch`). Resume with `POST /v1/chat/{session_id}/continue` (the `continue_chat` tool over MCP), passing `{ "job_id": "...", "continue": true }` to keep going or `false` to stop and keep what's done.
  * otherwise → a HITL change review. Read `metadata.pending_changes` and respond with `/approve`.

  Calling `/approve` on a `continue_prompt` pause is rejected with `409` (and vice-versa) — always branch on `awaiting_kind` first.
</Warning>

```json theme={null}
{
  "status": "awaiting_approval",
  "metadata": {
    "pending_changes": [
      {
        "change_id": "ch_1",
        "operation": "edit",
        "chunk_id": "550e8400-e29b-41d4-a716-446655440000",
        "old_html": "<p>Original section 3 content...</p>",
        "new_html": "<p>Updated content with GDPR compliance...</p>",
        "ai_explanation": "Added GDPR data processing requirements"
      }
    ]
  }
}
```

### 3. Approve or deny changes

<Warning>
  **The `approved` field is required at the top level of every approve request — including batch shapes.** A common integration trap is to send `{ "job_id": "...", "changes": [...] }` for a batch decision, omitting top-level `approved`. The endpoint rejects this with a generic `422` because the top-level `approved` is required by the request schema. The top-level value acts as the default for any change inside `changes` that does not specify its own `approved`. If every entry inside `changes` carries its own `approved`, the top-level value is unused but still required — set it to `true` or `false`, it doesn't matter which.

  **Correct shapes (one of these three) — all carry top-level `approved`:**

  * **Single change:** `{ "job_id": "...", "change_id": "...", "approved": true }`
  * **Batch — same decision for all:** `{ "job_id": "...", "approved": true, "changes": [{"change_id": "ch_1"}, {"change_id": "ch_2"}] }`
  * **Batch — per-change decisions:** `{ "job_id": "...", "approved": true, "changes": [{"change_id": "ch_1", "approved": true}, {"change_id": "ch_2", "approved": false}] }`

  **Incorrect — missing top-level `approved`:** `{ "job_id": "...", "changes": [...] }` → `422 Unprocessable Entity`.
</Warning>

**Approve a single change:**

```bash theme={null}
curl -X POST https://api.superdocs.app/v1/chat/contract-review/approve \
  -H "Authorization: Bearer sk_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "job_id": "JOB_ID",
    "change_id": "ch_1",
    "approved": true
  }'
```

**Approve all changes at once:**

```bash theme={null}
curl -X POST https://api.superdocs.app/v1/chat/contract-review/approve \
  -H "Authorization: Bearer sk_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "job_id": "JOB_ID",
    "approved": true,
    "changes": [
      {"change_id": "ch_1", "approved": true},
      {"change_id": "ch_2", "approved": false}
    ]
  }'
```

**Deny with feedback:**

```bash theme={null}
curl -X POST https://api.superdocs.app/v1/chat/contract-review/approve \
  -H "Authorization: Bearer sk_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "job_id": "JOB_ID",
    "change_id": "ch_1",
    "approved": false,
    "feedback": "Keep the original language but add a GDPR reference link"
  }'
```

### 4. Continue polling

After approval, the job resumes processing. Poll until `status` is `"completed"` to get the final result.

<Note>
  If you deny a change with feedback, the AI receives your feedback and may propose a revised change. Your polling loop should handle multiple rounds of `awaiting_approval` — not just one.
</Note>

## Understanding proposed changes

### Operation types

Each proposed change has an `operation` field that determines what the AI wants to do and which fields are populated:

| Operation | `old_html`            | `new_html`           | `insert_after_chunk_id` | Meaning                            |
| --------- | --------------------- | -------------------- | ----------------------- | ---------------------------------- |
| `edit`    | Current content       | Proposed replacement | —                       | Modify an existing section         |
| `create`  | null                  | New content to add   | Section to insert after | Add a new section to the document  |
| `delete`  | Content being removed | null                 | —                       | Remove a section from the document |

Proposed edits to page headers, footers, footnote/endnote bodies, and comments use these same shapes: each arrives as an ordinary entry (its `chunk_id` targets the part's block) in the same batch as any body edits from the turn, so one turn can propose a body change and a header change and the user approves each independently. There is no separate approval track for them.

### Building a diff view

To show users what the AI wants to change, compare `old_html` and `new_html`:

* **For `edit`**: Use a diff library (like `diff-match-patch` or `jsdiff`) to highlight additions and removals between `old_html` and `new_html`. Show a before/after view or inline diff.
* **For `create`**: Display `new_html` with a visual indicator that this is a new section being added (e.g., a green border or "New section" label). The `insert_after_chunk_id` value corresponds to a `data-chunk-id` attribute on an element in the document HTML — find that element and insert the new content after it.
* **For `delete`**: Display `old_html` with a visual indicator that this section will be removed (e.g., red strikethrough or "Will be deleted" label).

Always display the `ai_explanation` alongside the diff — it tells the user why the AI proposed the change.

### Rendering diffs inline in your editor

Three patterns for where the diff appears, in order of visual weight:

#### Pattern 1 — Side-by-side card in your chat panel

Render a card per pending change with `old_html` and `new_html` shown as two adjacent panels (red background for old, green background for new) and Approve / Deny buttons underneath. The card lives in your chat sidebar; the editor document is unaffected until the user approves. This is the simplest pattern and works with any editor. See the [JavaScript example](/examples/javascript) for a working HITL flow that produces this UI.

#### Pattern 2 — Inline overlay in the editor (Cursor-style)

The proposed edit appears **directly inside the document** — the affected block gets a coloured outline, the word-level diff renders inside it (red strikethrough for removed, green highlight for added), and Approve / Deny buttons float in the top-right corner of the block. This is the most accurate visual representation of what the AI wants to change and is the pattern most developer-audience apps converge on.

For ProseMirror-based editors (ProseMirror, TipTap, BlockNote, Remirror, Atlaskit), use the editor's native decoration system. The plugin below takes individual change objects (the entries of a `proposed_change_batch` event's `changes` array), builds a `DecorationSet` keyed by `data-chunk-id`, and dispatches `window`-level custom events when the user clicks Approve or Deny.

<Warning>
  **Schema prerequisite — read first.** The plugin below locates the target block by walking the editor doc and matching `node.attrs["data-chunk-id"]`. If your schema does not preserve `data-chunk-id` on every block node **and** does not register a wrapper Node for `<div data-chunk-id="…">…</div>` elements (used when a chunk spans multiple blocks), the plugin will compile, run, and silently render nothing — `findChunkRange` returns `null` and the decoration is skipped. This is the single most common reason an inline overlay implementation appears broken: the diff event arrives, `addProposedChange` fires, but the editor never lights up.

  Set up the schema first via [Editor Integration](/guides/editor-integration). Both the per-block attribute *and* the `<div data-chunk-id>` wrapper Node are required — neither alone is sufficient.
</Warning>

```typescript theme={null} theme={null}
// proposed-change-decoration.ts
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import type { Node as ProseMirrorNode } from "prosemirror-model";

export type ProposedChange = {
  change_id: string;
  operation: "edit" | "create" | "delete";
  chunk_id: string | null;
  old_html: string | null;
  new_html: string | null;
  ai_explanation: string;
  insert_after_chunk_id: string | null;
};

const META_ADD = "proposedChangeAdd";
const META_REMOVE = "proposedChangeRemove";
const META_CLEAR = "proposedChangeClear";

export const proposedChangeKey = new PluginKey("proposedChangeOverlay");

type State = {
  decorations: DecorationSet;
  pending: Map<string, ProposedChange>;
};

function findChunkRange(doc: ProseMirrorNode, chunkId: string) {
  let hit: { from: number; to: number } | null = null;
  doc.descendants((node, pos) => {
    if (hit) return false;
    if (node.attrs?.["data-chunk-id"] === chunkId) {
      hit = { from: pos, to: pos + node.nodeSize };
      return false;
    }
    return true;
  });
  return hit;
}

function stripHtml(html: string) {
  const withSpaces = html.replace(/<\/(p|div|li|h[1-6]|tr|td|th)>/gi, " ");
  const tmp = document.createElement("div");
  tmp.innerHTML = withSpaces;
  return (tmp.textContent ?? "").replace(/\s+/g, " ").trim();
}

function escapeHtml(s: string) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

// Word-level LCS diff → red/green HTML.
function wordDiff(oldText: string, newText: string) {
  const A = oldText.split(/(\s+)/);
  const B = newText.split(/(\s+)/);
  const m = A.length, n = B.length;
  const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  for (let i = 1; i <= m; i++)
    for (let j = 1; j <= n; j++)
      dp[i][j] = A[i - 1] === B[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
  const parts: { t: "same" | "del" | "add"; s: string }[] = [];
  let i = m, j = n;
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && A[i - 1] === B[j - 1]) { parts.unshift({ t: "same", s: A[i - 1] }); i--; j--; }
    else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { parts.unshift({ t: "add", s: B[j - 1] }); j--; }
    else { parts.unshift({ t: "del", s: A[i - 1] }); i--; }
  }
  return parts.map(p => {
    const e = escapeHtml(p.s);
    if (p.t === "del") return `<span class="diff-word-deleted">${e}</span>`;
    if (p.t === "add") return `<span class="diff-word-added">${e}</span>`;
    return e;
  }).join("");
}

function controls(change: ProposedChange): HTMLDivElement {
  const wrap = document.createElement("div");
  wrap.className = "inline-diff-controls";
  wrap.contentEditable = "false";
  for (const action of ["accept", "deny"] as const) {
    const btn = document.createElement("button");
    btn.className = `inline-diff-btn inline-diff-btn--${action}`;
    btn.textContent = action === "accept" ? "\u2713" : "\u2717";
    btn.addEventListener("mousedown", e => { e.preventDefault(); e.stopPropagation(); });
    btn.addEventListener("click", e => {
      e.preventDefault(); e.stopPropagation();
      window.dispatchEvent(new CustomEvent("diff-action", {
        detail: { action, change_id: change.change_id },
      }));
    });
    wrap.appendChild(btn);
  }
  return wrap;
}

function editWidget(pos: number, change: ProposedChange): Decoration {
  return Decoration.widget(pos, () => {
    const wrap = document.createElement("div");
    wrap.className = "diff-edit-wrapper";
    wrap.contentEditable = "false";
    wrap.setAttribute("data-diff-widget", "true");
    wrap.appendChild(controls(change));
    const preview = document.createElement("div");
    preview.className = "diff-preview-content";
    preview.innerHTML = wordDiff(stripHtml(change.old_html ?? ""), stripHtml(change.new_html ?? ""));
    wrap.appendChild(preview);
    return wrap;
  }, { side: -1, key: `diff-edit-${change.change_id}` });
}

function ghostCreateWidget(pos: number, change: ProposedChange): Decoration {
  return Decoration.widget(pos, () => {
    const ghost = document.createElement("div");
    ghost.className = "diff-ghost-create";
    ghost.contentEditable = "false";
    ghost.appendChild(controls(change));
    const body = document.createElement("div");
    body.className = "diff-ghost-content";
    body.innerHTML = change.new_html ?? "<p><em>New section</em></p>";
    ghost.appendChild(body);
    return ghost;
  }, { side: 1, key: `diff-ghost-${change.change_id}` });
}

function build(doc: ProseMirrorNode, pending: Map<string, ProposedChange>): DecorationSet {
  const out: Decoration[] = [];
  for (const change of pending.values()) {
    if (change.operation === "create") {
      if (!change.insert_after_chunk_id) continue;
      const after = findChunkRange(doc, change.insert_after_chunk_id);
      if (after) out.push(ghostCreateWidget(after.to, change));
      continue;
    }
    if (!change.chunk_id) continue;
    const range = findChunkRange(doc, change.chunk_id);
    if (!range) continue;
    if (change.operation === "edit") {
      const hasPreview = !!(change.old_html && change.new_html);
      out.push(Decoration.node(range.from, range.to, {
        class: hasPreview ? "diff-chunk-edit diff-chunk-has-preview" : "diff-chunk-edit",
      }));
      if (hasPreview) out.push(editWidget(range.from + 1, change));
    } else if (change.operation === "delete") {
      out.push(Decoration.node(range.from, range.to, { class: "diff-chunk-delete" }));
    }
  }
  return DecorationSet.create(doc, out);
}

export function proposedChangeDecoration(): Plugin<State> {
  return new Plugin<State>({
    key: proposedChangeKey,
    state: {
      init() { return { decorations: DecorationSet.empty, pending: new Map() }; },
      apply(tr: Transaction, value: State): State {
        let pending = value.pending;
        const add = tr.getMeta(META_ADD) as ProposedChange | undefined;
        const remove = tr.getMeta(META_REMOVE) as string | undefined;
        const clear = tr.getMeta(META_CLEAR) as boolean | undefined;
        if (clear) return { decorations: DecorationSet.empty, pending: new Map() };
        if (add) { pending = new Map(pending); pending.set(add.change_id, add); }
        if (remove) { pending = new Map(pending); pending.delete(remove); }
        if (add || remove) return { decorations: build(tr.doc, pending), pending };
        if (tr.docChanged) return { decorations: value.decorations.map(tr.mapping, tr.doc), pending };
        return value;
      },
    },
    props: { decorations(state) { return proposedChangeKey.getState(state)?.decorations ?? DecorationSet.empty; } },
  });
}

export const addProposedChange = (view: EditorView, change: ProposedChange) =>
  view.dispatch(view.state.tr.setMeta(META_ADD, change));
export const removeProposedChange = (view: EditorView, change_id: string) =>
  view.dispatch(view.state.tr.setMeta(META_REMOVE, change_id));
export const clearProposedChanges = (view: EditorView) =>
  view.dispatch(view.state.tr.setMeta(META_CLEAR, true));
```

Supporting CSS:

```css theme={null} theme={null}
.diff-chunk-edit {
  outline: 2px solid #4285f4; outline-offset: -1px;
  background: rgba(66, 133, 244, 0.06); border-radius: 4px; position: relative;
}
.diff-chunk-has-preview > *:not([data-diff-widget]) { display: none !important; }
.diff-chunk-delete {
  outline: 2px solid #dc3545; outline-offset: -1px;
  background: rgba(220, 53, 69, 0.06); border-radius: 4px; position: relative;
}
.diff-chunk-delete > *:not([data-diff-widget]) {
  text-decoration: line-through; text-decoration-color: rgba(220, 53, 69, 0.5); opacity: 0.65;
}
.inline-diff-controls {
  position: absolute; top: 4px; right: 4px; display: flex; gap: 4px;
  background: white; border: 1px solid #e0e0e0; border-radius: 6px;
  padding: 3px 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); z-index: 50; user-select: none;
}
.inline-diff-btn {
  width: 32px; height: 32px; border: none; border-radius: 4px;
  font-size: 18px; font-weight: 700; cursor: pointer;
  display: flex; align-items: center; justify-content: center; line-height: 1; padding: 0;
}
.inline-diff-btn--accept { background: #e6ffed; color: #28a745; }
.inline-diff-btn--accept:hover { background: #28a745; color: white; }
.inline-diff-btn--deny { background: #ffeef0; color: #dc3545; }
.inline-diff-btn--deny:hover { background: #dc3545; color: white; }
.diff-edit-wrapper { position: relative; padding: 44px 8px 8px 8px; }
.diff-preview-content { line-height: 1.6; white-space: pre-wrap; word-wrap: break-word; }
.diff-word-deleted {
  background: rgba(220, 53, 69, 0.15); color: #b71c1c;
  text-decoration: line-through; text-decoration-color: #dc3545;
  padding: 1px 2px; border-radius: 2px;
}
.diff-word-added {
  background: rgba(40, 167, 69, 0.15); color: #1b5e20;
  text-decoration: underline; text-decoration-color: #28a745;
  padding: 1px 2px; border-radius: 2px;
}
.diff-ghost-create {
  position: relative; border: 2px dashed #28a745; background: rgba(40, 167, 69, 0.04);
  border-radius: 6px; padding: 44px 8px 8px 8px; margin: 8px 0;
}
.diff-ghost-content { opacity: 0.75; line-height: 1.6; }
```

Wire the plugin into your editor and the chat panel:

* Register `proposedChangeDecoration()` in the plugin array when you build `EditorState`.
* On every `proposed_change_batch` SSE event in the chat panel, loop over the parsed `changes` array and call `addProposedChange(view, change)` for each one.
* Subscribe to `window.addEventListener("diff-action", ...)` in the chat panel to catch Accept / Deny clicks. Post the decision to `/v1/chat/{session_id}/approve` and call `removeProposedChange(view, change_id)`.
* On every `final` SSE event, call `clearProposedChanges(view)` before applying the new HTML — the editor is about to re-render anyway.

The pattern transfers to TipTap, BlockNote, Remirror, and Atlaskit with minor API-surface tweaks — they all expose the same underlying decoration system.

#### Pattern 3 — Native track-changes UI

If your editor already supports track-changes (CKEditor 5 with the Track Changes feature, TinyMCE Premium, etc.), map each proposed change (each entry of the `proposed_change_batch` event's `changes` array) to a tracked suggestion in the editor's native model. The user then approves or rejects via the editor's built-in UI. Consult your editor's track-changes API docs for the specific mapping — the SuperDocs side is identical to Pattern 1 or 2.

### Batch changes

When the AI proposes multiple changes at once (common for sweeping edits across many sections), every change in the turn arrives together. The shape depends on whether you're polling or streaming:

* **Polling**: all changes appear in the `metadata.pending_changes` array on a single `/v1/jobs/{job_id}` poll. Each change carries `batch_id` (the first change's `change_id`) and `batch_total` (the count) for convenience.
* **Streaming (recommended)**: a single `proposed_change_batch` SSE event carries the full batch as a `changes` array. See [Streaming guide → `proposed_change_batch`](/guides/streaming#proposed_change_batch).

Use the batched shape to render one "Accept all" / "Deny all" card without waiting on a fan-out of N events.

### Grouping a batch by document

In multi-document sessions, every change in the batch carries the `document_id` it belongs to. Group the review list per document (one sub-section per document, with its own accept-all control) — a flat list of 40 changes spanning three documents is hard to reason about, the same 40 grouped under three document headers is easy. Note that a turn can also **create** a new document even in review mode (creation applies immediately; you'll see it via the `documents_changed` event) — see [SSE Streaming](/guides/streaming#documents_changed).

### Very large batches

A whole-document instruction ("tighten every section") can propose hundreds of changes in one batch. Two practical rules from running this at scale: **virtualize the card list** (render only visible rows — hundreds of side-by-side HTML diffs in the DOM at once will lock the tab), and lead with the summary ("312 changes across 5 sections types — Accept all / Review one by one") rather than dropping the user straight into row 1 of 312.

## Recovering pending approvals after a reload

A pending review survives your client. If the user reloads the page (or your process restarts) while a job is `awaiting_approval`, the proposal is not lost, and the job keeps blocking new turns on the session until it's resolved (the server auto-denies unattended reviews after about an hour). To restore the review UI on load:

1. List the session's jobs with `GET /v1/sessions/{session_id}/jobs` (MCP: `get_session_jobs`).
2. Find the job with `status: "awaiting_approval"` and branch on `metadata.awaiting_kind` first: a `continue_prompt` pause is resumed with `/continue`, not `/approve` (see the [flavours warning](#2-poll-for-approval-status) above).
3. Rebuild the review list from `metadata.pending_changes`, exactly as you would from a `proposed_change_batch` event.
4. Skip entries already decided before the reload: `metadata.pending_batch_decisions` maps each decided `change_id` to `{ "approved": true|false, "feedback": ... }`. Render only the undecided remainder, and submit those decisions through the same `/approve` calls as usual.

## Alternative: SSE streaming workflow

The polling workflow above works but requires repeated API calls. For a real-time UI, use SSE to receive proposed changes as they're generated:

<Note>
  **B2B / server-side integrators (organization `lce_` keys)** that can't hold an `EventSource` open — serverless functions, batch workers, agent runtimes — can long-poll instead of streaming. `GET /v1/poll/{session_id}?since=<ISO-8601>&timeout=<seconds>` (org keys only; `timeout` ≤ 1800s) holds the connection open until there's a job update on the session, then returns it, so you get near-real-time progress without an SSE socket. Pass the last `since` timestamp on each call to avoid duplicate updates. Plain `/v1/jobs/{job_id}` polling (shown above) also works and is the simplest option.
</Note>

```javascript theme={null}
// 1. Start async request with approval mode
const response = await fetch("https://api.superdocs.app/v1/chat/async", {
  method: "POST",
  headers: { "Authorization": "Bearer sk_YOUR_API_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({
    message: "Rewrite the liability section",
    session_id: "contract-review",
    document_html: "...",
    approval_mode: "ask_every_time"
  })
});
const { job_id } = await response.json();

// 2. Open SSE connection
const es = new EventSource(
  `https://api.superdocs.app/v1/chat/contract-review/stream?job_id=${job_id}&api_key=sk_YOUR_API_KEY`
);

// 3. Collect proposed changes. Every HITL turn — one change or hundreds —
//    arrives as a single `proposed_change_batch` event carrying a
//    `changes` array (a one-change turn has type "single_approval").
let pendingChanges = [];

const showReviewUi = (changes) => {
  // Render approve/deny UI here with the full batch.
  // When the user decides, call POST /v1/chat/{session_id}/approve
  // with the change_id list and the decision.
  console.log(`Reviewing ${changes.length} change(s)`);
  for (const change of changes) {
    console.log(`  ${change.change_id}: ${change.operation} — ${change.ai_explanation}`);
  }
};

es.addEventListener("proposed_change_batch", (event) => {
  const data = JSON.parse(event.data);
  const batch = JSON.parse(data.content);   // { type, batch_id, batch_total, changes }
  pendingChanges = batch.changes;
  showReviewUi(pendingChanges);
});

// Optional safety net for older clients: a standalone `proposed_change`
// listener is harmless but never fires — single changes come through
// `proposed_change_batch` too. You don't need this if starting fresh.

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

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

<Note>
  After calling `/approve`, the AI resumes processing. If approved changes were applied, the SSE connection will eventually emit a `final` event with the updated document. If you denied with feedback, you may receive a new `proposed_change_batch` event as the AI tries again.

  <Warning>
    **Apply changes once — either per-accept or from the final, never both.** If your UI applies each approved change to the editor as the user accepts it, do NOT also load the `final` event's full document — it contains those same changes again, and re-applying them duplicates inserted content (newly created sections are the classic symptom). Pick one: incremental apply (ignore the final document) or final-only apply (editor untouched during review).
  </Warning>
</Note>

<Warning>
  **Subscribe to `proposed_change_batch`.** It is the only event that delivers proposed changes — one change or hundreds, it always arrives as a single `proposed_change_batch` event carrying a `changes` array (a one-change turn has `type: "single_approval"`). A standalone `proposed_change` listener never fires; keep one only as a harmless safety net for older clients, but don't make your review flow depend on it.
</Warning>

## Complete Python example

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

API_KEY = "sk_YOUR_API_KEY"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
BASE = "https://api.superdocs.app"

# 1. Start with approval mode
response = requests.post(f"{BASE}/v1/chat/async", headers=HEADERS, json={
    "message": "Rewrite the liability section",
    "session_id": "contract-review",
    "document_html": "...",
    "approval_mode": "ask_every_time"
})
job_id = response.json()["job_id"]

# 2. Poll and handle approvals
while True:
    job = requests.get(f"{BASE}/v1/jobs/{job_id}", headers=HEADERS).json()

    if job["status"] == "completed":
        print("Done:", job["result"]["response"])
        break

    elif job["status"] == "failed":
        print("Error:", job["error"])
        break

    elif job["status"] == "awaiting_approval":
        changes = job["metadata"]["pending_changes"]
        for change in changes:
            print(f"\nChange {change['change_id']}:")
            print(f"  Operation: {change['operation']}")
            print(f"  Explanation: {change.get('ai_explanation', 'N/A')}")
            print(f"  Old: {change.get('old_html', 'N/A')[:100]}")
            print(f"  New: {change.get('new_html', 'N/A')[:100]}")

        # Approve all changes
        requests.post(f"{BASE}/v1/chat/contract-review/approve", headers=HEADERS, json={
            "job_id": job_id,
            "approved": True,
            "changes": [{"change_id": c["change_id"], "approved": True} for c in changes]
        })
        print("\nApproved all changes, continuing...")

    time.sleep(2)
```
