Skip to main content

Human-in-the-Loop

Quick path: new to the API? Go from an API key to your first successful call in the 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.
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.
Revert vs. pending approval. While a session is awaiting_approval (one or more proposed changes pending review), calling revert 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.
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

2. Poll for approval status

When status is "awaiting_approval", check metadata.pending_changes:
awaiting_approval has two flavours — continue vs. approve. The same status is also used for the large-edit continue prompt, 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 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.

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:
Approve all changes at once:
Deny with feedback:

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: 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 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.
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.
Supporting CSS:
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.
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.

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

Complete Python example