Skip to main content

Human-in-the-Loop

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.
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 for the full matrix.
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. For batch / server-side workflows that auto-approve every change, see Server Integration and pass approval_mode: "approve_all" instead.
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 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

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

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:
{
  "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

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.
Approve a single change:
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:
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:
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.
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.

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:
Operationold_htmlnew_htmlinsert_after_chunk_idMeaning
editCurrent contentProposed replacementModify an existing section
createnullNew content to addSection to insert afterAdd a new section to the document
deleteContent being removednullRemove a section from the document

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 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 collects proposed_change events, builds a DecorationSet keyed by data-chunk-id, and dispatches window-level custom events when the user clicks Approve or Deny.
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. Both the per-block attribute and the <div data-chunk-id> wrapper Node are required — neither alone is sufficient.
// 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:
.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 SSE event in the chat panel, call addProposedChange(view, change).
  • 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 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, each change includes:
  • batch_id — Groups related changes. All changes in a batch share the same batch_id.
  • batch_total — How many changes are in this batch.
If you’re receiving changes via SSE, wait for batch_total events with the same batch_id before showing the review UI. You can then offer “Accept All” and “Deny All” buttons alongside individual change controls. For polling, all changes appear together in the metadata.pending_changes array.

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:
// 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
const pendingChanges = [];

es.addEventListener("proposed_change", (event) => {
  const data = JSON.parse(event.data);
  const change = JSON.parse(data.content);  // content is JSON-stringified
  pendingChanges.push(change);

  // Show the change to the user with a diff view
  console.log(`Change ${change.change_id}: ${change.operation}`);
  console.log(`  Explanation: ${change.ai_explanation}`);
  console.log(`  Old: ${change.old_html}`);
  console.log(`  New: ${change.new_html}`);

  // If batch, wait for all changes before showing review UI
  if (change.batch_total && pendingChanges.length < change.batch_total) {
    return;  // Wait for more changes
  }

  // Show approve/deny UI to the user here
  // When user decides, call the approve endpoint (see step 3 above)
});

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();
});
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 new proposed_change events as the AI tries again.

Complete Python example

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)