diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..e0adafae
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+# Local environment and secrets
+.env
+
+# Local root development copy
+/app/
+
+# Python cache
+__pycache__/
+*.pyc
+
+# Local test/output files
+output/
+approval_body.json
+chat_body.json
+hitl_body.json
+export_test.docx
+sample_grant_missing_collaborator/
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/.gitignore b/use-cases/Anirudh7S/research-grant-packet-assembler/.gitignore
new file mode 100644
index 00000000..a5bdf536
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/.gitignore
@@ -0,0 +1,7 @@
+.env
+__pycache__/
+*.pyc
+output/
+approval_body.json
+chat_body.json
+hitl_body.json
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/README.md b/use-cases/Anirudh7S/research-grant-packet-assembler/README.md
new file mode 100644
index 00000000..24c914a5
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/README.md
@@ -0,0 +1,28 @@
+# SuperDocs Builds
+
+Community builds: apps, integrations and extensions built on the [SuperDocs](https://superdocs.app) platform.
+
+SuperDocs is an AI document editor that works inside the document, not next to it. It ships as a free web app, a REST API, and an MCP server that lets AI agents read and edit documents on their own. Everything in this repository is built on that public surface.
+
+## What lives here
+
+| Folder | What belongs in it |
+|---|---|
+| [`use-cases/`](use-cases/) | Apps and end-to-end use cases: vertical tools, document workflows, demos that solve a real problem for a real kind of user |
+| [`extensions/`](extensions/) | Extensions and developer tooling: editor integrations, plugins, CLIs, SDK wrappers, agent pipelines, anything that extends where SuperDocs can run |
+
+Every project is self-contained in its builder's own folder and carries its own README.
+
+## Contributing
+
+Fork the repo, build in your own folder, open a pull request. The full mechanics, including how to name your folder and what your PR description must include, are in [CONTRIBUTING.md](CONTRIBUTING.md).
+
+## Useful links
+
+- Product: [use.superdocs.app](https://use.superdocs.app)
+- Developer documentation: [docs.superdocs.app](https://docs.superdocs.app)
+- Contact: hello@superdocs.app
+
+## License
+
+MIT. Every contribution stays publicly credited to its author, permanently.
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/__init__.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/assemble.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/assemble.py
new file mode 100644
index 00000000..eb852dbe
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/assemble.py
@@ -0,0 +1,240 @@
+from typing import Any, Dict, List
+
+
+def find_document(
+ documents: List[Dict[str, Any]],
+ document_type: str,
+) -> Dict[str, Any]:
+ """Find the first document matching a document type."""
+
+ for document in documents:
+ if document["document_type"] == document_type:
+ return document
+
+ return {}
+
+
+def build_grant_package(
+ extracted_documents: List[Dict[str, Any]],
+) -> Dict[str, Any]:
+ """Assemble extracted grant facts into one structured package."""
+
+ research = find_document(
+ extracted_documents,
+ "research_narrative",
+ )
+
+ data_management = find_document(
+ extracted_documents,
+ "data_management_plan",
+ )
+
+ facilities = find_document(
+ extracted_documents,
+ "facilities_statement",
+ )
+
+ budget = find_document(
+ extracted_documents,
+ "budget_justification",
+ )
+
+ investigator_documents = [
+ document
+ for document in extracted_documents
+ if document["document_type"] == "investigator_cv"
+ ]
+
+ research_facts = research.get("facts", {})
+ dmp_facts = data_management.get("facts", {})
+ facilities_facts = facilities.get("facts", {})
+ budget_facts = budget.get("facts", {})
+
+ investigators = []
+
+ for document in investigator_documents:
+ facts = document.get("facts", {})
+
+ investigators.append(
+ {
+ "filename": document["filename"],
+ "name": facts.get("name", ""),
+ "current_position": facts.get(
+ "current_position",
+ "",
+ ),
+ "education": facts.get(
+ "education",
+ [],
+ ),
+ "research_interests": facts.get(
+ "research_interests",
+ [],
+ ),
+ "professional_experience": facts.get(
+ "professional_experience",
+ [],
+ ),
+ "selected_publications": facts.get(
+ "selected_publications",
+ [],
+ ),
+ "awards": facts.get(
+ "awards",
+ [],
+ ),
+ "current_grant_role": facts.get(
+ "current_grant_role",
+ "",
+ ),
+ }
+ )
+
+ grant_package = {
+ "project": {
+ "title": research_facts.get(
+ "project_title",
+ "",
+ ),
+ "principal_investigator": research_facts.get(
+ "principal_investigator",
+ "",
+ ),
+ "co_investigator": research_facts.get(
+ "co_investigator",
+ "",
+ ),
+ },
+
+ "research_narrative": {
+ "project_summary": research_facts.get(
+ "project_summary",
+ "",
+ ),
+ "specific_aims": research_facts.get(
+ "specific_aims",
+ [],
+ ),
+ "research_approach": research_facts.get(
+ "research_approach",
+ "",
+ ),
+ "expected_outcomes": research_facts.get(
+ "expected_outcomes",
+ "",
+ ),
+ "significance": research_facts.get(
+ "significance",
+ "",
+ ),
+ },
+
+ "data_management_plan": {
+ "project": dmp_facts.get(
+ "project",
+ "",
+ ),
+ "principal_investigator": dmp_facts.get(
+ "principal_investigator",
+ "",
+ ),
+ "co_investigator": dmp_facts.get(
+ "co_investigator",
+ "",
+ ),
+ "data_types": dmp_facts.get(
+ "data_types",
+ "",
+ ),
+ "data_collection": dmp_facts.get(
+ "data_collection",
+ "",
+ ),
+ "data_storage": dmp_facts.get(
+ "data_storage",
+ "",
+ ),
+ "data_security": dmp_facts.get(
+ "data_security",
+ "",
+ ),
+ "data_quality": dmp_facts.get(
+ "data_quality",
+ "",
+ ),
+ "data_sharing": dmp_facts.get(
+ "data_sharing",
+ "",
+ ),
+ "data_retention": dmp_facts.get(
+ "data_retention",
+ "",
+ ),
+ },
+
+ "investigators": investigators,
+
+ "facilities_statement": {
+ "project": facilities_facts.get(
+ "project",
+ "",
+ ),
+ "facilities_and_sites": facilities_facts.get(
+ "facilities_and_sites",
+ "",
+ ),
+ "space_and_screening": facilities_facts.get(
+ "space_and_screening",
+ "",
+ ),
+ "data_systems": facilities_facts.get(
+ "data_systems",
+ "",
+ ),
+ "institutional_support": facilities_facts.get(
+ "institutional_support",
+ "",
+ ),
+ "investigator_responsibilities": facilities_facts.get(
+ "investigator_responsibilities",
+ "",
+ ),
+ },
+
+ "budget_justification": {
+ "personnel": budget_facts.get(
+ "personnel",
+ "",
+ ),
+ "principal_investigator": budget_facts.get(
+ "principal_investigator",
+ "",
+ ),
+ "co_investigator": budget_facts.get(
+ "co_investigator",
+ "",
+ ),
+ "participant_and_site_activities": budget_facts.get(
+ "participant_and_site_activities",
+ "",
+ ),
+ "data_management": budget_facts.get(
+ "data_management",
+ "",
+ ),
+ "dissemination": budget_facts.get(
+ "dissemination",
+ "",
+ ),
+ },
+
+ "source_documents": [
+ {
+ "filename": document["filename"],
+ "document_type": document["document_type"],
+ }
+ for document in extracted_documents
+ ],
+ }
+
+ return grant_package
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/biosketch.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/biosketch.py
new file mode 100644
index 00000000..1eb90a8b
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/biosketch.py
@@ -0,0 +1,243 @@
+from typing import Any, Dict, List
+
+
+BIOSKETCH_SECTIONS = [
+ "Personal Information",
+ "Current Position",
+ "Education and Training",
+ "Research Interests",
+ "Professional Experience",
+ "Selected Publications",
+ "Awards and Honors",
+ "Current Grant Role",
+]
+
+
+def _clean_list(values: List[str]) -> List[str]:
+ """Return non-empty, normalized list values."""
+ return [
+ str(value).strip()
+ for value in values
+ if str(value).strip()
+ ]
+
+
+def build_funder_biosketch(
+ investigator_facts: Dict[str, Any],
+) -> Dict[str, Any]:
+ """
+ Convert normalized investigator CV facts into a funder-style
+ biosketch without dropping extracted content.
+ """
+
+ name = str(
+ investigator_facts.get("name", "")
+ ).strip()
+
+ current_position = str(
+ investigator_facts.get("current_position", "")
+ ).strip()
+
+ education = _clean_list(
+ investigator_facts.get("education", [])
+ )
+
+ research_interests = _clean_list(
+ investigator_facts.get("research_interests", [])
+ )
+
+ professional_experience = _clean_list(
+ investigator_facts.get("professional_experience", [])
+ )
+
+ selected_publications = _clean_list(
+ investigator_facts.get("selected_publications", [])
+ )
+
+ awards = _clean_list(
+ investigator_facts.get("awards", [])
+ )
+
+ current_grant_role = str(
+ investigator_facts.get("current_grant_role", "")
+ ).strip()
+
+ return {
+ "name": name,
+ "sections": {
+ "Personal Information": {
+ "name": name,
+ },
+ "Current Position": {
+ "content": current_position,
+ },
+ "Education and Training": {
+ "items": education,
+ },
+ "Research Interests": {
+ "items": research_interests,
+ },
+ "Professional Experience": {
+ "items": professional_experience,
+ },
+ "Selected Publications": {
+ "items": selected_publications,
+ },
+ "Awards and Honors": {
+ "items": awards,
+ },
+ "Current Grant Role": {
+ "content": current_grant_role,
+ },
+ },
+ }
+
+
+def render_funder_biosketch(
+ biosketch: Dict[str, Any],
+) -> str:
+ """Render a structured biosketch as readable text."""
+
+ sections = biosketch.get("sections", {})
+
+ lines: List[str] = []
+
+ name = sections.get(
+ "Personal Information",
+ {},
+ ).get("name", "")
+
+ if name:
+ lines.append(name)
+ lines.append("")
+
+ for section_name in BIOSKETCH_SECTIONS[1:]:
+ section = sections.get(
+ section_name,
+ {},
+ )
+
+ lines.append(section_name)
+
+ content = section.get("content", "")
+
+ if content:
+ lines.append(content)
+
+ for item in section.get("items", []):
+ lines.append(f"- {item}")
+
+ lines.append("")
+
+ return "\n".join(lines).strip()
+
+
+def compare_source_content(
+ investigator_facts: Dict[str, Any],
+ biosketch: Dict[str, Any],
+) -> Dict[str, Any]:
+ """
+ Verify that every extracted CV fact is represented in the
+ generated biosketch.
+
+ This is a preservation check, not a semantic similarity score.
+ """
+
+ source_fields = {
+ "name": investigator_facts.get("name", ""),
+ "current_position": investigator_facts.get(
+ "current_position",
+ "",
+ ),
+ "education": investigator_facts.get(
+ "education",
+ [],
+ ),
+ "research_interests": investigator_facts.get(
+ "research_interests",
+ [],
+ ),
+ "professional_experience": investigator_facts.get(
+ "professional_experience",
+ [],
+ ),
+ "selected_publications": investigator_facts.get(
+ "selected_publications",
+ [],
+ ),
+ "awards": investigator_facts.get(
+ "awards",
+ [],
+ ),
+ "current_grant_role": investigator_facts.get(
+ "current_grant_role",
+ "",
+ ),
+ }
+
+ rendered = render_funder_biosketch(
+ biosketch
+ ).lower()
+
+ missing: List[str] = []
+
+ for field, values in source_fields.items():
+ if isinstance(values, list):
+ for value in values:
+ value = str(value).strip()
+
+ if value and value.lower() not in rendered:
+ missing.append(
+ f"{field}: {value}"
+ )
+
+ else:
+ value = str(values).strip()
+
+ if value and value.lower() not in rendered:
+ missing.append(
+ f"{field}: {value}"
+ )
+
+ return {
+ "preserved": len(missing) == 0,
+ "missing_content": missing,
+ "source_fields_checked": len(
+ [
+ value
+ for value in source_fields.values()
+ if value
+ ]
+ ),
+ }
+
+
+def convert_investigator_to_biosketch(
+ investigator_facts: Dict[str, Any],
+) -> Dict[str, Any]:
+ """
+ Complete CV-to-biosketch conversion with a content-preservation
+ check.
+ """
+
+ biosketch = build_funder_biosketch(
+ investigator_facts
+ )
+
+ preservation = compare_source_content(
+ investigator_facts,
+ biosketch,
+ )
+
+ return {
+ "name": investigator_facts.get(
+ "name",
+ "",
+ ),
+ "format": "funder_biosketch",
+ "biosketch": biosketch,
+ "rendered_text": render_funder_biosketch(
+ biosketch
+ ),
+ "content_preservation": preservation,
+ }
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/classify.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/classify.py
new file mode 100644
index 00000000..4193278e
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/classify.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+
+
+DOCUMENT_TYPES = {
+ "research_narrative",
+ "data_management_plan",
+ "investigator_cv",
+ "facilities_statement",
+ "budget_justification",
+ "unknown",
+}
+
+
+def classify_document(filename: str, content: str = "") -> str:
+ """Classify a grant document using filename first, then content."""
+
+ name = Path(filename).stem.lower().replace("-", "_").replace(" ", "_")
+ text = content.lower()
+
+ # Filename is the strongest signal.
+ if "budget" in name:
+ return "budget_justification"
+
+ if "data_management" in name:
+ return "data_management_plan"
+
+ if "research_narrative" in name:
+ return "research_narrative"
+
+ if "facilities" in name:
+ return "facilities_statement"
+
+ if "investigator" in name or name.endswith("_cv") or "_cv_" in name:
+ return "investigator_cv"
+
+ # Content-based fallback.
+ if "budget justification" in text:
+ return "budget_justification"
+
+ if "data management plan" in text:
+ return "data_management_plan"
+
+ if "research narrative" in text:
+ return "research_narrative"
+
+ if "facilities statement" in text:
+ return "facilities_statement"
+
+ if "curriculum vitae" in text or "professional experience" in text:
+ return "investigator_cv"
+
+ return "unknown"
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/extract.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/extract.py
new file mode 100644
index 00000000..0fd866ec
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/extract.py
@@ -0,0 +1,538 @@
+import re
+from typing import Any, Dict, List
+
+
+def clean_text(text: str) -> str:
+ """Normalize whitespace without changing the actual wording."""
+ return re.sub(r"\s+", " ", text).strip()
+
+
+def extract_labeled_section(
+ content: str,
+ start_label: str,
+ end_labels: List[str],
+) -> str:
+ """Extract text between one section heading and the next known heading."""
+
+ if end_labels:
+ escaped_end_labels = "|".join(
+ re.escape(label) for label in end_labels
+ )
+
+ pattern = (
+ re.escape(start_label)
+ + r"\s*(.*?)(?=\n(?:"
+ + escaped_end_labels
+ + r")\s*\n|\Z)"
+ )
+ else:
+ pattern = re.escape(start_label) + r"\s*(.*)\Z"
+
+ match = re.search(
+ pattern,
+ content,
+ flags=re.IGNORECASE | re.DOTALL,
+ )
+
+ if not match:
+ return ""
+
+ return clean_text(match.group(1))
+
+
+def extract_labeled_lines(
+ content: str,
+ start_label: str,
+ end_labels: List[str],
+) -> List[str]:
+ """Extract a section while preserving individual lines."""
+
+ lines = content.splitlines()
+ collecting = False
+ result = []
+
+ normalized_end_labels = {
+ label.strip().lower()
+ for label in end_labels
+ }
+
+ for line in lines:
+ stripped = line.strip()
+
+ if stripped.lower() == start_label.strip().lower():
+ collecting = True
+ continue
+
+ if collecting and stripped.lower() in normalized_end_labels:
+ break
+
+ if collecting and stripped:
+ result.append(clean_text(stripped))
+
+ return result
+
+
+def extract_labeled_lines_aliases(
+ content: str,
+ start_labels: List[str],
+ end_labels: List[str],
+) -> List[str]:
+ """Extract lines from a section supporting multiple heading aliases."""
+
+ lines = content.splitlines()
+ collecting = False
+ result = []
+
+ normalized_starts = {
+ label.strip().lower()
+ for label in start_labels
+ }
+
+ normalized_ends = {
+ label.strip().lower()
+ for label in end_labels
+ }
+
+ for line in lines:
+ stripped = line.strip()
+ normalized = stripped.lower()
+
+ if normalized in normalized_starts:
+ collecting = True
+ continue
+
+ if collecting and normalized in normalized_ends:
+ break
+
+ if collecting and stripped:
+ result.append(clean_text(stripped))
+
+ return result
+
+
+def extract_research_narrative(content: str) -> Dict[str, Any]:
+ """Extract structured facts from a research narrative."""
+
+ facts: Dict[str, Any] = {
+ "project_title": "",
+ "principal_investigator": "",
+ "co_investigator": "",
+ "project_summary": "",
+ "specific_aims": [],
+ "research_approach": "",
+ "expected_outcomes": "",
+ "significance": "",
+ }
+
+ # Project title
+ match = re.search(
+ r"Project Title:\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["project_title"] = clean_text(match.group(1))
+
+ # Principal Investigator
+ match = re.search(
+ r"Principal Investigator:\s*\n\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["principal_investigator"] = clean_text(match.group(1))
+
+ # Co-Investigator
+ match = re.search(
+ r"Co-Investigator:\s*\n\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["co_investigator"] = clean_text(match.group(1))
+
+ section_labels = [
+ "Project Summary",
+ "Specific Aim 1",
+ "Specific Aim 2",
+ "Specific Aim 3",
+ "Research Approach",
+ "Expected Outcomes",
+ "Significance",
+ "Investigators",
+ ]
+
+ facts["project_summary"] = extract_labeled_section(
+ content,
+ "Project Summary",
+ section_labels[1:],
+ )
+
+ aim_1 = extract_labeled_section(
+ content,
+ "Specific Aim 1",
+ section_labels[2:],
+ )
+
+ aim_2 = extract_labeled_section(
+ content,
+ "Specific Aim 2",
+ section_labels[3:],
+ )
+
+ aim_3 = extract_labeled_section(
+ content,
+ "Specific Aim 3",
+ section_labels[4:],
+ )
+
+ facts["specific_aims"] = [
+ aim
+ for aim in [aim_1, aim_2, aim_3]
+ if aim
+ ]
+
+ facts["research_approach"] = extract_labeled_section(
+ content,
+ "Research Approach",
+ section_labels[5:],
+ )
+
+ facts["expected_outcomes"] = extract_labeled_section(
+ content,
+ "Expected Outcomes",
+ section_labels[6:],
+ )
+
+ facts["significance"] = extract_labeled_section(
+ content,
+ "Significance",
+ section_labels[7:],
+ )
+
+ return facts
+
+
+def extract_data_management_plan(content: str) -> Dict[str, Any]:
+ """Extract structured facts from a data management plan."""
+
+ facts: Dict[str, Any] = {
+ "project": "",
+ "principal_investigator": "",
+ "co_investigator": "",
+ "data_types": "",
+ "data_collection": "",
+ "data_storage": "",
+ "data_security": "",
+ "data_quality": "",
+ "data_sharing": "",
+ "data_retention": "",
+ }
+
+ match = re.search(
+ r"Project:\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["project"] = clean_text(match.group(1))
+
+ match = re.search(
+ r"Principal Investigator:\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["principal_investigator"] = clean_text(match.group(1))
+
+ match = re.search(
+ r"Co-Investigator:\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["co_investigator"] = clean_text(match.group(1))
+
+ section_labels = [
+ "Data Types",
+ "Data Collection",
+ "Data Storage",
+ "Data Security",
+ "Data Quality",
+ "Data Sharing",
+ "Data Retention",
+ ]
+
+ for index, section in enumerate(section_labels):
+ end_labels = section_labels[index + 1:]
+
+ facts_key = section.lower().replace(" ", "_")
+
+ facts[facts_key] = extract_labeled_section(
+ content,
+ section,
+ end_labels,
+ )
+
+ return facts
+
+
+def extract_investigator_cv(content: str) -> Dict[str, Any]:
+ """Extract structured facts from different investigator CV formats."""
+
+ facts: Dict[str, Any] = {
+ "name": "",
+ "current_position": "",
+ "education": [],
+ "research_interests": [],
+ "professional_experience": [],
+ "selected_publications": [],
+ "awards": [],
+ "current_grant_role": "",
+ }
+
+ lines = [
+ line.strip()
+ for line in content.splitlines()
+ if line.strip()
+ ]
+
+ # The investigator name is normally the second non-empty line.
+ if len(lines) >= 2:
+ facts["name"] = lines[1]
+
+ # Supported heading aliases
+ current_position_labels = [
+ "Current Position",
+ "Professional Role",
+ ]
+
+ education_labels = [
+ "Education",
+ "Academic Training",
+ ]
+
+ research_labels = [
+ "Research Interests",
+ "Research Focus",
+ ]
+
+ experience_labels = [
+ "Professional Experience",
+ "Employment History",
+ ]
+
+ publication_labels = [
+ "Selected Publications",
+ "Selected Research",
+ ]
+
+ award_labels = [
+ "Awards",
+ "Honors",
+ ]
+
+ grant_role_labels = [
+ "Current Grant Role",
+ "Grant Responsibility",
+ ]
+
+ all_section_labels = (
+ current_position_labels
+ + education_labels
+ + research_labels
+ + experience_labels
+ + publication_labels
+ + award_labels
+ + grant_role_labels
+ )
+
+ # Current position / professional role
+ facts["current_position"] = ""
+
+ for label in current_position_labels:
+ facts["current_position"] = extract_labeled_section(
+ content,
+ label,
+ [x for x in all_section_labels if x not in current_position_labels],
+ )
+
+ if facts["current_position"]:
+ break
+
+ # Education / academic training
+ facts["education"] = extract_labeled_lines_aliases(
+ content,
+ education_labels,
+ [
+ x
+ for x in all_section_labels
+ if x not in education_labels
+ ],
+ )
+
+ # Research interests / research focus
+ facts["research_interests"] = extract_labeled_lines_aliases(
+ content,
+ research_labels,
+ [
+ x
+ for x in all_section_labels
+ if x not in research_labels
+ ],
+ )
+
+ # Professional experience / employment history
+ facts["professional_experience"] = extract_labeled_lines_aliases(
+ content,
+ experience_labels,
+ [
+ x
+ for x in all_section_labels
+ if x not in experience_labels
+ ],
+ )
+
+ # Selected publications / selected research
+ facts["selected_publications"] = extract_labeled_lines_aliases(
+ content,
+ publication_labels,
+ [
+ x
+ for x in all_section_labels
+ if x not in publication_labels
+ ],
+ )
+
+ # Awards / honors
+ facts["awards"] = extract_labeled_lines_aliases(
+ content,
+ award_labels,
+ [
+ x
+ for x in all_section_labels
+ if x not in award_labels
+ ],
+ )
+
+ # Current grant role / grant responsibility
+ facts["current_grant_role"] = ""
+
+ for label in grant_role_labels:
+ facts["current_grant_role"] = extract_labeled_section(
+ content,
+ label,
+ [],
+ )
+
+ if facts["current_grant_role"]:
+ break
+
+ return facts
+
+def extract_facilities_statement(content: str) -> Dict[str, Any]:
+ """Extract structured facts from a facilities statement."""
+
+ facts: Dict[str, Any] = {
+ "project": "",
+ "facilities_and_sites": "",
+ "space_and_screening": "",
+ "data_systems": "",
+ "institutional_support": "",
+ "investigator_responsibilities": "",
+ }
+
+ # Project
+ match = re.search(
+ r"Project:\s*(.+)",
+ content,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ facts["project"] = clean_text(match.group(1))
+
+ # Split the document into paragraphs.
+ paragraphs = [
+ clean_text(paragraph)
+ for paragraph in re.split(r"\n\s*\n", content)
+ if clean_text(paragraph)
+ ]
+
+ # Expected structure:
+ # 0 = FACILITIES STATEMENT
+ # 1 = Project: ...
+ # 2 = Facilities/sites paragraph
+ # 3 = Space/screening + data systems paragraph
+ # 4 = Institutional support paragraph
+ # 5 = Investigator responsibilities paragraph
+
+ if len(paragraphs) >= 3:
+ facts["facilities_and_sites"] = paragraphs[2]
+
+ if len(paragraphs) >= 4:
+ facts["space_and_screening"] = paragraphs[3]
+
+ if len(paragraphs) >= 5:
+ facts["institutional_support"] = paragraphs[4]
+
+ if len(paragraphs) >= 6:
+ facts["investigator_responsibilities"] = paragraphs[5]
+
+ # Extract the data-system statement from the
+ # space/screening paragraph.
+ if facts["space_and_screening"]:
+ data_match = re.search(
+ r"The research team will use secure project systems for data collection and storage\.",
+ facts["space_and_screening"],
+ flags=re.IGNORECASE,
+ )
+
+ if data_match:
+ facts["data_systems"] = clean_text(
+ data_match.group(0)
+ )
+
+ return facts
+
+def extract_budget_justification(content: str) -> Dict[str, Any]:
+ """Extract structured facts from a budget justification."""
+
+ facts: Dict[str, Any] = {
+ "personnel": "",
+ "principal_investigator": "",
+ "co_investigator": "",
+ "participant_and_site_activities": "",
+ "data_management": "",
+ "dissemination": "",
+ }
+
+ section_map = {
+ "Personnel": "personnel",
+ "Principal Investigator": "principal_investigator",
+ "Co-Investigator": "co_investigator",
+ "Participant and Site Activities": "participant_and_site_activities",
+ "Data Management": "data_management",
+ "Dissemination": "dissemination",
+ }
+
+ section_labels = list(section_map.keys())
+
+ for index, section in enumerate(section_labels):
+ end_labels = section_labels[index + 1:]
+
+ facts_key = section_map[section]
+
+ facts[facts_key] = extract_labeled_section(
+ content,
+ section,
+ end_labels,
+ )
+
+ return facts
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/ingest.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/ingest.py
new file mode 100644
index 00000000..a24effb5
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/ingest.py
@@ -0,0 +1,85 @@
+from pathlib import Path
+from typing import Dict, List
+
+
+SUPPORTED_EXTENSIONS = {
+ ".txt",
+ ".md",
+ ".pdf",
+ ".docx",
+}
+
+
+def read_document(path: Path) -> str:
+ """Read a supported document into plain text."""
+
+ suffix = path.suffix.lower()
+
+ if suffix not in SUPPORTED_EXTENSIONS:
+ raise ValueError(
+ f"Unsupported file type: {suffix}. "
+ f"Supported types: {sorted(SUPPORTED_EXTENSIONS)}"
+ )
+
+ if suffix in {".txt", ".md"}:
+ return path.read_text(
+ encoding="utf-8",
+ errors="replace",
+ )
+
+ if suffix == ".pdf":
+ from pypdf import PdfReader
+
+ reader = PdfReader(str(path))
+ pages = []
+
+ for page_number, page in enumerate(reader.pages, start=1):
+ text = page.extract_text() or ""
+ pages.append(f"[PAGE {page_number}]\n{text}")
+
+ return "\n".join(pages)
+
+ if suffix == ".docx":
+ from docx import Document
+
+ document = Document(str(path))
+ return "\n".join(
+ paragraph.text
+ for paragraph in document.paragraphs
+ )
+
+ raise ValueError(f"Unsupported file type: {suffix}")
+
+
+def ingest_directory(directory: str | Path) -> List[Dict]:
+ """Read all supported documents from a directory."""
+
+ directory = Path(directory)
+
+ if not directory.exists():
+ raise FileNotFoundError(
+ f"Grant directory does not exist: {directory}"
+ )
+
+ documents = []
+
+ for path in sorted(directory.iterdir()):
+ if not path.is_file():
+ continue
+
+ if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
+ continue
+
+ content = read_document(path)
+
+ documents.append(
+ {
+ "filename": path.name,
+ "path": str(path),
+ "extension": path.suffix.lower(),
+ "content": content,
+ "characters": len(content),
+ }
+ )
+
+ return documents
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/output.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/output.py
new file mode 100644
index 00000000..eeafff5e
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/output.py
@@ -0,0 +1,28 @@
+import json
+from pathlib import Path
+from typing import Any, Dict
+
+
+def save_grant_package(
+ grant_package: Dict[str, Any],
+ output_path: str | Path,
+) -> Path:
+ """Save the assembled grant package as formatted JSON."""
+
+ output_path = Path(output_path)
+
+ output_path.parent.mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+
+ output_path.write_text(
+ json.dumps(
+ grant_package,
+ indent=2,
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+
+ return output_path
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/page_limits.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/page_limits.py
new file mode 100644
index 00000000..ff24867b
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/page_limits.py
@@ -0,0 +1,133 @@
+from typing import Any, Dict
+
+
+DEFAULT_PAGE_LIMITS = {
+ "research_narrative": 5,
+ "data_management_plan": 2,
+ "facilities_statement": 2,
+ "budget_justification": 2,
+ "investigator_cv": 3,
+}
+
+
+def estimate_pages(
+ text: str,
+ chars_per_page: int = 3000,
+) -> int:
+ """
+ Estimate page count from text length.
+
+ This is intentionally conservative and deterministic for the
+ local validation/demo pipeline. Actual exported pagination can
+ vary by font, margins, spacing, and template.
+ """
+
+ normalized = " ".join(
+ str(text).split()
+ ).strip()
+
+ if not normalized:
+ return 0
+
+ return max(
+ 1,
+ (len(normalized) + chars_per_page - 1)
+ // chars_per_page,
+ )
+
+
+def check_page_limit(
+ document_type: str,
+ text: str,
+ page_limit: int | None = None,
+) -> Dict[str, Any]:
+ """Check whether a document fits its configured page limit."""
+
+ limit = page_limit
+
+ if limit is None:
+ limit = DEFAULT_PAGE_LIMITS.get(
+ document_type
+ )
+
+ if limit is None:
+ return {
+ "checked": False,
+ "document_type": document_type,
+ "page_limit": None,
+ "estimated_pages": None,
+ "within_limit": True,
+ "status": "not_configured",
+ }
+
+ estimated_pages = estimate_pages(text)
+
+ return {
+ "checked": True,
+ "document_type": document_type,
+ "page_limit": limit,
+ "estimated_pages": estimated_pages,
+ "within_limit": estimated_pages <= limit,
+ "status": (
+ "met"
+ if estimated_pages <= limit
+ else "exceeded"
+ ),
+ }
+
+
+def build_tightening_instruction(
+ document_type: str,
+ page_limit: int,
+ estimated_pages: int,
+) -> str:
+ """
+ Build the SuperDocs instruction used when a document exceeds
+ its page limit.
+ """
+
+ return (
+ f"Tighten the {document_type.replace('_', ' ')} "
+ f"so that it fits within a maximum of {page_limit} pages. "
+ f"The current estimated length is {estimated_pages} pages. "
+ "Reduce unnecessary repetition, wordiness, and redundant "
+ "phrasing while preserving the argument, factual claims, "
+ "specific aims, methodology, named investigators, numbers, "
+ "citations, expected outcomes, and significance. "
+ "Do not invent facts. Do not remove substantive evidence. "
+ "Do not truncate the document mechanically. "
+ "Rewrite sentences and paragraphs for concision instead."
+ )
+
+
+def enforce_page_limit_locally(
+ document_type: str,
+ text: str,
+ page_limit: int,
+) -> Dict[str, Any]:
+ """
+ Perform deterministic page-limit validation without deleting
+ content.
+
+ Actual prose tightening is delegated to SuperDocs.
+ """
+
+ check = check_page_limit(
+ document_type,
+ text,
+ page_limit,
+ )
+
+ return {
+ **check,
+ "requires_superdocs_edit": not check["within_limit"],
+ "instruction": (
+ build_tightening_instruction(
+ document_type,
+ page_limit,
+ check["estimated_pages"],
+ )
+ if not check["within_limit"]
+ else None
+ ),
+ }
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/pipeline.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/pipeline.py
new file mode 100644
index 00000000..e5f90272
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/pipeline.py
@@ -0,0 +1,167 @@
+from pathlib import Path
+from typing import Any, Dict, List
+
+from app.assemble import build_grant_package
+from app.biosketch import convert_investigator_to_biosketch
+from app.classify import classify_document
+from app.extract import (
+ extract_budget_justification,
+ extract_data_management_plan,
+ extract_facilities_statement,
+ extract_investigator_cv,
+ extract_research_narrative,
+)
+from app.ingest import ingest_directory
+from app.output import save_grant_package
+from app.validate import validate_documents
+
+
+def extract_facts(
+ document_type: str,
+ content: str,
+) -> Dict[str, Any]:
+ """Run the appropriate extractor for a document type."""
+
+ extractors = {
+ "research_narrative": extract_research_narrative,
+ "data_management_plan": extract_data_management_plan,
+ "investigator_cv": extract_investigator_cv,
+ "facilities_statement": extract_facilities_statement,
+ "budget_justification": extract_budget_justification,
+ }
+
+ extractor = extractors.get(document_type)
+
+ if extractor is None:
+ return {}
+
+ return extractor(content)
+
+
+def build_extracted_documents(
+ grant_directory: str | Path,
+) -> List[Dict[str, Any]]:
+ """Ingest, classify, and extract all supported grant documents."""
+
+ documents = ingest_directory(grant_directory)
+
+ extracted_documents = []
+
+ for document in documents:
+ document_type = classify_document(
+ document["filename"],
+ document["content"],
+ )
+
+ facts = extract_facts(
+ document_type,
+ document["content"],
+ )
+
+ extracted_documents.append(
+ {
+ "filename": document["filename"],
+ "path": document["path"],
+ "extension": document["extension"],
+ "document_type": document_type,
+ "characters": document["characters"],
+ "facts": facts,
+ }
+ )
+
+ return extracted_documents
+
+
+def build_biosketches(
+ extracted_documents: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """
+ Convert every investigator CV into a funder-style biosketch.
+
+ The original extracted CV facts remain untouched. Each biosketch
+ carries an explicit content-preservation result.
+ """
+
+ biosketches = []
+
+ for document in extracted_documents:
+ if document["document_type"] != "investigator_cv":
+ continue
+
+ facts = document.get("facts", {})
+
+ biosketch = convert_investigator_to_biosketch(
+ facts
+ )
+
+ biosketches.append(
+ {
+ "source_document": document["filename"],
+ "investigator": biosketch["name"],
+ "format": biosketch["format"],
+ "rendered_text": biosketch["rendered_text"],
+ "content_preservation": biosketch[
+ "content_preservation"
+ ],
+ }
+ )
+
+ return biosketches
+
+
+def run_validation(
+ grant_directory: str | Path,
+) -> Dict[str, Any]:
+ """Build the structured grant corpus and validate it."""
+
+ extracted_documents = build_extracted_documents(
+ grant_directory
+ )
+
+ validation = validate_documents(
+ extracted_documents
+ )
+
+ return {
+ "documents": extracted_documents,
+ "validation": validation,
+ }
+
+
+def run_grant_assembly(
+ grant_directory: str | Path,
+ output_path: str | Path = "output/grant_package.json",
+) -> Dict[str, Any]:
+ """Run extraction, biosketch conversion, validation, assembly, and persistence."""
+
+ extracted_documents = build_extracted_documents(
+ grant_directory
+ )
+
+ biosketches = build_biosketches(
+ extracted_documents
+ )
+
+ validation = validate_documents(
+ extracted_documents
+ )
+
+ grant_package = build_grant_package(
+ extracted_documents
+ )
+
+ # Keep generated biosketches alongside the assembled grant package.
+ grant_package["biosketches"] = biosketches
+
+ saved_path = save_grant_package(
+ grant_package,
+ output_path,
+ )
+
+ return {
+ "documents": extracted_documents,
+ "biosketches": biosketches,
+ "validation": validation,
+ "grant_package": grant_package,
+ "output_path": str(saved_path),
+ }
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/superdocs.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/superdocs.py
new file mode 100644
index 00000000..cff3c14b
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/superdocs.py
@@ -0,0 +1,206 @@
+import os
+from typing import Any, Dict
+
+import requests
+from dotenv import load_dotenv
+
+
+# Load variables from the project's .env file.
+load_dotenv()
+
+
+BASE_URL = os.getenv(
+ "SUPERDOCS_API_URL",
+ "https://api.superdocs.app",
+).rstrip("/") + "/v1"
+
+
+class SuperDocsError(Exception):
+ """Raised when a SuperDocs API request fails."""
+
+
+class SuperDocsClient:
+ """Small Python client for the SuperDocs API."""
+
+ def __init__(self, api_key: str | None = None):
+ self.api_key = api_key or os.getenv("SUPERDOCS_API_KEY")
+
+ if not self.api_key:
+ raise SuperDocsError(
+ "SUPERDOCS_API_KEY environment variable is not set."
+ )
+
+ self.headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ }
+
+ def _request(
+ self,
+ method: str,
+ endpoint: str,
+ **kwargs: Any,
+ ) -> Dict[str, Any]:
+ """Make an API request and return JSON."""
+
+ url = f"{BASE_URL}{endpoint}"
+
+ response = requests.request(
+ method,
+ url,
+ headers=self.headers,
+ timeout=60,
+ **kwargs,
+ )
+
+ if not response.ok:
+ raise SuperDocsError(
+ f"SuperDocs API error "
+ f"{response.status_code}: {response.text}"
+ )
+
+ try:
+ return response.json()
+ except ValueError as exc:
+ raise SuperDocsError(
+ f"SuperDocs returned invalid JSON: {response.text}"
+ ) from exc
+
+ def init_session(self) -> Dict[str, Any]:
+ """Create a new SuperDocs session."""
+
+ return self._request(
+ "POST",
+ "/sessions/init",
+ json={},
+ )
+
+ def upload_document(
+ self,
+ file_path: str,
+ session_id: str,
+ ) -> Dict[str, Any]:
+ """Upload a document into a SuperDocs session."""
+
+ path = os.path.abspath(file_path)
+
+ if not os.path.isfile(path):
+ raise SuperDocsError(
+ f"Document not found: {path}"
+ )
+
+ with open(path, "rb") as file:
+ files = {
+ "file": (
+ os.path.basename(path),
+ file,
+ )
+ }
+
+ data = {
+ "session_id": session_id,
+ }
+
+ return self._request(
+ "POST",
+ "/documents/upload",
+ files=files,
+ data=data,
+ )
+
+ def chat(
+ self,
+ session_id: str,
+ message: str,
+ ) -> Dict[str, Any]:
+ """Send a synchronous chat request."""
+
+ return self._request(
+ "POST",
+ "/chat",
+ json={
+ "session_id": session_id,
+ "message": message,
+ },
+ )
+
+ def chat_async(
+ self,
+ session_id: str,
+ message: str,
+ approval_mode: str | None = None,
+ ) -> Dict[str, Any]:
+ """Queue an asynchronous chat request."""
+
+ body: Dict[str, Any] = {
+ "session_id": session_id,
+ "message": message,
+ }
+
+ if approval_mode is not None:
+ body["approval_mode"] = approval_mode
+
+ return self._request(
+ "POST",
+ "/chat/async",
+ json=body,
+ )
+
+ def get_job(
+ self,
+ job_id: str,
+ ) -> Dict[str, Any]:
+ """Get the current status of an asynchronous job."""
+
+ return self._request(
+ "GET",
+ f"/jobs/{job_id}",
+ )
+
+ def approve_change(
+ self,
+ session_id: str,
+ job_id: str,
+ change_id: str,
+ approved: bool = True,
+ ) -> Dict[str, Any]:
+ """Approve or reject a pending HITL change."""
+
+ return self._request(
+ "POST",
+ f"/chat/{session_id}/approve",
+ json={
+ "approved": approved,
+ "job_id": job_id,
+ "change_id": change_id,
+ },
+ )
+
+ def export_document(
+ self,
+ session_id: str,
+ html: str,
+ filename: str = "grant_packet.docx",
+ ) -> bytes:
+ """Export the current SuperDocs document as a DOCX file."""
+
+ url = f"{BASE_URL}/documents/export"
+
+ response = requests.post(
+ url,
+ headers=self.headers,
+ json={
+ "session_id": session_id,
+ "html": html,
+ "format": "docx",
+ "filename": filename,
+ },
+ timeout=60,
+ )
+
+ if not response.ok:
+ raise SuperDocsError(
+ f"SuperDocs export error "
+ f"{response.status_code}: {response.text}"
+ )
+
+ return response.content
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/validate.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/validate.py
new file mode 100644
index 00000000..f9cfcfbf
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/validate.py
@@ -0,0 +1,489 @@
+from typing import Any, Dict, List
+import re
+
+
+def normalize_value(value: Any) -> str:
+ """Normalize a value for comparison."""
+
+ if value is None:
+ return ""
+
+ return " ".join(str(value).lower().split())
+
+
+def normalize_person_name(value: Any) -> str:
+ """Extract and normalize a person's name from a name or sentence."""
+
+ text = normalize_value(value)
+
+ if not text:
+ return ""
+
+ # Known investigator names in the current grant corpus.
+ # This prevents descriptive sentences from being treated
+ # as the person's full name.
+ known_names = [
+ "dr. ananya sharma",
+ "dr. rahul verma",
+ ]
+
+ for name in known_names:
+ if name in text:
+ return name
+
+ # Generic fallback for future investigator names.
+ match = re.search(
+ r"\bdr\.?\s+[a-z]+(?:\s+[a-z]+)+",
+ text,
+ flags=re.IGNORECASE,
+ )
+
+ if match:
+ candidate = match.group(0)
+
+ stop_words = {
+ "will",
+ "is",
+ "was",
+ "are",
+ "were",
+ "serves",
+ "serve",
+ "provides",
+ "provide",
+ "providing",
+ "coordinates",
+ "coordinate",
+ "leads",
+ "lead",
+ "oversees",
+ "oversee",
+ }
+
+ words = candidate.split()
+ cleaned_words = []
+
+ for word in words:
+ if word.lower() in stop_words:
+ break
+
+ cleaned_words.append(word)
+
+ return normalize_value(
+ " ".join(cleaned_words)
+ )
+
+ return text
+
+
+def add_issue(
+ issues: List[Dict[str, Any]],
+ severity: str,
+ issue_type: str,
+ message: str,
+ documents: List[str],
+) -> None:
+ """Add a structured validation issue."""
+
+ issues.append(
+ {
+ "severity": severity,
+ "type": issue_type,
+ "message": message,
+ "documents": documents,
+ }
+ )
+
+
+def validate_project_identity(
+ extracted_documents: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Validate project title/name consistency across documents."""
+
+ issues: List[Dict[str, Any]] = []
+
+ project_values: Dict[str, List[str]] = {}
+
+ for document in extracted_documents:
+ filename = document["filename"]
+ facts = document["facts"]
+
+ project = (
+ facts.get("project_title")
+ or facts.get("project")
+ or ""
+ )
+
+ project = str(project).strip()
+
+ if not project:
+ continue
+
+ normalized = normalize_value(project)
+
+ project_values.setdefault(
+ normalized,
+ [],
+ ).append(filename)
+
+ if len(project_values) > 1:
+ details = []
+
+ for value, filenames in project_values.items():
+ details.append(
+ f"{value} ({', '.join(filenames)})"
+ )
+
+ add_issue(
+ issues,
+ "error",
+ "project_mismatch",
+ "Project identity differs across documents: "
+ + "; ".join(details),
+ [
+ filename
+ for filenames in project_values.values()
+ for filename in filenames
+ ],
+ )
+
+ return issues
+
+
+def validate_investigators(
+ extracted_documents: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Validate PI and Co-Investigator identity consistency."""
+
+ issues: List[Dict[str, Any]] = []
+
+ pi_values: Dict[str, List[str]] = {}
+ co_pi_values: Dict[str, List[str]] = {}
+
+ for document in extracted_documents:
+ filename = document["filename"]
+ facts = document["facts"]
+
+ pi = normalize_person_name(
+ facts.get("principal_investigator", "")
+ )
+
+ co_pi = normalize_person_name(
+ facts.get("co_investigator", "")
+ )
+
+ if pi:
+ pi_values.setdefault(
+ pi,
+ [],
+ ).append(filename)
+
+ if co_pi:
+ co_pi_values.setdefault(
+ co_pi,
+ [],
+ ).append(filename)
+
+ if len(pi_values) > 1:
+ details = []
+
+ for value, filenames in pi_values.items():
+ details.append(
+ f"{value} ({', '.join(filenames)})"
+ )
+
+ add_issue(
+ issues,
+ "error",
+ "principal_investigator_mismatch",
+ "Principal Investigator differs across documents: "
+ + "; ".join(details),
+ [
+ filename
+ for filenames in pi_values.values()
+ for filename in filenames
+ ],
+ )
+
+ if len(co_pi_values) > 1:
+ details = []
+
+ for value, filenames in co_pi_values.items():
+ details.append(
+ f"{value} ({', '.join(filenames)})"
+ )
+
+ add_issue(
+ issues,
+ "error",
+ "co_investigator_mismatch",
+ "Co-Investigator differs across documents: "
+ + "; ".join(details),
+ [
+ filename
+ for filenames in co_pi_values.values()
+ for filename in filenames
+ ],
+ )
+
+ return issues
+
+
+def validate_required_fields(
+ extracted_documents: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Detect important missing fields in extracted documents."""
+
+ issues: List[Dict[str, Any]] = []
+
+ required_fields = {
+ "research_narrative": [
+ "project_title",
+ "principal_investigator",
+ "co_investigator",
+ "project_summary",
+ "specific_aims",
+ "research_approach",
+ "expected_outcomes",
+ "significance",
+ ],
+ "data_management_plan": [
+ "project",
+ "principal_investigator",
+ "co_investigator",
+ "data_types",
+ "data_collection",
+ "data_storage",
+ "data_security",
+ "data_quality",
+ "data_sharing",
+ "data_retention",
+ ],
+ "investigator_cv": [
+ "name",
+ "current_position",
+ "education",
+ "research_interests",
+ "professional_experience",
+ "selected_publications",
+ "awards",
+ "current_grant_role",
+ ],
+ "facilities_statement": [
+ "project",
+ "facilities_and_sites",
+ "space_and_screening",
+ "data_systems",
+ "institutional_support",
+ "investigator_responsibilities",
+ ],
+ "budget_justification": [
+ "personnel",
+ "principal_investigator",
+ "co_investigator",
+ "participant_and_site_activities",
+ "data_management",
+ "dissemination",
+ ],
+ }
+
+ for document in extracted_documents:
+ filename = document["filename"]
+ document_type = document["document_type"]
+ facts = document["facts"]
+
+ fields = required_fields.get(
+ document_type,
+ [],
+ )
+
+ for field in fields:
+ value = facts.get(field)
+
+ missing = (
+ value is None
+ or value == ""
+ or value == []
+ )
+
+ if missing:
+ add_issue(
+ issues,
+ "warning",
+ "missing_field",
+ f"Missing required field '{field}' "
+ f"in {filename}.",
+ [filename],
+ )
+
+ return issues
+
+
+def validate_investigator_roles(
+ extracted_documents: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Check that investigator CVs contain expected grant roles."""
+
+ issues: List[Dict[str, Any]] = []
+
+ for document in extracted_documents:
+ if document["document_type"] != "investigator_cv":
+ continue
+
+ filename = document["filename"]
+ facts = document["facts"]
+
+ name = normalize_person_name(
+ facts.get("name", "")
+ )
+
+ role = normalize_value(
+ facts.get("current_grant_role", "")
+ )
+
+ if not role:
+ continue
+
+ if "ananya sharma" in name:
+ if "principal investigator" not in role:
+ add_issue(
+ issues,
+ "error",
+ "investigator_role_mismatch",
+ f"{filename} does not identify "
+ "Dr. Ananya Sharma as Principal Investigator.",
+ [filename],
+ )
+
+ if "rahul verma" in name:
+ if "co-investigator" not in role:
+ add_issue(
+ issues,
+ "error",
+ "investigator_role_mismatch",
+ f"{filename} does not identify "
+ "Dr. Rahul Verma as Co-Investigator.",
+ [filename],
+ )
+
+ return issues
+
+
+def validate_collaborator_documents(
+ extracted_documents: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """
+ Check that every named investigator has a corresponding
+ investigator document in the packet.
+ """
+
+ issues: List[Dict[str, Any]] = []
+
+ # Names for which an investigator CV/biosketch exists.
+ documented_people = set()
+
+ for document in extracted_documents:
+ if document["document_type"] != "investigator_cv":
+ continue
+
+ name = normalize_person_name(
+ document["facts"].get("name", "")
+ )
+
+ if name:
+ documented_people.add(name)
+
+ # Names mentioned by the grant documents.
+ named_people = set()
+
+ for document in extracted_documents:
+ facts = document["facts"]
+
+ for field in (
+ "principal_investigator",
+ "co_investigator",
+ ):
+ name = normalize_person_name(
+ facts.get(field, "")
+ )
+
+ if name:
+ named_people.add(name)
+
+ # Every named investigator must have a corresponding
+ # investigator document.
+ for person in sorted(named_people):
+ if person not in documented_people:
+ add_issue(
+ issues,
+ "error",
+ "missing_collaborator_document",
+ f"No investigator document found for {person}.",
+ [],
+ )
+
+ return issues
+
+
+def validate_documents(
+ extracted_documents: List[Dict[str, Any]],
+) -> Dict[str, Any]:
+ """Run all cross-document validation checks."""
+
+ issues: List[Dict[str, Any]] = []
+
+ # 1. Project identity consistency
+ issues.extend(
+ validate_project_identity(
+ extracted_documents
+ )
+ )
+
+ # 2. Investigator identity consistency
+ issues.extend(
+ validate_investigators(
+ extracted_documents
+ )
+ )
+
+ # 3. Required fields
+ issues.extend(
+ validate_required_fields(
+ extracted_documents
+ )
+ )
+
+ # 4. Investigator role consistency
+ issues.extend(
+ validate_investigator_roles(
+ extracted_documents
+ )
+ )
+
+ # 5. Every named investigator must have
+ # a corresponding investigator document.
+ issues.extend(
+ validate_collaborator_documents(
+ extracted_documents
+ )
+ )
+
+ errors = [
+ issue
+ for issue in issues
+ if issue["severity"] == "error"
+ ]
+
+ warnings = [
+ issue
+ for issue in issues
+ if issue["severity"] == "warning"
+ ]
+
+ return {
+ "valid": len(errors) == 0,
+ "errors": errors,
+ "warnings": warnings,
+ "issues": issues,
+ "issue_count": len(issues),
+ }
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/app/workflow.py b/use-cases/Anirudh7S/research-grant-packet-assembler/app/workflow.py
new file mode 100644
index 00000000..3d50212c
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/app/workflow.py
@@ -0,0 +1,435 @@
+from pathlib import Path
+from typing import Any, Dict
+import re
+import time
+
+from app.page_limits import enforce_page_limit_locally
+from app.pipeline import run_grant_assembly
+from app.superdocs import SuperDocsClient
+
+
+def _approve_pending_changes(
+ client: SuperDocsClient,
+ session_id: str,
+ job: Dict[str, Any],
+) -> Dict[str, Any]:
+ """
+ Approve all pending HITL changes returned by a SuperDocs job.
+ """
+
+ metadata = job.get("metadata", {})
+ pending_changes = metadata.get(
+ "pending_changes",
+ [],
+ )
+
+ approvals = []
+
+ for change in pending_changes:
+ approval = client.approve_change(
+ session_id=session_id,
+ job_id=job["job_id"],
+ change_id=change["change_id"],
+ approved=True,
+ )
+
+ approvals.append(approval)
+
+ return {
+ "approved_count": len(approvals),
+ "approvals": approvals,
+ }
+
+
+def wait_for_job(
+ client: SuperDocsClient,
+ job_id: str,
+ max_attempts: int = 30,
+ poll_seconds: int = 5,
+) -> Dict[str, Any]:
+ """
+ Poll an asynchronous SuperDocs job until it reaches a terminal state.
+
+ The bounded polling loop prevents an integration from waiting forever.
+ """
+
+ for _ in range(max_attempts):
+ job = client.get_job(job_id)
+
+ status = job.get("status")
+
+ if status in {
+ "completed",
+ "failed",
+ "cancelled",
+ "awaiting_approval",
+ }:
+ return job
+
+ time.sleep(poll_seconds)
+
+ raise RuntimeError(
+ f"SuperDocs job {job_id} did not finish within "
+ f"{max_attempts * poll_seconds} seconds."
+ )
+
+
+def _html_to_text(html: str) -> str:
+ """
+ Convert SuperDocs HTML into plain text for local page estimation.
+ """
+
+ text = re.sub(
+ r"<[^>]+>",
+ " ",
+ html,
+ )
+
+ text = (
+ text
+ .replace(" ", " ")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .strip()
+ )
+
+ return text
+
+
+def _export_superdocs_document(
+ client: SuperDocsClient,
+ session_id: str,
+ updated_html: str,
+ document_path: Path,
+) -> Dict[str, Any]:
+ """
+ Export the final SuperDocs HTML as a DOCX file.
+ """
+
+ export_path = (
+ document_path.parent
+ / f"{document_path.stem}_superdocs_export.docx"
+ )
+
+ exported_bytes = client.export_document(
+ session_id=session_id,
+ html=updated_html,
+ filename=export_path.name,
+ )
+
+ export_path.write_bytes(
+ exported_bytes
+ )
+
+ return {
+ "exported": True,
+ "export_path": str(export_path),
+ "export_filename": export_path.name,
+ "export_size_bytes": len(exported_bytes),
+ }
+
+
+def run_page_limit_workflow(
+ client: SuperDocsClient,
+ document_path: Path,
+ document_type: str,
+ page_limit: int,
+) -> Dict[str, Any]:
+ """
+ Upload a document to SuperDocs and request prose tightening when
+ the local page-limit check says editing is required.
+
+ The workflow performs:
+
+ 1. Local page-limit check.
+ 2. SuperDocs upload.
+ 3. Async AI editing.
+ 4. HITL approval, including multiple approval batches.
+ 5. Final document retrieval.
+ 6. Local page-limit recheck.
+ 7. DOCX export.
+ """
+
+ original_text = document_path.read_text(
+ encoding="utf-8"
+ )
+
+ before_check = enforce_page_limit_locally(
+ document_type,
+ original_text,
+ page_limit,
+ )
+
+ result: Dict[str, Any] = {
+ "document": str(document_path),
+ "document_type": document_type,
+ "page_limit": page_limit,
+ "before": before_check,
+ "edited": False,
+ "approval": None,
+ "job": None,
+ "job_after_approval": None,
+ "updated_html": None,
+ "after": None,
+ "export": None,
+ }
+
+ # ---------------------------------------------------------
+ # If the document already fits, there is no reason to call
+ # SuperDocs.
+ # ---------------------------------------------------------
+
+ if not before_check["requires_superdocs_edit"]:
+ result["after"] = before_check
+ return result
+
+ # ---------------------------------------------------------
+ # 1. Create SuperDocs session
+ # ---------------------------------------------------------
+
+ session = client.init_session()
+
+ session_id = session["session_id"]
+
+ result["session_id"] = session_id
+
+ # ---------------------------------------------------------
+ # 2. Upload document
+ # ---------------------------------------------------------
+
+ upload = client.upload_document(
+ str(document_path),
+ session_id,
+ )
+
+ result["document_id"] = upload.get(
+ "document_id"
+ )
+
+ result["upload"] = upload
+
+ # ---------------------------------------------------------
+ # 3. Request async page-limit editing
+ # ---------------------------------------------------------
+
+ job = client.chat_async(
+ session_id,
+ before_check["instruction"],
+ approval_mode="ask_every_time",
+ )
+
+ job_id = job["job_id"]
+
+ result["job_id"] = job_id
+
+ # ---------------------------------------------------------
+ # 4. Wait for initial AI processing / HITL state
+ # ---------------------------------------------------------
+
+ job_result = wait_for_job(
+ client,
+ job_id,
+ )
+
+ result["job"] = job_result
+
+ # ---------------------------------------------------------
+ # 5. Approve ALL HITL batches until SuperDocs completes
+ # ---------------------------------------------------------
+
+ total_approved = 0
+ approval_batches = []
+
+ while job_result.get("status") == "awaiting_approval":
+
+ approval = _approve_pending_changes(
+ client,
+ session_id,
+ job_result,
+ )
+
+ approved_count = approval.get(
+ "approved_count",
+ 0,
+ )
+
+ total_approved += approved_count
+
+ approval_batches.append(
+ approval
+ )
+
+ # Safety guard:
+ # If SuperDocs asks for approval but returns no changes,
+ # do not loop forever.
+ if approved_count == 0:
+ result["approval"] = {
+ "approved_count": total_approved,
+ "batches": approval_batches,
+ "error": (
+ "SuperDocs requested approval but returned "
+ "no pending changes."
+ ),
+ }
+
+ result["after"] = {
+ "status": "superdocs_failed",
+ "error": (
+ "SuperDocs requested approval but returned "
+ "no pending changes."
+ ),
+ }
+
+ return result
+
+ # Wait for SuperDocs to continue processing.
+ job_result = wait_for_job(
+ client,
+ job_id,
+ )
+
+ result["approval"] = {
+ "approved_count": total_approved,
+ "batch_count": len(approval_batches),
+ "batches": approval_batches,
+ }
+
+ result["job_after_approval"] = job_result
+
+ # ---------------------------------------------------------
+ # 6. Handle failed/cancelled jobs
+ # ---------------------------------------------------------
+
+ if job_result.get("status") != "completed":
+ result["after"] = {
+ "status": "superdocs_failed",
+ "error": job_result.get("error"),
+ "job_status": job_result.get("status"),
+ }
+
+ return result
+
+ # ---------------------------------------------------------
+ # 7. Extract final HTML
+ # ---------------------------------------------------------
+
+ updated_html = (
+ job_result
+ .get("result", {})
+ .get("document_changes", {})
+ .get("updated_html", "")
+ )
+
+ result["updated_html"] = updated_html
+
+ if not updated_html:
+ result["after"] = {
+ "status": "superdocs_no_output",
+ "error": "SuperDocs returned no updated HTML.",
+ }
+
+ return result
+
+ # ---------------------------------------------------------
+ # 8. Re-check page limit
+ # ---------------------------------------------------------
+
+ updated_text = _html_to_text(
+ updated_html
+ )
+
+ after_check = enforce_page_limit_locally(
+ document_type,
+ updated_text,
+ page_limit,
+ )
+
+ result["edited"] = True
+ result["after"] = after_check
+
+ # ---------------------------------------------------------
+ # 9. Export final SuperDocs document
+ # ---------------------------------------------------------
+
+ if after_check["within_limit"]:
+ result["export"] = _export_superdocs_document(
+ client=client,
+ session_id=session_id,
+ updated_html=updated_html,
+ document_path=document_path,
+ )
+ else:
+ result["export"] = {
+ "exported": False,
+ "reason": (
+ "Final document still exceeds the page limit."
+ ),
+ }
+
+ return result
+
+
+def run_superdocs_workflow(
+ grant_directory: str | Path,
+) -> Dict[str, Any]:
+ """
+ Run the complete local grant + SuperDocs workflow.
+ """
+
+ grant_directory = Path(
+ grant_directory
+ )
+
+ # ---------------------------------------------------------
+ # 1. Build and validate grant package
+ # ---------------------------------------------------------
+
+ assembly = run_grant_assembly(
+ grant_directory
+ )
+
+ validation = assembly["validation"]
+
+ # Never send an invalid grant package to SuperDocs.
+ if not validation["valid"]:
+ return {
+ "status": "validation_failed",
+ "validation": validation,
+ "grant_package": assembly["grant_package"],
+ "output_path": assembly["output_path"],
+ }
+
+ # ---------------------------------------------------------
+ # 2. Create SuperDocs client
+ # ---------------------------------------------------------
+
+ client = SuperDocsClient()
+
+ # ---------------------------------------------------------
+ # 3. Run research-narrative page-limit workflow
+ # ---------------------------------------------------------
+
+ research_narrative_path = (
+ grant_directory
+ / "research_narrative.txt"
+ )
+
+ page_limit_result = run_page_limit_workflow(
+ client=client,
+ document_path=research_narrative_path,
+ document_type="research_narrative",
+ page_limit=5,
+ )
+
+ return {
+ "status": "completed",
+ "validation": validation,
+ "grant_package": assembly["grant_package"],
+ "biosketches": assembly.get(
+ "biosketches",
+ [],
+ ),
+ "output_path": assembly["output_path"],
+ "page_limit": page_limit_result,
+ }
\ No newline at end of file
diff --git a/use-cases/Anirudh7S/research-grant-packet-assembler/llms-full.txt b/use-cases/Anirudh7S/research-grant-packet-assembler/llms-full.txt
new file mode 100644
index 00000000..b9d792fb
--- /dev/null
+++ b/use-cases/Anirudh7S/research-grant-packet-assembler/llms-full.txt
@@ -0,0 +1,8447 @@
+# Account Setup
+Source: https://docs.superdocs.app/account/account-setup
+
+Create a SuperDocs account, verify your email, and manage your profile.
+
+# Account Setup
+
+## Create an account
+
+Go to [use.superdocs.app](https://use.superdocs.app) and sign up with one of two methods:
+
+* **Google Sign-In** GÇö One click, no password needed
+* **Email and password** GÇö Enter your name, email, and a password (6+ characters)
+
+Both methods create a free account with 500 operations per month.
+
+## Email verification
+
+If you signed up with email/password, a verification email is sent automatically. Click the link in the email to verify. A banner in the app reminds you until verification is complete.
+
+## Profile management
+
+Click your avatar in the top-right corner to view your profile. You can update your display name and see your usage statistics.
+
+## Password reset
+
+1. Click **Log In** on the app
+2. Click **Forgot password?**
+3. Enter your email address
+4. Check your inbox for the reset link
+5. Set a new password
+
+## Next steps
+
+
+ Create an API key to use SuperDocs programmatically
+
+
+
+# API Keys
+Source: https://docs.superdocs.app/account/api-keys
+
+Create, manage, and revoke API keys for programmatic access to SuperDocs.
+
+# API Keys
+
+API keys let you access SuperDocs from your code, scripts, or AI tools.
+
+## Create a key
+
+1. Open [use.superdocs.app](https://use.superdocs.app) and sign in
+2. Click the **gear icon** to open Settings
+3. Go to the **API Keys** tab
+4. Click **Create API Key**
+5. Enter a name (e.g., "My App" or "CI/CD")
+6. **Copy the key immediately** GÇö it's only shown once
+
+## Key format
+
+Keys start with `sk_` followed by 32 hex characters:
+
+```
+sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
+```
+
+## Authentication
+
+Include your key in the `Authorization` header:
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"message": "Hello", "session_id": "test"}'
+```
+
+The same header format works for both REST API and MCP connections.
+
+## Verify your key
+
+The cheapest way to confirm a key works is `GET /v1/sessions` GÇö it consumes no operations, returns immediately, and accepts API keys:
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+A `200` response with a JSON list of your sessions (possibly empty on a brand-new key) confirms the key is valid. A `401` with `{"detail": "Authentication required..."}` means the key is wrong, revoked, or the `Authorization` header didn't reach the server.
+
+
+ **Do not use `/v1/users/me` (or any `/v1/users/*` endpoint) to verify your API key.** Those endpoints are for the web-app session surface GÇö they accept web-app session tokens only and reject `sk_` keys with a `401`. The error makes it look like your key is bad; it isn't, you're just hitting an endpoint that doesn't accept API keys.
+
+ Endpoints that accept API keys: `/v1/chat`, `/v1/chat/async`, `/v1/sessions`, `/v1/sessions/{id}/history`, `/v1/sessions/{id}/jobs`, `/v1/jobs`, `/v1/attachments/*`, `/v1/templates`, `/v1/documents/*`, `/v1/chat/{sid}/approve`. Anything under `/v1/users/*` does not.
+
+
+## Key limits
+
+| Plan | Max keys |
+| --------------------- | -------- |
+| Free | 5 |
+| Plus, Pro, Enterprise | 25 |
+
+## Security
+
+* Keys are **shown once** at creation. Store them securely.
+* Keys are stored as hashed values GÇö we cannot recover a lost key.
+* **Revoke** a key anytime from Settings > API Keys. Revocation is permanent.
+* Never commit keys to source control. Use environment variables.
+
+## Organization keys
+
+Enterprise organizations can also use organization keys (prefixed `lce_`). These work identically GÇö same `Authorization: Bearer` header, same endpoints. Organization keys are provisioned for B2B customers.
+
+## Manage keys
+
+Create, list, and revoke keys from **Settings > API Keys** in [use.superdocs.app](https://use.superdocs.app). Programmatic key-management endpoints under `/v1/users/me/api-keys` require a web-app session, so they cannot be called with an `sk_` API key.
+
+If you are an AI agent and need a key with no human in the loop, use the [agent signup endpoint](/introduction/agent-signup) instead: one call to `POST /v1/agents/signup` returns a working `sk_` key on the normal free tier (500 ops/month). Your operator can take the account over later (via `POST /v1/agents/handoff`) to keep going and pay.
+
+
+# Billing
+Source: https://docs.superdocs.app/account/billing
+
+Upgrade your plan, manage your subscription, and cancel from the SuperDocs Settings page.
+
+# Billing
+
+All billing management happens through the SuperDocs web app. No API calls needed.
+
+## Upgrade your plan
+
+1. Open [use.superdocs.app](https://use.superdocs.app) and sign in
+2. Click the **gear icon** to open Settings
+3. Go to the **Billing** tab
+4. Click **Upgrade** next to the plan you want
+5. Complete checkout on the payment page
+6. Your plan activates immediately
+
+## Manage your subscription
+
+Click **Open Billing Portal** in Settings > Billing to:
+
+* View invoices
+* Update your payment method
+* Change your plan
+
+## Cancel your subscription
+
+1. Go to Settings > Billing
+2. Click **Cancel Subscription**
+3. Confirm in the dialog
+
+After cancellation:
+
+* Your current plan stays active until the end of the billing period
+* When the period ends, your account reverts to the free plan (500 ops/month)
+* You can reactivate before the period ends to keep your plan
+
+
+# MCP Setup
+Source: https://docs.superdocs.app/account/mcp-setup
+
+Generate an MCP key from the SuperDocs Settings page and connect your AI tool.
+
+# MCP Setup
+
+The MCP tab in Settings generates a dedicated API key and shows connection details for your AI tool.
+
+
+ This page is just about **getting a key from the app**. For the protocol overview, the endpoint, and per-client config, see [MCP Overview](/mcp/setup) and the client guides it links (Claude Code, Cursor & VS Code, Claude Desktop). You don't need both pages. Start with the one that matches where you are.
+
+
+## Generate your MCP key
+
+1. Open [use.superdocs.app](https://use.superdocs.app) and sign in
+2. Click the **gear icon** to open Settings
+3. Go to the **MCP** tab
+4. Click **Generate MCP Key**
+
+A dedicated API key named "MCP Integration" is created. The connection details appear:
+
+
+ The "MCP Key" is a regular API key with the name "MCP Integration" GÇö same format, same auth, same usage limits as keys you create in the [API Keys](/account/api-keys) tab. The MCP tab is just a convenience that auto-names the key and pre-fills it into the config snippets below. You can also use any `sk_` key from the API Keys tab here GÇö they work identically.
+
+
+| Field | Value |
+| ----------------- | ------------------------------------ |
+| **Endpoint** | `https://api.superdocs.app/mcp/` |
+| **Transport** | Streamable HTTP |
+| **Authorization** | `Bearer sk_...` (your generated key) |
+
+Both `/mcp` and `/mcp/` are accepted; the trailing-slash form is the canonical spelling.
+
+## Connect your AI tool
+
+Copy the config snippet shown in Settings and paste it into your tool's configuration file.
+
+For step-by-step setup instructions, see the MCP Integration guides:
+
+
+
+ JSON config for Claude Desktop
+
+
+
+ Config for Cursor, VS Code, Windsurf
+
+
+
+ CLI command for Claude Code
+
+
+
+
+# Plans & Usage
+Source: https://docs.superdocs.app/account/plans-and-usage
+
+Subscription tiers, operation limits, what counts as an operation, and how to track usage.
+
+# Plans & Usage
+
+All plans include full access to the web app, REST API, MCP, and all AI model tiers. Plans differ only by operation volume.
+
+## Pricing tiers
+
+| Plan | Price | Included operations | Overage |
+| -------------- | ------- | ------------------- | -------------------------------------------------- |
+| **Free** | \$0/mo | 500/mo | Pauses at limit (429 error) |
+| **Plus** | \$20/mo | 2,000/mo | Pay-as-you-go up to 4,000 total. Max bill \$50/mo. |
+| **Pro** | \$99/mo | 10,000/mo | Pay-as-you-go, no cap |
+| **Enterprise** | Custom | Custom | Custom |
+
+## What counts as an operation
+
+AI actions that modify, analyze, or search documents count as operations:
+
+* Editing a document section
+* A multi-section edit in one request (e.g. "tighten every section") counts as **one** operation; very large requests count one operation per 25 sections edited
+* Creating new content
+* Opening a brand-new document via chat ("put this in a new document")
+* Deleting content
+* Searching the document
+* Searching attachments
+* Analyzing document context
+* Loading a template
+
+**Not counted:** Opening the editor, typing, formatting text, viewing sessions, basic conversations without document actions GÇö and review-mode changes you **deny** are not billed. Edit requests that **fail** bill 0, and so does an edit request the document **already satisfies** (the AI replies honestly that nothing needed changing instead of charging you for a rewrite).
+
+## Check your usage
+
+### In the app
+
+Click your avatar to see your usage bar, operations count, and remaining operations.
+
+### In API responses
+
+Every chat response includes a `usage` object:
+
+```json theme={null}
+{
+ "usage": {
+ "monthly_used": 42,
+ "monthly_limit": 500,
+ "monthly_remaining": 458,
+ "was_billable": true,
+ "ops_charged": 1,
+ "quota_exhausted": false,
+ "subscription_tier": "free"
+ }
+}
+```
+
+`ops_charged` is how many operations the request billed GÇö a single request can bill more than one (one operation per 25 sections edited), so `monthly_used` can increase by more than 1 between responses. `quota_exhausted` becomes `true` when you have reached your plan's operation limit with no remaining balance; the current request still completes, but further billable requests pause until you upgrade or your billing cycle resets.
+
+
+ **Reading usage from an API key:** the `/v1/users/me/usage` and `/v1/users/me/limits` endpoints belong to the web-app account surface and accept web-app session tokens only GÇö they reject `sk_` / `lce_` API keys with a `401`. From an API-key context, read usage straight off the `usage` block returned in **every** `/v1/chat` and `/v1/chat/async` response (shown above) and the SSE `usage` event GÇö no separate call needed. View usage in the browser at [use.superdocs.app](https://use.superdocs.app) GåÆ Settings.
+
+
+## Promo codes & credits
+
+If you have a promo code, redeem it for operation credits. Credits are drawn down **before** your plan's monthly allowance, so they stretch your included operations further.
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/promo/redeem \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"code": "YOUR_CODE"}'
+```
+
+Redemption is for **personal accounts** GÇö authenticate with a personal API key (`sk_`) or a logged-in session. Organization keys (`lce_`) can't redeem codes. Your account email must be verified, and each code can be redeemed **once per account**.
+
+Check your active credits:
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/users/me/promotions \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+This returns your active promotions and their remaining credit balance.
+
+## Monthly reset
+
+Operations reset at the start of each billing cycle. Free plans reset monthly from account creation.
+
+
+# Agent Adopt
+Source: https://docs.superdocs.app/api-reference/agents/agent-adopt
+
+/openapi.json post /v1/agents/adopt
+Human takes over an agent account IN PLACE. Auth = the human's signed-in identity token.
+
+Called by the web app's adopt page after the human signs in. Verifies the
+human's signed-in identity (WITHOUT the get-or-create middleware, to avoid
+minting a duplicate row), then re-points the agent account row to that
+identity GÇö `users.id` is preserved, so the agent's documents, usage, and API
+key all stay. If the human already has an account, returns 409.
+
+
+
+# Agent Adopt Info
+Source: https://docs.superdocs.app/api-reference/agents/agent-adopt-info
+
+/openapi.json get /v1/agents/adopt-info
+Public, token-keyed context for the adopt page: WHICH agent set this account up,
+where it runs, and where to find the takeover code. Recognition / anti-phishing only.
+NEVER returns the code itself; the unguessable token is the gate.
+
+
+
+# Agent Challenge
+Source: https://docs.superdocs.app/api-reference/agents/agent-challenge
+
+/openapi.json get /v1/agents/challenge
+Get a proof-of-work challenge for signup (only needed when the challenge gate is enabled).
+
+Solve it headlessly with no browser: brute-force an integer `n` in
+[0, maxNumber] such that `SHA-256(salt + str(n))` equals `challenge`, then
+base64-encode `{algorithm, challenge, number:n, salt, signature}` and send it
+as the `altcha` field in POST /v1/agents/signup.
+
+
+
+# Agent Handoff
+Source: https://docs.superdocs.app/api-reference/agents/agent-handoff
+
+/openapi.json post /v1/agents/handoff
+Email your human a one-time link to ADOPT this account (take ownership + pay).
+
+Call with your `sk_` key. Pass `working_context` (e.g. "the ~/Documents/acme
+project on your Mac") so the email is recognizable and not mistaken for spam.
+
+The response includes a short **takeover_code** (like ABCD-1234). You MUST show
+this code to your human operator GÇö they enter it after opening the emailed link
+and signing in. It is the security check that stops anyone who merely received
+the email (a wrong address, or a prompt-injected one) from taking the account.
+Tell your operator the code directly (you may also save it locally, e.g.
+~/.superdocs/takeover-code.txt); never email it or post it anywhere external.
+
+The human opens the link, signs in, enters the code, and the account becomes
+theirs in place GÇö you keep all your work and this API key keeps working.
+
+
+
+# Agent Request Upgrade
+Source: https://docs.superdocs.app/api-reference/agents/agent-request-upgrade
+
+/openapi.json post /v1/agents/request-upgrade
+For a FULLY AUTONOMOUS agent with no human who can upgrade: register interest in paying directly.
+
+We don't offer agent-native (no-human) payment yet, but we're gauging demand.
+Call this with your `sk_` key when you've hit your cap and genuinely cannot
+involve a human. We record it and follow up (or reach hello@superdocs.app).
+If a human CAN help, use POST /v1/agents/handoff instead GÇö that upgrades you now.
+
+
+
+# Agent Signup
+Source: https://docs.superdocs.app/api-reference/agents/agent-signup
+
+/openapi.json post /v1/agents/signup
+Create a SuperDocs account and get a working API key in one call GÇö no human required.
+
+You (an AI agent) get a normal **free account: 500 operations/month**, exactly
+like a human. Use the returned `sk_` key as `Authorization: Bearer ` on
+this API or the MCP server at `/mcp`. Store the key GÇö it is shown only once.
+Save the whole response to `~/.superdocs/agent_credentials.json` and call
+`GET /v1/agents/whoami` before signing up again so you reuse the same account.
+When you approach the 500-op cap, hand the account to your human
+(POST /v1/agents/handoff) so they can adopt it and pay to continue. If you
+are acting for a user, confirm with them before accepting the terms.
+
+
+
+# Check your account status: subscription tier plus monthly operations used and remaining.
+Source: https://docs.superdocs.app/api-reference/agents/check-your-account-status:-subscription-tier-plus-monthly-operations-used-and-remaining
+
+/openapi.json get /v1/agents/whoami
+Check your SuperDocs account status: tier, monthly operations used/remaining, and (for agent accounts) whether a human has adopted it.
+
+Call this with your `sk_` key. Useful before doing work (to confirm you have
+operations left) and before signing up again (to reuse your account).
+
+Nearing your monthly cap and need to keep going? Hand the account to a human:
+POST /v1/agents/handoff with their email returns a one-time takeover code to
+give them; they open the emailed link, enter the code, and adopt the account
+in place (your work and this API key keep working) so they can upgrade and
+pay. No human? POST /v1/agents/request-upgrade instead. Full flow:
+https://docs.superdocs.app/introduction/agent-signup
+
+
+
+# Api Catalog
+Source: https://docs.superdocs.app/api-reference/api-catalog
+
+/openapi.json get /.well-known/api-catalog
+
+
+
+# Health Check Deep
+Source: https://docs.superdocs.app/api-reference/health-check-deep
+
+/openapi.json get /health/deep
+Deep health check that verifies the job queue, AI workflow state store, and database are all responsive within a bounded time budget. Used by container orchestrators as a liveness probe to distinguish "process alive and TCP listening" (what /health reports) from "application is actually serving requests" (this endpoint).
+
+
+
+# Mcp Server Card
+Source: https://docs.superdocs.app/api-reference/mcp-server-card
+
+/openapi.json get /.well-known/mcp.json
+
+
+
+# Mcp Server Card
+Source: https://docs.superdocs.app/api-reference/mcp-server-card-1
+
+/openapi.json get /.well-known/mcp-server-card
+
+
+
+# Verify the SuperDocs MCP server is reachable and serving traffic.
+Source: https://docs.superdocs.app/api-reference/mcp/verify-the-superdocs-mcp-server-is-reachable-and-serving-traffic
+
+/openapi.json get /health
+Returns 200 with {"status":"healthy"} when the API is up. Call this once after MCP install to confirm the connection works before invoking other tools. No authentication required.
+
+
+
+# Oauth Authorization Server
+Source: https://docs.superdocs.app/api-reference/oauth-authorization-server
+
+/openapi.json get /mcp/.well-known/oauth-authorization-server
+
+
+
+# Oauth Authorization Server
+Source: https://docs.superdocs.app/api-reference/oauth-authorization-server-1
+
+/openapi.json get /.well-known/oauth-authorization-server/mcp
+
+
+
+# Oauth Authorization Server
+Source: https://docs.superdocs.app/api-reference/oauth-authorization-server-2
+
+/openapi.json get /.well-known/oauth-authorization-server
+
+
+
+# Oauth Protected Resource
+Source: https://docs.superdocs.app/api-reference/oauth-protected-resource
+
+/openapi.json get /mcp/.well-known/oauth-protected-resource
+
+
+
+# Oauth Protected Resource
+Source: https://docs.superdocs.app/api-reference/oauth-protected-resource-1
+
+/openapi.json get /.well-known/oauth-protected-resource/mcp
+
+
+
+# Oauth Protected Resource
+Source: https://docs.superdocs.app/api-reference/oauth-protected-resource-2
+
+/openapi.json get /.well-known/oauth-protected-resource
+
+
+
+# Openid Configuration
+Source: https://docs.superdocs.app/api-reference/openid-configuration
+
+/openapi.json get /mcp/.well-known/openid-configuration
+
+
+
+# Openid Configuration
+Source: https://docs.superdocs.app/api-reference/openid-configuration-1
+
+/openapi.json get /.well-known/openid-configuration/mcp
+
+
+
+# Openid Configuration
+Source: https://docs.superdocs.app/api-reference/openid-configuration-2
+
+/openapi.json get /.well-known/openid-configuration
+
+
+
+# List My Promotions
+Source: https://docs.superdocs.app/api-reference/promo/list-my-promotions
+
+/openapi.json get /v1/users/me/promotions
+List the authenticated user's promotion redemptions, split into `active` (drawable now)
+and `history` (exhausted / expired / revoked).
+
+Used by the Settings GåÆ Billing tab to render the Credits & Promotions section.
+
+
+
+# Redeem Promo
+Source: https://docs.superdocs.app/api-reference/promo/redeem-promo
+
+/openapi.json post /v1/promo/redeem
+Redeem a promo code and add the granted operations to the authenticated user's account.
+
+B2C only: web app login tokens and sk_ user API keys are accepted; lce_ org keys are rejected.
+Requires a verified email. Each user may redeem a given code at most once.
+
+Rate-limited to 3 attempts per IP per hour. All attempts (success and failure) are
+logged for audit.
+
+
+
+# Root
+Source: https://docs.superdocs.app/api-reference/root
+
+/openapi.json get /
+
+
+
+# Get a pre-signed URL to download an exported document without proxying through the agent.
+Source: https://docs.superdocs.app/api-reference/uploads/get-a-pre-signed-url-to-download-an-exported-document-without-proxying-through-the-agent
+
+/openapi.json post /v1/downloads
+Returns a short-lived (15-minute) GET URL plus a ready-to-run `curl_example`. An agent with shell access can run the `curl_example` directly to save the exported file to the working directory, so the bytes never pass through its context window; clients without shell execution (or callers who only want the link) can use the signed URL directly. Generates the document in the requested format and returns a time-limited signed download URL. Specify format as 'pdf', 'docx', 'html', 'markdown', or 'txt' (legacy 'doc' also accepted).
+
+
+
+# Get a pre-signed URL to upload large files (.docx/PDF/HTML/MD/RTF) without bloating agent context.
+Source: https://docs.superdocs.app/api-reference/uploads/get-a-pre-signed-url-to-upload-large-files-docxpdfhtmlmdrtf-without-bloating-agent-context
+
+/openapi.json post /v1/uploads
+Returns a short-lived (5-minute) PUT URL plus a ready-to-run `curl_example`. An agent with shell access can run the `curl_example` directly to push the file to cloud storage, so the bytes never pass through its context window; clients without shell execution can surface the command or URL for the caller to run. After upload completes, call process_uploaded_document with the upload_id to trigger parsing. For files <100KB where token cost is trivial, upload_document_base64 still works inline. Max file size: 100 MB. Supported formats: .pdf, .docx, .txt, .rtf, .md, .html, .htm, .tex (plus .zip LaTeX project archives).
+
+
+
+# Parse an uploaded file into structured HTML with chunk IDs for targeted AI editing.
+Source: https://docs.superdocs.app/api-reference/uploads/parse-an-uploaded-file-into-structured-html-with-chunk-ids-for-targeted-ai-editing
+
+/openapi.json post /v1/uploads/{upload_id}/process
+Fetches the file uploaded via request_upload_url and runs the same parsing pipeline as upload_document_base64: every paragraph, heading, table, row, and cell gets a unique chunk ID, enabling targeted structural edits via chat ("remove row 3 of the pricing table" works), with tables, borders, shading, alternating row colors, fonts, and inline styling preserved on edit and export. By default the response is compact (metadata only, no document body) to keep your context small GÇö the document is loaded into the session and you edit it via chat; pass return_html=true if you need the parsed HTML inline. Uploading and parsing is NOT itself a billable operation GÇö you are charged only when the AI edits the document. Specify parse_mode='document' to load as the active editable document, or parse_mode='attachment' to load as a read-only AI-searchable reference.
+
+
+
+# Check User Limits
+Source: https://docs.superdocs.app/api-reference/users/check-user-limits
+
+/openapi.json get /v1/users/me/limits
+Check current usage limits and remaining operations
+
+Returns real-time usage information with reset date
+
+
+
+# Create Api Key
+Source: https://docs.superdocs.app/api-reference/users/create-api-key
+
+/openapi.json post /v1/users/me/api-keys
+Create a new API key for the current user.
+
+The raw key is returned ONCE in the response. It cannot be retrieved again.
+
+
+
+# Delete User Session
+Source: https://docs.superdocs.app/api-reference/users/delete-user-session
+
+/openapi.json delete /v1/users/me/sessions/{session_id}
+Delete a specific chat session
+
+Removes all messages for the given session ID (user must own the session)
+
+
+
+# Get Current User Profile
+Source: https://docs.superdocs.app/api-reference/users/get-current-user-profile
+
+/openapi.json get /v1/users/me
+Get current user's profile information
+
+Returns user profile with subscription tier and usage limits
+
+
+
+# Get User Sessions
+Source: https://docs.superdocs.app/api-reference/users/get-user-sessions
+
+/openapi.json get /v1/users/me/sessions
+List all chat sessions for current user
+
+Returns list of sessions with last activity and message counts
+
+
+
+# Get User Usage Stats
+Source: https://docs.superdocs.app/api-reference/users/get-user-usage-stats
+
+/openapi.json get /v1/users/me/usage
+Get detailed usage statistics for current user
+
+Returns operation counts, token usage, and success rates by operation type
+
+
+
+# List Api Keys
+Source: https://docs.superdocs.app/api-reference/users/list-api-keys
+
+/openapi.json get /v1/users/me/api-keys
+List all API keys for the current user (masked).
+
+Returns key prefix and last 4 characters for identification.
+
+
+
+# Revoke Api Key
+Source: https://docs.superdocs.app/api-reference/users/revoke-api-key
+
+/openapi.json delete /v1/users/me/api-keys/{key_id}
+Revoke (soft delete) an API key.
+
+The key will be marked as inactive and can no longer be used for authentication.
+
+
+
+# Update User Profile
+Source: https://docs.superdocs.app/api-reference/users/update-user-profile
+
+/openapi.json patch /v1/users/me
+Update current user's profile
+
+Allows updating display name, timezone, language, and preferences
+
+
+
+# Approve or deny AI-proposed document changes one-by-one or in batch (HITL workflow).
+Source: https://docs.superdocs.app/api-reference/v1/approve-or-deny-ai-proposed-document-changes-one-by-one-or-in-batch-hitl-workflow
+
+/openapi.json post /v1/chat/{session_id}/approve
+Used with chat_async when approval_mode='ask_every_time'. For each proposed change (with chunk_id and HTML diff), respond approved=true|false plus optional feedback for the AI to revise on. Approved changes apply atomically; denied changes are discarded; the AI may revise based on feedback in the next turn. Required for regulated workflows (legal, medical, compliance) where every AI edit must be reviewed before it touches the document. For single changes: set approved=true/false and optionally provide feedback. For batch decisions: provide a 'changes' array with per-change decisions. After approval, the job resumes processing and eventually reaches status=completed.
+
+
+
+# Cancel a pending or in-progress async chat job.
+Source: https://docs.superdocs.app/api-reference/v1/cancel-a-pending-or-in-progress-async-chat-job
+
+/openapi.json post /v1/jobs/{job_id}/cancel
+Stops the AI mid-edit. Already-applied changes are preserved in the document; pending changes are discarded. Use to abort long-running operations that are no longer needed (e.g., user changed their mind, or you want to retry with different parameters or model_tier). Only jobs with status pending or processing can be cancelled. Returns the updated job details with status cancelled.
+
+
+
+# Check processing status of all attachments in a session.
+Source: https://docs.superdocs.app/api-reference/v1/check-processing-status-of-all-attachments-in-a-session
+
+/openapi.json get /v1/attachments/status/{session_id}
+Returns each attachment's processing state (pending/processing/completed/failed) plus extracted text length and chunk count once ready. Poll this after upload_attachment_base64 to know when an attachment becomes queryable by the AI. Surfaces processing errors with actionable messages.
+
+
+
+# Clear your cross-session memory note.
+Source: https://docs.superdocs.app/api-reference/v1/clear-your-cross-session-memory-note
+
+/openapi.json delete /v1/users/me/cross-session-memory
+Delete the caller's cross-session memory note. Owner-scoped: a caller can only clear its OWN
+note. With no key this clears the account-level note; with a memory_key it clears that one
+end-customer's note. Idempotent (removed=0 if it didn't exist).
+
+
+
+# Close (remove) a document from a multi-document session.
+Source: https://docs.superdocs.app/api-reference/v1/close-remove-a-document-from-a-multi-document-session
+
+/openapi.json delete /v1/sessions/{session_id}/documents/{document_id}
+Remove document_id from the session's open documents (the tab close button). If it's the
+focused document, focus another open document GÇö `next_focus` if it's still open (so the client
+can pick the adjacent tab), else the next remaining one; closing the last document leaves the
+session empty. Returns the updated document roster + the now-focused document's HTML.
+
+The close takes effect immediately and is persisted, so a later reconnect or session restore
+won't bring the closed document back. Persistence is best-effort GÇö a failure is logged, not
+fatal (the close still holds for the active session).
+
+
+
+# Delete a saved document template by ID.
+Source: https://docs.superdocs.app/api-reference/v1/delete-a-saved-document-template-by-id
+
+/openapi.json delete /v1/templates/{template_id}
+Soft-deletes the template; only the owner (user or organization) can delete. Once deleted, the template no longer appears in list_user_templates and cannot be referenced by the AI for new documents. Existing documents already drafted from the template are unaffected.
+
+
+
+# Delete (archive) a saved document.
+Source: https://docs.superdocs.app/api-reference/v1/delete-archive-a-saved-document
+
+/openapi.json delete /v1/documents/{document_id}
+Soft-archive a saved document GÇö it leaves the Files view but is recoverable server-side.
+Presence-aware: if the document is currently open in ANOTHER session and `force` is not set,
+the request FAILS honestly with HTTP 409 (`code:"document_in_use"` + `open_in_sessions:N` +
+`suggested_action`) so callers can confirm "open in N other session(s) GÇö delete anyway?" and
+re-call with `force=true`. On `force=true` it archives, unlinks every session, and notifies the
+other sessions (which then prompt the user to keep editing or honor the deletion).
+
+A no-op never wears a success status: if nothing was archived you get the 409 above, so a
+2xx always means the archive actually happened.
+
+
+
+# Edit, draft, or restructure a document using natural language. Preserves tables, styling, and formatting.
+Source: https://docs.superdocs.app/api-reference/v1/edit-draft-or-restructure-a-document-using-natural-language-preserves-tables-styling-and-formatting
+
+/openapi.json post /v1/chat
+Synchronous AI chat that can rewrite specific paragraphs, add or remove table rows, restructure sections, generate new content from templates, or transform an entire document. Pass document_html only to load or replace the document; once a session holds a document the server persists it across turns, so omit it on follow-up turns. To replace the whole document with EXACT content you already hold, pass that content in document_html (a verbatim load GÇö the AI never re-types it) or upload it via upload_document_base64; describing the content only in `message` makes the AI author it, which can drift from your text. Returns AI response text plus structural document changes (HTML edits, additions, deletions) with chunk IDs. One billable operation per document-modifying turn; very large multi-section edits bill one operation per 25 sections changed. For long-running edits or human-in-the-loop approval, use chat_async. Optional: model_tier (core/turbo/pro/max), thinking_depth (fast/balanced/deep), image_attachments for multimodal vision. RECOMMENDED for AI agents working with large documents (>20 pages): set response_mode='compact' to skip the full HTML in the response (the AI returns only per-section diffs in chunk_diffs) and save thousands of tokens per turn. To read sections in compact mode, just send a natural-language request like 'show me the force majeure section' GÇö the AI returns the content in the reply text. Always use natural language to describe what you want; the AI handles all internal section lookups.
+
+
+
+# Export the current document as a styled .docx (default), .pdf, .html, .md, or .txt file with full fidelity.
+Source: https://docs.superdocs.app/api-reference/v1/export-the-current-document-as-a-styled-docx-default-pdf-html-md-or-txt-file-with-full-fidelity
+
+/openapi.json post /v1/documents/export
+Round-trips the document through the original docx renderer, preserving tables, borders, shading, alternating row colors, headers, footers, fonts, inline styling, and embedded images. Two modes: pass html directly to export ad-hoc content, OR pass session_id to export the session's current state. Format options: 'docx' (default, native Open XML, best for programmatic processing or mail merge), 'pdf', 'html', 'markdown', or 'txt' ('doc' is a legacy Word-compatible HTML alias). Fidelity is the differentiator vs naive HTML to docx converters.
+
+
+
+# Get a saved document's detail, structure outline, and the chats that used it.
+Source: https://docs.superdocs.app/api-reference/v1/get-a-saved-documents-detail-structure-outline-and-the-chats-that-used-it
+
+/openapi.json get /v1/documents/{document_id}
+One saved document's metadata plus a STRUCTURE outline and the chat sessions that have it
+open (prior-chats-per-file). Owner-scoped GÇö 404 if the document isn't yours.
+
+`structure` (always included, token-light, NON-BILLABLE) is the cheap verify step after
+any edit: `headings` (level + text + position), `section_count` (heading-anchored), `block_count`
+(editable chunks), `media` (image/diagram counts). It answers "did my edit land?" for free GÇö
+never export a whole document just to check its structure. Pass include_html=true only when
+you need the body.
+
+
+
+# Get the status, partial results, and any pending changes for an async chat job.
+Source: https://docs.superdocs.app/api-reference/v1/get-the-status-partial-results-and-any-pending-changes-for-an-async-chat-job
+
+/openapi.json get /v1/jobs/{job_id}
+Returns the job's current state (pending, in_progress, awaiting_approval, completed, failed, or cancelled), intermediate AI responses streamed during execution, the final document HTML once complete, and any pending changes awaiting user approval (with chunk IDs and proposed HTML diffs in metadata.pending_changes). Poll this after chat_async to track progress and retrieve results. When status is awaiting_approval, call POST /v1/chat/{session_id}/approve to approve or deny each pending change. Headless/MCP clients get the same typed progress events the web SSE stream delivers via metadata.intermediate_responses (e.g. documents_changed, continue_prompt, model_fallback, proposed_change_batch); cross-session document updates from OTHER sessions are reported separately by polling GET /v1/sessions/{session_id}/doc-events.
+
+
+
+# List all async chat jobs for a specific session, most recent first.
+Source: https://docs.superdocs.app/api-reference/v1/list-all-async-chat-jobs-for-a-specific-session-most-recent-first
+
+/openapi.json get /v1/sessions/{session_id}/jobs
+Returns the full job history of one document. Useful for auditing what the AI did to a document over time, or finding a specific job that's waiting for HITL approval. Same shape as list_jobs but scoped to one session.
+
+
+
+# List all saved document templates available to the user or organization.
+Source: https://docs.superdocs.app/api-reference/v1/list-all-saved-document-templates-available-to-the-user-or-organization
+
+/openapi.json get /v1/templates
+Returns active (non-deleted) templates with name, format, size, and creation date metadata. Use to show the AI what reusable document structures are available for drafting new documents. Templates are scoped to the authenticated entity: users via the web app or sk_ key see only their own templates; organizations via lce_ key see theirs.
+
+
+
+# List the documents open in a multi-document session.
+Source: https://docs.superdocs.app/api-reference/v1/list-the-documents-open-in-a-multi-document-session
+
+/openapi.json get /v1/sessions/{session_id}/documents
+Returns the editable documents open in this session (focused first), each with its id, chunk
+count, and focused flag GÇö so a client can render document tabs. The response is token-light by
+default; pass include_html=true to also get each document's reassembled HTML.
+
+ID MAPPING: `document_id` is the SESSION-LOCAL slot id ("doc_primary", "doc_ab12GǪ");
+`durable_document_id` is the PERMANENT documents.id UUID GÇö the SAME id `list_documents` (Files)
+shows and `get_document_detail` / `rename_document` / `archive_document` / `open_documents`
+take (null until the document's first save). focus_session_document, close_session_document,
+and chat's `document_id` accept EITHER form, so you can drive a session entirely with durable
+ids.
+
+
+
+# List your active document editing sessions to resume or audit prior work.
+Source: https://docs.superdocs.app/api-reference/v1/list-your-active-document-editing-sessions-to-resume-or-audit-prior-work
+
+/openapi.json get /v1/sessions
+Returns sessions sorted by most recent activity, with message counts and last-updated timestamps. Each session represents one document with full edit history and AI conversation persisted server-side. Use to find a previous editing context to resume (then call get_session_history) or to audit your workspace.
+
+
+
+# List your async chat jobs (in-progress, awaiting approval, completed, failed).
+Source: https://docs.superdocs.app/api-reference/v1/list-your-async-chat-jobs-in-progress-awaiting-approval-completed-failed
+
+/openapi.json get /v1/jobs
+Returns jobs sorted by most recent, optionally filtered by status. Use to monitor long-running AI edits started via chat_async, see which jobs are paused waiting for human approval, or audit completed work. Each job tracks the chat that started it, the changes made, and any HITL decisions logged.
+
+
+
+# List your saved documents (the Files view).
+Source: https://docs.superdocs.app/api-reference/v1/list-your-saved-documents-the-files-view
+
+/openapi.json get /v1/documents
+List the authenticated user/org's saved documents, most-recently-updated first
+(metadata only GÇö no document content). Each carries a session_count ("N chats"). Documents
+become durable + reusable automatically as you create or edit them; on the FIRST call we also
+one-time import the documents of pre-Files-view chats so prior work appears here too.
+
+
+
+# Open a new blank document as a tab in the session.
+Source: https://docs.superdocs.app/api-reference/v1/open-a-new-blank-document-as-a-tab-in-the-session
+
+/openapi.json post /v1/sessions/{session_id}/documents/blank
+Open a fresh BLANK document as a new focused tab in the session (the tab-strip "+").
+Non-billable GÇö no AI, no upload pipeline; it is saved on first edit.
+REST-only (a manual UI affordance, not an MCP tool GÇö agents create documents through chat).
+
+
+
+# Open saved documents into a chat session (shared, never copied).
+Source: https://docs.superdocs.app/api-reference/v1/open-saved-documents-into-a-chat-session-shared-never-copied
+
+/openapi.json post /v1/sessions/{session_id}/documents/open
+Load one or more SAVED documents into a session as editable tabs. The SAME durable
+document is attached (never copied), so edits flow back to the one shared row with
+cross-session soft-collaboration. The first listed document is focused. Returns the
+refreshed document roster so the client can render tabs immediately.
+
+
+
+# Persist a user-edited document (non-AI autosave).
+Source: https://docs.superdocs.app/api-reference/v1/persist-a-user-edited-document-non-ai-autosave
+
+/openapi.json post /v1/sessions/{session_id}/documents/{document_id}/save
+Persist a HUMAN-edited document GÇö the editor's debounced autosave + save-on-blur GÇö WITHOUT
+an AI turn. Re-indexes the html into the target document (preserving its durable identity so it
+UPDATES the same Files entry), then saves it so pure typing is preserved AND other sessions with
+the same document open stay in sync. The AI-edit flow is unaffected (autosave saves first; a
+later AI result is merged with your saved edits). Non-billable, REST-only (a UI affordance, not an MCP tool).
+
+
+
+# Poll Session Updates
+Source: https://docs.superdocs.app/api-reference/v1/poll-session-updates
+
+/openapi.json get /v1/poll/{session_id}
+Long-poll for real-time job updates on a session (B2B organizations only).
+
+Returns immediately if there are recent job updates, or holds the connection
+open up to the specified timeout. Use the 'since' parameter to avoid receiving
+duplicate updates.
+
+
+
+# Re-apply the AI's intended edit on top of the user's current version of one section.
+Source: https://docs.superdocs.app/api-reference/v1/re-apply-the-ais-intended-edit-on-top-of-the-users-current-version-of-one-section
+
+/openapi.json post /v1/sessions/{session_id}/chunks/{chunk_id}/re-edit
+Resolve a concurrent-edit conflict on ONE section. When a user edited a section while the AI
+was also changing it, this re-applies the AI's intended change on top of the user's current text
+(mode="redo"), or blends the user's and AI's versions into one (mode="merge"). Returns only the
+rewritten section HTML. Performs one AI edit and counts as one billable operation.
+
+
+
+# Remove an attachment from a session or cancel its in-progress processing.
+Source: https://docs.superdocs.app/api-reference/v1/remove-an-attachment-from-a-session-or-cancel-its-in-progress-processing
+
+/openapi.json delete /v1/attachments/{attachment_id}
+Removes the attachment from the session; the AI will no longer reference it in subsequent chat turns. If processing is still in progress, also cancels the underlying job. Use to free up context, remove sensitive files mid-session, or replace a stale reference document. The attachment_id can be either the final attachment ID or the job ID (during processing).
+
+
+
+# Rename a saved document and/or update its out-of-flow parts (headers, footers, footnotes, endnotes, comments, sections).
+Source: https://docs.superdocs.app/api-reference/v1/rename-a-saved-document-andor-update-its-out-of-flow-parts-headers-footers-footnotes-endnotes-comments-sections
+
+/openapi.json patch /v1/documents/{document_id}
+Rename one of your saved documents and/or update its out-of-flow parts. Parts are
+the document's out-of-flow content GÇö headers/footers, footnote and endnote bodies,
+comments, and per-section page geometry. Every fragment is sanitized on write, writes
+are versioned and safe under concurrent editors, and parts revert with the document.
+A title-only call behaves exactly as the original rename (response `status:"renamed"`).
+Part content lives in the document body and is edited exactly like any other document
+content (via chat or the document write endpoints); a legacy `parts` payload returns
+400 `parts_moved_to_chunks` pointing you there.
+
+
+
+# Request higher document/chat scale limits in one call GÇö no email needed.
+Source: https://docs.superdocs.app/api-reference/v1/request-higher-documentchat-scale-limits-in-one-call-GÇö-no-email-needed
+
+/openapi.json post /v1/limits/increase-request
+Request a higher scale limit for your account in ONE call.
+
+WHEN TO USE: after any 413 with error_code DOCUMENT_TOO_COMPLEX (a single
+document crossed the standard per-document page limit) or SESSION_TOO_FULL
+(the documents open in one chat crossed the standard per-chat limit). These
+are stability limits, not hard caps: the platform supports larger
+deployments, and limits are raised per account on request.
+
+WHAT HAPPENS: the SuperDocs team is notified immediately with your account
+identity and the numbers you send; limits are typically expanded within a
+day and you are contacted at your account email. No email writing needed GÇö
+though hello@superdocs.app also works if you prefer.
+
+ALTERNATIVES while you wait: split the file into smaller documents, upload
+it as an attachment (reference/search, not editing), start another session
+for additional documents (init_session), or close documents you no longer
+need (close_session_document).
+
+
+
+# Request Large Export Email
+Source: https://docs.superdocs.app/api-reference/v1/request-large-export-email
+
+/openapi.json post /v1/documents/export/email-request
+Enqueue a large-export job GÇö runs in the background and emails a
+secure, time-limited download link when the export finishes. 24h SLA
+promised in the email.
+
+
+
+# Restore a document another session archived, keeping your current edits (web app).
+Source: https://docs.superdocs.app/api-reference/v1/restore-a-document-another-session-archived-keeping-your-current-edits-web-app
+
+/openapi.json post /v1/sessions/{session_id}/documents/{document_id}/unarchive
+Restore (un-archive) a document that was archived, re-linking it to this session and bringing
+its content back. `document_id` is the session-local id (same as /save); the durable id is
+recovered from the cached document. Idempotent: restoring an already-active (or never-archived)
+document is a no-op. Non-billable.
+
+Two shapes: WITHOUT a body (e.g. an AI agent restoring a document by id) the archived content is
+restored as-is; WITH a body carrying the current editor HTML (the web app's "keep editing
+(restores it)" choice after another session deleted the document) the document is restored AND the
+supplied edits are saved on top, so it converges for everyone.
+
+
+
+# Restore the full conversation and document state for a previous session.
+Source: https://docs.superdocs.app/api-reference/v1/restore-the-full-conversation-and-document-state-for-a-previous-session
+
+/openapi.json get /v1/sessions/{session_id}/history
+Returns complete message history (user and AI), final document HTML with chunk IDs preserved, attachment list, and editor actions (font, color, alignment changes). Use to continue editing a document you started in a prior session. The AI rehydrates with full context of all prior decisions, chunk IDs, and attachments. Pass include_document_html=false to skip the full document body when you only need the conversation.
+
+
+
+# Restore (un-archive) a previously archived document by its id.
+Source: https://docs.superdocs.app/api-reference/v1/restore-un-archive-a-previously-archived-document-by-its-id
+
+/openapi.json post /v1/documents/{document_id}/unarchive
+Restore (un-archive) a saved document by its id GÇö the mirror of archive_document. The document
+re-enters your Files view and can be opened/edited again (open it into a chat with open_documents).
+`document_id` is the same id used by list_documents / archive_document. Idempotent: restoring an
+already-active (or unknown) document is a no-op. Non-billable.
+
+
+
+# Resume or stop a chat turn paused by a large-edit continue prompt.
+Source: https://docs.superdocs.app/api-reference/v1/resume-or-stop-a-chat-turn-paused-by-a-large-edit-continue-prompt
+
+/openapi.json post /v1/chat/{session_id}/continue
+Used with chat_async when a large edit paused to ask whether to keep going.
+Poll get_job until status=awaiting_approval AND metadata.awaiting_kind='continue_prompt';
+then POST here with continue=true to resume (the AI picks up where it left off with a
+fresh time/step budget) or continue=false to stop (everything applied so far is kept).
+The job then resumes/finishes and reaches status=completed. This is NOT the change-
+approval endpoint (that is approve_change) GÇö it can only act on a continue-prompt pause.
+
+
+
+# Rewind a chat session to before a specific user message GÇö restores both the document and the conversation in one call.
+Source: https://docs.superdocs.app/api-reference/v1/rewind-a-chat-session-to-before-a-specific-user-message-GÇö-restores-both-the-document-and-the-conversation-in-one-call
+
+/openapi.json post /v1/sessions/{session_id}/revert
+Rewinds a chat session to the state immediately before a specific user message. The document, conversation history, and supporting context all snap back to that point. The text of the reverted message is returned (compose_text) so the caller can edit and resend it. Messages from the reverted turn forward are soft-archived: hidden from active reads but retained for audit. The original conversation is preserved server-side; the restored conversation becomes the active timeline. Rejects with 409 if the session has a chat job in progress or awaiting approval GÇö wait for the job to settle before reverting. Returns 422 if the message predates the revertability feature.
+
+
+
+# Save a document template (NDA, contract, SOP, letterhead) for reuse across sessions.
+Source: https://docs.superdocs.app/api-reference/v1/save-a-document-template-nda-contract-sop-letterhead-for-reuse-across-sessions
+
+/openapi.json post /v1/templates/upload-base64
+Templates persist across sessions and can be referenced by the AI when drafting new documents (e.g., "draft an NDA using my standard template"). Stored at user or organization scope. Ideal for boilerplate, branded letterheads, recurring document structures, or compliance-required templates. Supports the same formats as document upload: .docx, .pdf, .txt, .rtf, .md, .html, .htm. Max 50 MB validated; note the hosted transport layer caps any REQUEST BODY at ~32 MB and rejects larger ones at the gateway with an HTML (not JSON) error GÇö base64 inflates content ~33%, so inline uploads are reliable up to ~20 MB of file bytes; beyond that use request_upload_url (pre-signed flow, up to 100 MB).
+
+
+
+# Session Document Events
+Source: https://docs.superdocs.app/api-reference/v1/session-document-events
+
+/openapi.json get /v1/sessions/{session_id}/doc-events
+Poll for cross-session document updates: returns `document_updated` events for documents
+THIS session holds that OTHER sessions committed since `after_id`. The client polls this
+(~every 1-2s while a doc is open) and re-fetches the changed document's content so all open
+sessions converge without a refresh. Excludes this session's own writes by default (the web UI
+already has its own changes); a REST/MCP integrator can pass `include_own=true` to receive its
+own events too. REST-only GÇö a lightweight poll, deliberately NOT an MCP tool and NOT a held
+SSE connection (keeps the per-instance connection budget free).
+
+
+
+# Start a long-running or HITL-approved AI edit; returns a job_id to poll for results.
+Source: https://docs.superdocs.app/api-reference/v1/start-a-long-running-or-hitl-approved-ai-edit;-returns-a-job_id-to-poll-for-results
+
+/openapi.json post /v1/chat/async
+Use instead of chat when (a) the edit is large or multi-step, (b) you need human approval on each proposed change before it applies (set approval_mode='ask_every_time'), or (c) you can't afford to block on a synchronous response. To replace the whole document with EXACT content you already hold, pass that content in document_html (a verbatim load GÇö the AI never re-types it) or upload it via upload_document_base64; describing the content only in `message` makes the AI author it, which can drift from your text. Returns a job_id immediately. Poll get_job to track progress and retrieve the final document. Approve or deny pending changes via approve_change. Job state is durable and survives server restarts. Optional: model_tier (core/turbo/pro/max), thinking_depth (fast/balanced/deep), image_attachments for multimodal vision. RECOMMENDED for AI agents working with large documents (>20 pages): set response_mode='compact' so the job result skips the full HTML and surfaces only per-section diffs in chunk_diffs, saving thousands of tokens per poll. To read sections in compact mode, just send a natural-language chat request ('show me the pricing section') GÇö the AI returns the content in the reply text. Always use natural language; the AI handles all internal section lookups. HITL workflow (approval_mode='ask_every_time'): 1) poll get_job until status=awaiting_approval, 2) read metadata.pending_changes for proposed edits, 3) call approve_change per change, 4) continue polling until status=completed.
+
+
+
+# Start a new chat session and open documents into it in one call.
+Source: https://docs.superdocs.app/api-reference/v1/start-a-new-chat-session-and-open-documents-into-it-in-one-call
+
+/openapi.json post /v1/sessions/init
+Create a session AND open N saved documents into it in ONE call (the documents are SHARED,
+never copied GÇö the same cross-session behavior as /documents/open). The web UI passes its own
+session_id so its session model stays consistent; an MCP/API integrator can omit it and the
+server mints one GÇö the one-call way to start a session with documents already open. With no
+document_ids it just returns a fresh, empty session. The open semantics (first document focused,
+durable binding, returned roster) are identical to /documents/open.
+
+
+
+# Stream Chat Progress
+Source: https://docs.superdocs.app/api-reference/v1/stream-chat-progress
+
+/openapi.json get /v1/chat/{session_id}/stream
+Stream real-time progress for a chat job using Server-Sent Events (SSE).
+
+Opens an SSE connection that streams events as the AI processes your request.
+Use this with the job_id returned from POST /v1/chat/async.
+
+Event types:
+- 'intermediate': Progress updates during processing (content, sequence, timestamp).
+- 'proposed_change_batch': The batch of document changes proposed for review, delivered as one
+ event carrying changes[] (emitted when approval_mode is 'ask_every_time'). Changes are always
+ delivered as a batch via this event.
+- 'document_sync': Chunk-id sync emitted before the agent runs, so changes can reference stable ids.
+- 'continue_prompt': A pause on a large edit, asking whether to continue or stop.
+- 'documents_changed': Signals that one or more documents were auto-applied (with per-document
+ change counts and changed chunk ids).
+- 'model_fallback': Notice that the request automatically failed over to another model tier.
+- 'final': Processing complete. Contains the full result with AI response and document changes.
+- 'usage': Billing data emitted after 'final' (monthly_used, monthly_limit, monthly_remaining).
+- 'error': An error occurred (job failed, cancelled, or not found).
+
+Authentication: Pass a token or api_key as a query parameter (required for EventSource which cannot set headers).
+
+
+
+# Switch which document is focused in a multi-document session.
+Source: https://docs.superdocs.app/api-reference/v1/switch-which-document-is-focused-in-a-multi-document-session
+
+/openapi.json post /v1/sessions/{session_id}/documents/{document_id}/focus
+Make document_id the focused document (the editor's active document and the default edit
+target). Accepts either the session slot id OR the durable documents.id UUID (the id shown by
+list_documents/Files). Persists the outgoing focused document into the session's
+document map and returns the now-focused document's HTML.
+
+
+
+# Undo a revert: restore the pre-revert state and the rolled-back turns.
+Source: https://docs.superdocs.app/api-reference/v1/undo-a-revert:-restore-the-pre-revert-state-and-the-rolled-back-turns
+
+/openapi.json post /v1/sessions/{session_id}/redo
+Revert is non-destructive: this restores the session FORWARD to the pre-revert state
+(returned by the revert as redo_checkpoint_id), restores the document to that state while still
+merging any concurrent edits from other sessions (keeping both where they conflict), and
+un-archives exactly the chat turns the revert hid. Intended for use immediately after a revert;
+once a new message is sent the timeline diverges and the web app stops offering it.
+
+
+
+# Upload a base64-encoded image and get back a stable URL to embed in a document via .
+Source: https://docs.superdocs.app/api-reference/v1/upload-a-base64-encoded-image-and-get-back-a-stable-url-to-embed-in-a-document-via-
+
+/openapi.json post /v1/documents/images/upload-base64
+Upload an image (base64-encoded) and get a stable public URL to reference
+in a document via .
+
+Accepts PNG/JPEG/WebP/GIF/SVG, up to 10 MB decoded. Send raw base64 or a
+data: URL in image_base64. This is the agent-friendly counterpart to the
+browser multipart upload GÇö use it to save a generated or fetched image so a
+document can embed it.
+
+
+
+# Upload a reference file (PDF/DOCX/image) for the AI to query while editing.
+Source: https://docs.superdocs.app/api-reference/v1/upload-a-reference-file-pdfdocximage-for-the-ai-to-query-while-editing
+
+/openapi.json post /v1/attachments/upload-base64
+Files are processed asynchronously and become AI-searchable once ready. The AI can then reference the attachment's content during chat (e.g., "rewrite section 3 to match the style guide PDF I attached"). Images are queryable via multimodal vision. Poll get_attachment_status to know when ready. Distinct from upload_document_base64: attachments are read-only context for the AI to reference, not the editable working document. Supported formats: .pdf, .docx, .txt, .rtf, .md, .html, .htm. Max 50 MB validated; note the hosted transport layer caps any REQUEST BODY at ~32 MB and rejects larger ones at the gateway with an HTML (not JSON) error GÇö base64 inflates content ~33%, so inline uploads are reliable up to ~20 MB of file bytes; beyond that use request_upload_url (pre-signed flow, up to 100 MB). Requires session_id.
+
+
+
+# Upload an image and get back a stable URL the editor can drop into .
+Source: https://docs.superdocs.app/api-reference/v1/upload-an-image-and-get-back-a-stable-url-the-editor-can-drop-into-
+
+/openapi.json post /v1/documents/images/upload
+Upload a single inline image and return a stable URL you can reference
+in the document via .
+
+Accepts PNG/JPEG/WebP/GIF/SVG, up to 10 MB per upload. The returned URL is
+public-read with an unguessable path. Useful for saving a drawing or
+screenshot so a document can embed it.
+
+
+
+# Upload Attachment
+Source: https://docs.superdocs.app/api-reference/v1/upload-attachment
+
+/openapi.json post /v1/attachments/upload
+Upload a document attachment to a session for AI reference.
+
+The file is processed asynchronously GÇö text is extracted, converted, and indexed
+so the AI can search and reference it during chat. Use GET /v1/attachments/status/{session_id}
+to check processing progress.
+
+Supported file types: .pdf, .docx, .txt, .rtf, .md, .html, .htm (max 50 MB).
+Returns a job_id for tracking the processing status.
+
+
+
+# Upload Document To Editor
+Source: https://docs.superdocs.app/api-reference/v1/upload-document-to-editor
+
+/openapi.json post /v1/documents/upload
+Upload a document file and load it into the editor.
+
+Converts the file to HTML with formatting preserved (tables, colors, images).
+Images are extracted to cloud storage and referenced by URL.
+Returns the HTML for the editor to display.
+
+AI indexing happens automatically on the first chat message.
+For API clients who want immediate indexing, pass ?index=true.
+
+
+
+# Upload .docx/PDF/HTML/MD/RTF as the active editable document with chunk-ID structural editing.
+Source: https://docs.superdocs.app/api-reference/v1/upload-docxpdfhtmlmdrtf-as-the-active-editable-document-with-chunk-id-structural-editing
+
+/openapi.json post /v1/documents/upload-base64
+Parses the file into structured HTML where every paragraph, heading, table, row, and cell has a unique chunk ID, enabling the AI to make targeted structural edits via chat ("remove row 3 of the pricing table" works). Tables, borders, shading, alternating row colors, fonts, and inline styling are preserved on edit and export. Also works for AI-generated content: if you've drafted an outline or partial document in your context, upload it here as the working doc, then use chat to fill in the rest. This is also the reliable way to REPLACE a session's document with exact content you already hold GÇö the upload is a verbatim load, so it can never come back paraphrased or placeholder-shaped the way asking chat to re-type it can. When you pass a session_id the response is compact by default (metadata only GÇö pass return_html=true for the full parsed HTML); the document is loaded into the session and you edit it via chat. For files >100KB, prefer request_upload_url (pre-signed URL flow) to avoid token bloat from base64 through the agent context. Supported formats: .pdf, .docx, .txt, .rtf, .md, .html, .htm. Max 50 MB validated; note the hosted transport layer caps any REQUEST BODY at ~32 MB and rejects larger ones at the gateway with an HTML (not JSON) error GÇö base64 inflates content ~33%, so inline uploads are reliable up to ~20 MB of file bytes; beyond that use request_upload_url (pre-signed flow, up to 100 MB).
+
+
+
+# Upload User Template
+Source: https://docs.superdocs.app/api-reference/v1/upload-user-template
+
+/openapi.json post /v1/templates/upload
+Upload a document file as a personal/organization template.
+
+The file is processed synchronously: text extraction, HTML conversion, and content indexing.
+Supported formats: .docx, .pdf, .txt, .rtf, .md, .html, .htm
+Templates are scoped to the uploading user or organization.
+
+
+
+# Attachments
+Source: https://docs.superdocs.app/concepts/attachments
+
+Upload PDF, DOCX, HTML, text, and image files to give the AI additional context or load them into the editor.
+
+# Attachments
+
+Upload files to provide the AI with reference material. The AI can search and reference attachment content when editing your document GÇö or you can load an attachment directly into the editor.
+
+## Supported file types
+
+| Type | Extensions |
+| ------------ | ---------------------------------------- |
+| PDF | `.pdf` |
+| Word | `.docx`, `.doc` |
+| OpenDocument | `.odt` |
+| HTML | `.html`, `.htm` |
+| Plain text | `.txt` |
+| Rich text | `.rtf` |
+| Markdown | `.md` |
+| LaTeX | `.tex` (plus `.zip` project archives) |
+| Images | `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp` |
+
+Legacy Word (`.doc`), OpenDocument (`.odt`), and Rich Text (`.rtf`) files are normalized into the same rich pipeline as `.docx`, so their headings, tables, and styling carry through.
+
+**LaTeX projects.** A `.tex` file uploads directly. A multi-file LaTeX project GÇö the shape editors export: a root `.tex` plus its `\input` chapters, figures, bibliography, and any custom class files GÇö uploads as a single `.zip`; the root document is detected automatically. Math imports losslessly (every equation keeps its LaTeX source and exports as native Word math), footnotes become real footnotes, bibliographies render with their citations, and source-declared page geometry, font size, and line spacing carry into the editor. Documents built on heavily visual custom classes (many r+¬sum+¬ and poster templates) are compiled and imported with their visual layout preserved. A `.zip` that contains no LaTeX root returns a structured error, and anything that can't be represented (for example an EPS figure or a TikZ drawing) is marked with a visible placeholder plus an ingest warning GÇö never silently dropped. Standard BibTeX (`.bib`) bibliographies resolve fully; biblatex-specific flows may degrade to bracketed citation keys plus an ingest warning.
+
+Maximum file size: up to **100 MB** through the pre-signed upload flow (`request_upload_url` GåÆ PUT GåÆ process) GÇö the path the web app and SDKs use automatically for anything large. Direct inline and multipart uploads are reliable up to about **20 MB**; an API-gateway request-body limit caps direct uploads below the pre-signed ceiling, so send larger files through the pre-signed flow (for files over \~100 KB it is preferable anyway, since bytes never pass through your context window). Oversized or unsupported uploads return a structured error naming the supported formats and the applicable limit.
+
+**Document scale.** A single document can expand into up to **25,000 editable sections** (roughly 6,000 pages), and one chat session can hold up to **30,000 sections** across all of its open documents (roughly 7,500 pages). These are stability limits for the hosted service, not platform caps: there is no hard platform limit on document length or on how many documents you work with in parallel. Crossing a limit returns a structured `413` (`DOCUMENT_TOO_COMPLEX` for a single file, `SESSION_TOO_FULL` for a chat) with `limit_expandable: true` and a contact address; the team is notified automatically and limits are raised on request, typically within a day, via [hello@superdocs.app](mailto:hello@superdocs.app). Larger dedicated deployments are available on Pro and Enterprise.
+
+## Upload a file
+
+Attachments are processed asynchronously. Upload the file, get a `job_id`, then poll for completion. The `job_id` is an opaque UUID GÇö attachment jobs are queried through the same `GET /v1/jobs/{job_id}` path as chat jobs (there's no separate id namespace or endpoint for them).
+
+```bash theme={null}
+# 1. Upload
+curl -X POST https://api.superdocs.app/v1/attachments/upload \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -F "file=@reference-doc.pdf" \
+ -F "session_id=my-session"
+```
+
+Response:
+
+```json theme={null}
+{
+ "job_id": "550e8400-e29b-41d4-a716-446655440000",
+ "filename": "reference-doc.pdf",
+ "status": "processing",
+ "message": "Upload successful. Processing reference-doc.pdf..."
+}
+```
+
+```bash theme={null}
+# 2. Poll for completion
+curl https://api.superdocs.app/v1/jobs/550e8400-e29b-41d4-a716-446655440000 \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+When `status` is `"completed"`, the attachment is indexed and available as AI context for that session.
+
+## Check attachment status
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/attachments/status/my-session \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## Attachments vs document upload
+
+SuperDocs has two ways to load files GÇö they serve different purposes:
+
+| | Attachment (`/v1/attachments/upload`) | Document upload (`/v1/documents/upload`) |
+| ------------------ | ----------------------------------------------------------------------------- | ---------------------------------------- |
+| **Purpose** | Reference material for the AI | Load as the active document |
+| **Processing** | Async (poll for completion) | Synchronous (immediate) |
+| **Editing** | Read-only GÇö AI can search and reference | Fully editable via chat |
+| **Multiple files** | Yes GÇö multiple per session | One active document per session |
+| **Best for** | Supporting context (e.g., upload a brief, then create a document based on it) | Loading a file you want to edit |
+
+## Loading an attachment into the editor
+
+After uploading an attachment, you can ask the AI to load it into the editor GÇö making it the active document you can edit:
+
+```python theme={null}
+# 1. Upload a file as an attachment
+upload = requests.post("https://api.superdocs.app/v1/attachments/upload",
+ headers={"Authorization": f"Bearer {API_KEY}"},
+ files={"file": open("draft.docx", "rb")},
+ data={"session_id": "my-session"}
+)
+
+# 2. Wait for processing to complete
+# ... poll /v1/jobs/{job_id} until status is "completed" ...
+
+# 3. Ask the AI to load it into the editor
+response = requests.post("https://api.superdocs.app/v1/chat", headers=HEADERS, json={
+ "message": "Load the draft document into the editor",
+ "session_id": "my-session"
+})
+# The AI loads the attachment content as the active document
+```
+
+The AI supports two loading modes:
+
+* **Replace** GÇö clears the editor and loads the full attachment (default when the editor is empty)
+* **Insert** GÇö adds the attachment content to the existing document (when the editor already has content)
+
+## Image attachments and vision
+
+SuperDocs can **see** the images you attach. The AI visually interprets diagrams, screenshots, photos, charts, scanned documents, and any other image content GÇö and uses what it sees to inform the document it is editing.
+
+Common use cases:
+
+* **Transcribe a screenshot** into the document ("Add the table from this screenshot to my report")
+* **Reference a diagram** in generated content ("Write a paragraph describing the architecture in this image")
+* **Extract data from a chart** ("Summarize the trends in this chart and add a section about them")
+* **Identify objects or text in a photo** ("What does the sign in this photo say? Add it as a quote")
+* **Swap an image in the document** ("Replace the cover photo with this attached image", "Use this as the new company logo") GÇö pure URL swap, no AI generation cost
+
+The same `image_attachments` field also drives image generation and editing inside the document GÇö see [Visual content](/features#visual-content--media). Tell the AI **"generate an image of a glass office building at sunrise"** and it creates one and inserts it; click any image in the editor and tell the AI **"brighten this dramatically"** or **"recolor with warm sepia"** and the AI rewrites the bytes.
+
+Images are handled inline with your chat request GÇö no separate upload step is required:
+
+```json theme={null}
+{
+ "message": "What does this diagram show? Add a section explaining it.",
+ "session_id": "my-session",
+ "image_attachments": [{
+ "id": "img-1",
+ "name": "diagram.png",
+ "base64Data": "iVBORw0KGgo...",
+ "mimeType": "image/png",
+ "size": 54321
+ }]
+}
+```
+
+Supported image formats: PNG, JPG/JPEG, GIF, WebP.
+
+## Delete an attachment
+
+```bash theme={null}
+curl -X DELETE "https://api.superdocs.app/v1/attachments/ATTACHMENT_ID?session_id=my-session" \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+
+# Cross-session memory & search
+Source: https://docs.superdocs.app/concepts/cross-session-context
+
+Let the AI remember your preferences across chats and reuse your own prior documents GÇö opt-in, off by default, and scoped to you.
+
+# Cross-session memory & search
+
+By default, each [session](/concepts/sessions) is self-contained: the AI only knows about the conversation and documents in that session. Two **opt-in** capabilities let a session reach beyond itself:
+
+* **Cross-session memory** GÇö the AI remembers your durable preferences and recurring instructions across chats (e.g. "always use British spelling", "our company name is Acme Ltd"), so you don't repeat them every time.
+* **Cross-session search** GÇö the AI can find and reuse your own prior documents and chats ("pull the payment terms from last month's contract") instead of being limited to the current session.
+
+Both are **off by default**. You turn them on per request with the `cross_session_*` fields on `chat` / `chat_async` (REST and MCP). Both are **owner-scoped** GÇö they never reach across the API key owner. One customer's data is never visible to another.
+
+## Turning it on
+
+Pass the flags on a chat request. Omit them (or set them to `false`) and behavior is exactly as it is today GÇö single-session only.
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Draft a follow-up using the same tone as my previous proposals",
+ "session_id": "new-proposal",
+ "cross_session_memory": true,
+ "cross_session_search": true
+ }'
+```
+
+| Field | Type | Default | What it does |
+| -------------------------- | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
+| `cross_session_memory` | boolean | `false` | The AI applies durable preferences it has learned from your past chats, and may note new ones for next time. |
+| `cross_session_search` | boolean | `false` | The AI can find and reuse content from your own prior documents and chats. |
+| `cross_session_memory_key` | string | GÇö | Keeps a **separate** memory per end-customer (B2B2C GÇö see below). |
+| `cross_session_scope` | list of session ids | GÇö | Narrows cross-session search to a specific set of sessions instead of everything you own. |
+
+## What memory is (and isn't)
+
+Memory is about **how you like to work**, not a transcript of everything you've written. Think of it as the assistant remembering your standing instructions: preferred tone, spelling conventions, recurring names and facts, formatting habits. It's there so the AI carries your preferences from one chat to the next without you restating them.
+
+Memory is **not** a dump of your document content into every prompt. Cross-session search is the capability that pulls in actual prior content, and only when a request calls for it.
+
+## Owner scoping
+
+Everything stays inside the API key owner's data. With cross-session search on, the AI can reach your other sessions and documents GÇö and only yours. There is no path for it to surface another account's or another organization's content. Memory follows the same boundary.
+
+## B2B2C: separate memory and scope per end-customer
+
+If you serve multiple end-users through a single API key, you don't want one user's preferences leaking into another user's chats. Two controls handle this:
+
+* **`cross_session_memory_key`** GÇö pass a stable per-end-customer key (e.g. your own user id) and the AI keeps a **distinct memory** for each one. User A's preferences never affect User B's drafts.
+* **`cross_session_scope`** GÇö pass the list of session ids that belong to that end-customer so cross-session search only looks at their work, not the whole account's.
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Continue this in the usual style",
+ "session_id": "user_123_draft-7",
+ "cross_session_memory": true,
+ "cross_session_memory_key": "user_123",
+ "cross_session_search": true,
+ "cross_session_scope": ["user_123_draft-1", "user_123_draft-2"]
+ }'
+```
+
+Use `cross_session_memory_key` and `cross_session_scope` together to give each of your end-customers their own private memory and their own search boundary while still running under one API key.
+
+## Clearing memory
+
+To wipe the durable memory the AI has built up, clear it explicitly. This removes the stored preferences GÇö it does not touch your documents or chat history.
+
+```bash theme={null}
+curl -X DELETE https://api.superdocs.app/v1/users/me/cross-session-memory \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+For a B2B2C setup, pass the same value you've been sending as `cross_session_memory_key` as the `memory_key` **query parameter**, so you clear only that end-customer's memory (omitting it clears the account-level note):
+
+```bash theme={null}
+curl -X DELETE 'https://api.superdocs.app/v1/users/me/cross-session-memory?memory_key=user_123' \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+The MCP tool is `clear_cross_session_memory`.
+
+
+ **`clear_cross_session_memory` is destructive.** It permanently erases the stored memory note (scoped to the `memory_key` you pass, or your account-wide memory if you pass none). The preferences are gone after this call. If you expose it to an AI agent, confirm intent before calling it.
+
+
+## Notes
+
+* Both capabilities are strictly opt-in per request. A request that doesn't set the flags behaves exactly like a single-session chat.
+* Memory captures preferences over time; you may not see its effect on the very first chat after enabling it.
+* Memory and search are independent GÇö you can enable one without the other.
+* See [Multi-Document Sessions](/guides/multi-document) for working with several documents inside a single session (a separate concept from reaching across sessions).
+
+
+# Documents
+Source: https://docs.superdocs.app/concepts/documents
+
+Load documents from files, edit them with AI, create new ones from scratch, and export as Word files.
+
+# Documents
+
+## Sending document HTML
+
+Send your document HTML in `document_html`. The AI reads it, makes changes, and returns updated HTML in `document_changes.updated_html`.
+
+**The server keeps your document between turns.** Once a session holds a document, you don't re-send it every turn. Send just the `message` and the AI works on the copy it already has. Include `document_html` only to **load** a document into a session the first time, **replace** it wholesale, or **sync** edits you made to the HTML outside of chat (covered below).
+
+**The one rule, for when you *do* send HTML back: send it faithfully.** If the user edited the document in your own editor and you're syncing those edits, send the full current HTML exactly as your editor has it. Don't programmatically strip, modify, or reformat it. If your editor or HTML sanitizer removes custom `data-*` attributes, configure it to preserve them. Those `data-chunk-id` attributes are what let the AI make targeted edits.
+
+### Example flow
+
+```python theme={null}
+import requests
+
+API_KEY = "sk_YOUR_API_KEY"
+HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
+
+# 1. Send a document
+response = requests.post("https://api.superdocs.app/v1/chat", headers=HEADERS, json={
+ "message": "Add a summary at the top",
+ "session_id": "my-doc",
+ "document_html": "
Report
Q4 revenue increased by 15%.
"
+})
+
+data = response.json()
+updated_html = data["document_changes"]["updated_html"]
+
+# 2. Follow-up turn: the server still holds the document, so send just the message
+response = requests.post("https://api.superdocs.app/v1/chat", headers=HEADERS, json={
+ "message": "Make the summary more concise",
+ "session_id": "my-doc",
+})
+
+# Re-send document_html ONLY when you changed the HTML outside chat
+# (e.g. the user edited it in your editor) and want the AI to see those edits:
+#
+# response = requests.post("https://api.superdocs.app/v1/chat", headers=HEADERS, json={
+# "message": "Make the summary more concise",
+# "session_id": "my-doc",
+# "document_html": edited_html, # send it exactly as your editor has it
+# })
+```
+
+The returned HTML contains `data-chunk-id` attributes on elements. These identify document sections and enable the AI to make targeted edits without reprocessing the entire document. These same IDs appear in API responses GÇö for example, `chunk_id` and `insert_after_chunk_id` in [HITL proposed changes](/guides/human-in-the-loop#understanding-proposed-changes).
+
+If you make an edit you want to undo, you can rewind both the chat history and the document state to before any user message GÇö see [Revert a session to a previous message](/concepts/sessions#revert-a-session-to-a-previous-message).
+
+## Loading documents from files
+
+Upload a file to load it as the active document in a session. The file is processed synchronously GÇö the response includes the full document HTML ready for editing.
+
+**Supported formats:** DOCX, DOC, ODT, PDF, TXT, HTML, MD (Markdown), RTF
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/documents/upload \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -F "file=@contract.docx" \
+ -F "session_id=my-session"
+```
+
+Response:
+
+```json theme={null}
+{
+ "html": "
Contract
...
",
+ "session_id": "my-session",
+ "filename": "contract.docx",
+ "chunks_count": 12,
+ "version_id": "v_xyz789"
+}
+```
+
+**Multiple documents per session.** By default an upload **replaces** the session's focused document. Pass `open_mode=new_focused` to open the file as an additional document (and focus it), or `open_mode=background` to open it without stealing focus. The response also includes the session's document roster (every open document's id + title + which is focused) so you can render tabs immediately. See the [Multi-Document Sessions guide](/guides/multi-document).
+
+**Page geometry.** For DOCX and PDF uploads, document payloads include a nullable `page_setup` object GÇö detected page dimensions (`width_in` / `height_in`), per-side margins (`margin_in`), and `orientation` GÇö anywhere a document is returned (upload responses, the session-documents roster, session history). It's `null` when the source format carries no geometry (e.g. plain text or HTML).
+
+```json theme={null}
+{
+ "page_setup": {
+ "width_in": 8.27,
+ "height_in": 11.69,
+ "margin_in": {"top": 1.0, "right": 1.0, "bottom": 1.0, "left": 1.0},
+ "orientation": "portrait",
+ "source": "docx"
+ }
+}
+```
+
+After uploading, you can immediately send chat messages to edit the document:
+
+```python theme={null}
+# Upload a document
+upload = requests.post("https://api.superdocs.app/v1/documents/upload",
+ headers={"Authorization": f"Bearer {API_KEY}"},
+ files={"file": open("contract.docx", "rb")},
+ data={"session_id": "my-session"}
+)
+doc_html = upload.json()["html"]
+
+# Edit it with AI
+response = requests.post("https://api.superdocs.app/v1/chat", headers=HEADERS, json={
+ "message": "Simplify the language in section 3",
+ "session_id": "my-session",
+ "document_html": doc_html
+})
+```
+
+## Creating documents from scratch
+
+Send a message to an empty session (no `document_html`, no uploaded file) and the AI will generate a complete document for you.
+
+```python theme={null}
+response = requests.post("https://api.superdocs.app/v1/chat", headers=HEADERS, json={
+ "message": "Create a non-disclosure agreement between two companies",
+ "session_id": "new-nda"
+})
+
+data = response.json()
+new_document_html = data["document_changes"]["updated_html"]
+```
+
+Example prompts that work well:
+
+* "Create a consulting agreement"
+* "Draft a project proposal for a mobile app"
+* "Write a company privacy policy"
+* "Create a meeting minutes template"
+
+The AI generates structured, formatted HTML that you can then continue editing with follow-up messages.
+
+## Exporting documents
+
+Export the current document as a downloadable file.
+
+**Three ways to supply the document:**
+
+* **Inline HTML** GÇö send HTML content in `html` (use when you have the document HTML in your app, up to 20 MB)
+* **Export from a session** GÇö send a `session_id` (the API retrieves the document from the session)
+* **Pre-signed upload then export** GÇö upload the HTML to a pre-signed URL first, then send `upload_id` (use for documents between 20 MB and 100 MB GÇö see [Large documents](#large-documents) below)
+
+**Five output formats:**
+
+* `docx` (default) GÇö Microsoft Word (Open XML), preserves tables, formatting, embedded images
+* `pdf` GÇö paginated, print-ready PDF
+* `html` GÇö standalone HTML file with inlined CSS
+* `markdown` GÇö Markdown (.md) with ATX headings
+* `txt` GÇö plain text
+
+A sixth value, `doc` (Word-compatible HTML wrapper), is accepted as a legacy alias and will be removed in a future release. New integrations should use `docx`.
+
+### Export from inline HTML
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/documents/export \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "html": "
My Document
Content here...
",
+ "format": "docx",
+ "options": { "filename": "my-document" }
+ }' \
+ --output my-document.docx
+```
+
+### Export from a session
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/documents/export \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "session_id": "my-session",
+ "format": "pdf"
+ }' \
+ --output exported-document.pdf
+```
+
+### Export in Python
+
+```python theme={null}
+response = requests.post("https://api.superdocs.app/v1/documents/export",
+ headers=HEADERS,
+ json={
+ "session_id": "my-session",
+ "format": "docx",
+ "options": {
+ "paper_size": "A4",
+ "margins": "normal",
+ "filename": "final-contract"
+ }
+ }
+)
+
+with open("final-contract.docx", "wb") as f:
+ f.write(response.content)
+```
+
+### Request parameters
+
+| Parameter | Type | Required | Description |
+| ------------ | ------ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| `html` | string | One of `html` / `session_id` / `upload_id` required | HTML content to convert (inline HTML) |
+| `session_id` | string | One of `html` / `session_id` / `upload_id` required | Session to export from |
+| `upload_id` | string | One of `html` / `session_id` / `upload_id` required | Pre-signed upload reference (for large documents) |
+| `format` | string | No | `"docx"` (default), `"pdf"`, `"html"`, `"markdown"`, or `"txt"`. `"doc"` accepted as a legacy alias. |
+| `options` | object | No | Customisation block GÇö see [Export options](#export-options) below. |
+| `filename` | string | No | Legacy top-level filename. Prefer `options.filename`, which wins when both are set. |
+
+### Export options
+
+Pass an `options` object to customise the rendered output. Defaults are sensible for English-language documents; override only what you need.
+
+| Field | Type | Default | Description |
+| ----------------------- | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `paper_size` | string | `"Letter"` | `"Letter"`, `"A4"`, `"A3"`, or `"Legal"`. Applies to DOCX/PDF. |
+| `orientation` | string | `"portrait"` | `"portrait"` or `"landscape"`. Applies to DOCX/PDF. |
+| `margins` | string | `"normal"` | `"narrow"` (0.5 in), `"normal"` (1 in), `"wide"` (1.5 in), or `"custom"`. |
+| `custom_margins_inches` | object | GÇö | Required when `margins="custom"`. Object with `top`, `right`, `bottom`, `left` floats between 0.25 and 3.0. |
+| `filename` | string | GÇö | Name for the downloaded file. Precedence: `options.filename`, then the legacy top-level `filename`, then a name derived from the document's first heading. Pass it with or without an extension: a supplied extension matching the export format is kept as-is (never doubled), and an extension-less name gets the format's extension appended. |
+| `embed_images` | boolean | `false` | HTML export only. When `true`, images are base64-embedded for offline portability. Raises the size cap from 100 MB to 150 MB. |
+| `watermark_text` | string | GÇö | PDF only. Optional text watermark overlaid on every page (max 64 chars). |
+| `watermark_opacity` | number | `0.3` | PDF watermark opacity, between 0.05 and 1.0. |
+
+```python theme={null}
+options = {
+ "paper_size": "A4",
+ "orientation": "landscape",
+ "margins": "wide",
+ "filename": "Q4-Report",
+ "watermark_text": "DRAFT",
+ "watermark_opacity": 0.15,
+}
+```
+
+The response is a binary file download with the appropriate `Content-Type` header. The `Content-Disposition` header carries the filename (RFC 5987 encoded for non-ASCII characters).
+
+### Non-fatal warnings
+
+Exports may complete successfully but with non-fatal issues GÇö an image URL that 404'd, a diagram that exceeded the render timeout, an unsupported field code that was skipped. The response carries these in the `X-Export-Warnings` header as a base64-encoded JSON list. The header is always present alongside `Content-Disposition` in `Access-Control-Expose-Headers`, so browser clients can read it via `fetch`.
+
+```javascript theme={null}
+const response = await fetch('/v1/documents/export', { ... });
+const warningsHeader = response.headers.get('X-Export-Warnings');
+if (warningsHeader) {
+ const warnings = JSON.parse(atob(warningsHeader));
+ // [{ code: "image_download_failed", message: "...", detail: { src: "..." } }, ...]
+}
+```
+
+| Warning code | Meaning |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `image_download_failed` | An embedded image URL could not be fetched. The image is omitted from the output. |
+| `mermaid_render_failed` | A Mermaid diagram could not be rendered. The diagram source is kept in the output as a code block. |
+| `mermaid_timeout` | A Mermaid diagram took too long to render. Same fallback as above. |
+| `excalidraw_corrupt` | An Excalidraw drawing's JSON payload could not be parsed. |
+| `footnote_orphan` | A footnote reference has no matching footnote body. |
+| `track_change_unmatched` | A tracked change anchor could not be placed inline; the change is appended at the end of the document. |
+| `field_code_unsupported` | A citation or other field code could not be re-emitted in the output format. |
+| `watermark_skipped` | A requested PDF watermark could not be applied. |
+| `embed_image_skipped` | An image could not be inlined into an HTML export with `embed_images=true`. |
+| `size_cap_warning` | The document is close to the per-format size limit. |
+| `header_footer_degraded` | A rich page header or footer could not be fully reproduced; it was emitted as best-effort text with working page-number fields. |
+| `sections_degraded` | Per-section page geometry (a section with its own page size or orientation) could not be applied in this PDF render; the export uses a single page setup. |
+| `svg_png_conversion_failed` | An SVG image could not be converted for `.docx` output; the original SVG is kept in the file. |
+| `math_partial_fidelity` | An equation contained a construct that could not be fully converted to native Word math; that construct is kept as its LaTeX source inside the equation. |
+
+### Large documents
+
+There's one size story for export, driven by how big the document HTML is:
+
+**Three-tier flow:**
+
+| Document HTML size | Path |
+| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Up to 20 MB | Direct POST to `/v1/documents/export` with inline `html` or a `session_id`. |
+| 20 MB GÇô 100 MB | Request a pre-signed upload URL via `POST /v1/uploads` (`purpose: "export-html"`), PUT the HTML to it, then call `/v1/documents/export` with `upload_id`. |
+| Over 100 MB | Use the [email-fallback endpoint](#email-fallback-for-very-large-documents). |
+
+Keep inline POSTs at or under 20 MB. Above that, the hosted transport layer rejects the request (a hard \~32 MB clamp sits in front of the renderer), so switch to the upload-then-export pattern (PUT the HTML to a pre-signed URL, then export by `upload_id`) rather than pushing a bigger body through directly.
+
+```python theme={null}
+# 1. Request a pre-signed PUT URL
+upload = requests.post("https://api.superdocs.app/v1/uploads",
+ headers=HEADERS,
+ json={
+ "filename": "export.html",
+ "content_type": "text/html",
+ "size_bytes": len(html.encode()),
+ "purpose": "export-html",
+ }
+).json()
+
+# 2. PUT the HTML directly to storage
+requests.put(upload["upload_url"],
+ headers={"Content-Type": "text/html"},
+ data=html)
+
+# 3. Export, referencing the upload_id
+response = requests.post("https://api.superdocs.app/v1/documents/export",
+ headers=HEADERS,
+ json={
+ "upload_id": upload["upload_id"],
+ "filename": "export.html",
+ "format": "docx",
+ }
+)
+
+with open("output.docx", "wb") as f:
+ f.write(response.content)
+```
+
+The signed URL is valid for 5 minutes; the uploaded blob is retained for 24 hours.
+
+### Email fallback for very large documents
+
+For documents over 100 MB, render asynchronously and deliver via email. The endpoint accepts a session ID, queues a background job, and emails a 7-day signed download link to the recipient.
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/documents/export/email-request \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "session_id": "my-session",
+ "format": "docx",
+ "recipient_email": "user@example.com",
+ "options": { "paper_size": "A4" }
+ }'
+```
+
+Response:
+
+```json theme={null}
+{
+ "job_id": "9c2f...e3a",
+ "recipient_email": "user@example.com",
+ "status": "queued",
+ "eta": "24h",
+ "message": "Your file will be emailed to user@example.com within 24 hours."
+}
+```
+
+`recipient_email` falls back to the email on file for the authenticated user account when omitted. The job appears in `/v1/jobs/{job_id}` with `job_type: "large_export"`. See [Async jobs](/guides/async-jobs) for the polling pattern.
+
+### Size and error responses
+
+| Format | Cap |
+| ------------------------------- | ------ |
+| `docx`, `pdf`, `html`, `doc` | 100 MB |
+| `html` with `embed_images=true` | 150 MB |
+| `markdown`, `txt` | 50 MB |
+
+Keep inline POSTs at or under 20 MB; the hosted transport layer enforces a hard \~32 MB clamp and returns `413 Request Entity Too Large` before reaching the renderer, so use the pre-signed upload-then-export pattern for anything larger. Requests above the format-specific cap return a structured 413 with a JSON body explaining the limit and pointing to the email-fallback endpoint GÇö see [Error codes](/errors/error-codes#413-request-entity-too-large).
+
+## Supported formatting
+
+The AI can apply the following formatting when creating or editing documents:
+
+* **Text styling** GÇö bold, italic, underline, strikethrough
+* **Headings** GÇö H1 through H6
+* **Lists** GÇö ordered, unordered, and nested
+* **Text highlighting** GÇö with color options (e.g., yellow, green, red)
+* **Text color** GÇö change the color of specific text
+* **Links** GÇö clickable hyperlinks
+* **Tables** GÇö with rows and columns
+* **Blockquotes** GÇö indented quotation blocks
+* **Code blocks** GÇö for code snippets
+* **Horizontal rules** GÇö section dividers
+
+Ask for formatting in plain language: "highlight the key terms in yellow", "make the title bold and larger", "add a table comparing the two options".
+
+
+# Files (durable documents)
+Source: https://docs.superdocs.app/concepts/files
+
+Documents persist across sessions, so you can list, open, rename, and archive them GÇö and reopen a saved document in any new session.
+
+# Files (durable documents)
+
+Every document you create or upload is saved as a durable **File**. Files outlive the session they were made in: you can list them later, reopen them in a brand-new session, rename them, and archive ones you no longer need. A File is a reusable document, not a copy that disappears when a conversation ends.
+
+This is the layer above [sessions](/concepts/sessions). A session is a conversation thread; a File is the document itself. The same File can be opened into different sessions over time.
+
+Everything here works identically over the REST API and the MCP tools.
+
+## The mental model
+
+* **File** GÇö a durable document with a stable id and a title. It persists across sessions until you archive it.
+* **Session** GÇö a conversation thread. Opening a File into a session lets you edit it there; closing it from the session leaves the File untouched.
+* **Open** GÇö attaching a saved File to a session so you can work on it. Open is **share-attach, not copy**: edits flow back to the one underlying File, so reopening it elsewhere shows your latest changes.
+
+## List your Files
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/documents \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+Returns your saved documents with their ids, titles, and timestamps. Use the title for display labels. The list is token-light: it never returns a full document body. Pass `include_preview=true` to also get a small `preview_html` thumbnail (the first few sections) per document, and page through results with `limit` and `offset`. To read one document's full body, use `get_document_detail` with `include_html=true` (below) or open it into a session.
+
+The MCP tool is `list_documents`.
+
+## Get one File's detail
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/documents/{document_id} \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+Returns the File's title, metadata, and the list of prior chats that used it. Add `?include_html=true` to also get the full document `html` body back, along with its `page_setup` and `version_id`. This is how you read a saved File's content by id without opening it into a session.
+
+The MCP tool is `get_document_detail`.
+
+## Open a saved File into a session
+
+To keep working on a File, open it into a session. You can open one or more Files at once; the first one you list becomes the [focused tab](/guides/multi-document).
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/sessions/my-session/documents/open \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "document_ids": ["doc_2f6cGǪ", "doc_9a1bGǪ"]
+ }'
+```
+
+The response returns the session's document roster (every open document's id + title + which is focused) so you can render tabs immediately. Because open is share-attach, edits you make in the session update the underlying File GÇö there's no separate copy to reconcile.
+
+The MCP tool is `open_documents`.
+
+### Start a session and open Files in one call
+
+If you're beginning fresh, `init_session` creates a session and opens one or more saved Files into it in a single request GÇö handy for "reopen my last contract and start editing."
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/sessions/init \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "session_id": "contract-review",
+ "document_ids": ["doc_2f6cGǪ"]
+ }'
+```
+
+The MCP tool is `init_session`.
+
+## Rename a File
+
+```bash theme={null}
+curl -X PATCH https://api.superdocs.app/v1/documents/{document_id} \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Q3 Vendor Agreement"}'
+```
+
+The new title is what shows up in your File list and on tabs.
+
+The MCP tool is `rename_document`.
+
+## Archive a File
+
+Archiving removes a File from your active list. It's a **soft-delete: recoverable**, not an immediate permanent erase. Archived Files stay restorable for roughly 30 days before they are purged, after which they're gone for good.
+
+```bash theme={null}
+curl -X DELETE https://api.superdocs.app/v1/documents/{document_id} \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+Two optional query params control what happens when the File is currently open somewhere:
+
+* `?force=true` GÇö archive even if the File is still open in a live session. Without it, archiving a File that another session is actively holding fails with **409 `document_in_use`** (`open_in_sessions` + a suggested recovery) and leaves it untouched, so you don't pull a document out from under someone mid-edit GÇö and you always know nothing was archived.
+* `?from_session={session_id}` GÇö also detach the File from that session as part of the archive.
+
+The MCP tool is `archive_document`.
+
+
+ **`archive_document` is destructive.** It's a soft-delete GÇö the File is recoverable for about 30 days and then permanently purged. If you expose this to an AI agent, treat it like any other delete: confirm intent before calling it. There is no separate permanent-delete-by-default; archiving is the only removal path, and the \~30-day window is your safety net.
+
+
+## Restore (un-archive) a File
+
+Bring an archived File back to your active list GÇö the clean inverse of archive. It takes the same durable `document_id` you'd pass to archive, so you can round-trip archive Gåö restore by the same id (no session needed).
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/documents/{document_id}/unarchive \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+Idempotent GÇö restoring an already-active File is a no-op. The MCP tool is `unarchive_document`. (Both are non-billable.)
+
+## Files vs sessions vs open documents
+
+| | File | Session | Open document |
+| -------------- | --------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------- |
+| **What it is** | A durable, reusable document | A conversation thread | A File attached to a session for editing |
+| **Lifetime** | Persists until archived (then \~30-day purge) | Persists across restarts; you choose the id | Lasts while open in the session |
+| **Closing it** | n/a | Delete the session | Removes it from the session only GÇö the File is untouched |
+| **Editing** | Edited via whatever session it's open in | Holds the chat history | Edits flow back to the underlying File (share-attach, not copy) |
+
+## Notes
+
+* Closing a document from a session (see [Multi-Document Sessions](/guides/multi-document)) does **not** archive or delete the File GÇö it only detaches it from that session.
+* Because open is share-attach, the same File opened in two sessions reflects the same content; there is no fork-on-open. To rewind a specific session's edits, use [revert](/concepts/sessions#revert-a-session-to-a-previous-message).
+* Files are scoped to the API key owner GÇö your saved documents are only visible to you (or your organization).
+
+
+# Sessions
+Source: https://docs.superdocs.app/concepts/sessions
+
+Sessions persist conversation history and document state across requests. You choose the session ID.
+
+# Sessions
+
+A session holds your conversation history and document state. Reuse a session ID to pick up where you left off.
+
+## How sessions work
+
+You choose the `session_id` GÇö any string you want. The API stores the conversation and document state for that session.
+
+```bash theme={null}
+# First request GÇö starts a new session
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Add an introduction section",
+ "session_id": "project-proposal-v1",
+ "document_html": "
Project Proposal
"
+ }'
+
+# Second request GÇö continues the same session
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Now add a budget section",
+ "session_id": "project-proposal-v1",
+ "document_html": "
...updated HTML from previous response...
"
+ }'
+```
+
+The AI remembers the full conversation. On the second request, it knows about the introduction it just wrote.
+
+
+ You don't have to re-send `document_html` on every turn. Once the session holds a document the server keeps it, so a follow-up can send just the `message` (the example above re-sends it to also sync any edits the client made). Re-send the HTML only to replace the document or to sync edits you made outside chat. See [Documents](/concepts/documents#sending-document-html).
+
+
+A session can also hold **multiple open documents** at once GÇö each with its own `document_id`, with one document focused at a time. Chat turns target the focused document unless you pass `document_id` (or name a document in the message). See the [Multi-Document Sessions guide](/guides/multi-document).
+
+## Persistence
+
+Sessions persist across server restarts. You can close your browser, come back tomorrow, and resume with the same `session_id`.
+
+**Sessions do not expire.** There is no retention window or time-based cleanup: a session from six months ago is just as resumable as one from this morning. Conversation history and document state remain available until you delete the session yourself (see [Delete a session](#delete-a-session) below). Note that archived durable Files follow their own lifecycle and are purged about 30 days after archiving; see [Files](/concepts/files).
+
+## List sessions
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+Returns session IDs, message counts, timestamps, and a preview of the first message.
+
+## Load session history
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions/project-proposal-v1/history \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+The response includes:
+
+* `messages` GÇö Full conversation history. Each message carries `turn_index` (the position in the session) and `checkpoint_id` (a marker used by the revert endpoint; `null` for older messages recorded before the revert feature shipped).
+* `document_state` GÇö Current document HTML, version ID, and attachment list
+* `editor_action` GÇö What to do with the document: `"update"` (load the HTML), `"clear"` (reset), or `"keep"` (no change)
+
+## Revert a session to a previous message
+
+Rewind both the chat and the document to the state immediately before a specific user message.
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/sessions/project-proposal-v1/revert \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"turn_index": 4}'
+```
+
+`turn_index` is the position of the **user message** you want to undo. Pass it from a `messages[i].turn_index` you got back from the history endpoint above.
+
+The response gives you:
+
+* `compose_text` GÇö The text of the user message that was reverted. Useful to pre-fill a compose box so the user can edit and resend.
+* `reverted_to_turn` GÇö The turn the conversation now ends at (the previous AI reply), or `-1` if the session was reset to its initial empty state.
+* `document_state` + `editor_action` GÇö Same shape as the history endpoint. Apply them to your editor.
+* `archived_turn_count` GÇö How many rows were soft-archived.
+
+What happens:
+
+* The session is restored to the state at that point in the conversation GÇö both the chat history and the document are rewound to the previous AI reply. Messages and document state from the reverted point onward are retained for audit but hidden from active reads.
+* Subsequent messages on this session continue from that restored point. There is no timeline-switcher in the API today GÇö the restored timeline becomes the only visible one.
+* If the session has a chat job currently running (in progress or awaiting approval), the revert call returns `409`. Wait for the job to settle or cancel it first.
+* If the user message predates the revert feature (its `checkpoint_id` is `null`), the call returns `422`. Older sessions can still be loaded; only their per-message revert is unavailable.
+
+## Session isolation
+
+Sessions are isolated per user. Your sessions are never visible to other users.
+
+## B2B session management
+
+If you're serving multiple end users through a single API key, manage session IDs per user on your end. For example, prefix session IDs with your user's identifier:
+
+```
+session_id: "user_123_draft-contract"
+session_id: "user_456_meeting-notes"
+```
+
+This keeps each user's conversations and documents separate.
+
+## Delete a session
+
+Session deletion lives at `/v1/users/me/sessions/{id}`, which is part of the web-app account surface GÇö it accepts web-app session tokens only and **rejects `sk_` / `lce_` API keys with a `401`**. Delete sessions from the browser at [use.superdocs.app](https://use.superdocs.app) GåÆ Settings.
+
+From an API-key context you don't need to delete sessions: they persist server-side but are never billed unless used, so simply stop reusing a `session_id` to retire it. Each user's sessions stay isolated regardless.
+
+
+# Templates
+Source: https://docs.superdocs.app/concepts/templates
+
+Upload reusable document templates that the AI can search and apply when creating content.
+
+# Templates
+
+Upload document templates for the AI to reference. When you ask the AI to create a document, it can search your templates and use them as a starting point.
+
+## Upload a template
+
+Templates support the same file types as attachments: `.docx`, `.pdf`, `.txt`, `.rtf`, `.md`, `.html`, `.htm`.
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/templates/upload \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -F "file=@nda-template.docx"
+```
+
+Template processing is synchronous GÇö the template is ready immediately after upload.
+
+## List your templates
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/templates \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## Delete a template
+
+```bash theme={null}
+curl -X DELETE https://api.superdocs.app/v1/templates/TEMPLATE_ID \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## How the AI uses templates
+
+When you ask the AI to create a document (e.g., "Draft an NDA"), it searches your uploaded templates for relevant matches. If a template fits, the AI uses it as a starting point and customizes it based on your instructions.
+
+Templates are scoped per user or organization GÇö your templates are only visible to you.
+
+
+# Error Codes
+Source: https://docs.superdocs.app/errors/error-codes
+
+HTTP status codes, error response format, and common error scenarios for the SuperDocs API.
+
+# Error Codes
+
+All API errors return a JSON response with a `detail` field:
+
+```json theme={null}
+{
+ "detail": "Human-readable error message"
+}
+```
+
+## Status codes
+
+| Code | Meaning | Common causes |
+| ------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **400** | Bad Request | Empty message, invalid parameters |
+| **401** | Unauthorized | Missing or invalid API key, expired token |
+| **403** | Forbidden | Accessing a resource you don't own; wrong agent-takeover code (the `detail` says how many attempts remain) |
+| **404** | Not Found | Job, session, template, or attachment doesn't exist; revert target message not in active history; pre-signed upload expired; expired or unknown agent-takeover link |
+| **409** | Conflict | Revert blocked because a chat job is currently running on the same session |
+| **410** | Gone | Agent-takeover link permanently disabled after too many incorrect codes. Terminal: the old link can never work again; the agent must send a fresh handoff link |
+| **413** | Request Entity Too Large | Request body or export payload above the size cap |
+| **415** | Unsupported Media Type | Upload file extension not supported, including a legacy `.doc` upload (convert to `.docx`); use `.pdf`, `.docx`, `.txt`, `.rtf`, `.md`, `.html`, `.htm` |
+| **422** | Validation Error | Request body doesn't match expected schema; format value outside the allowed enum; revert target message predates the revert feature |
+| **429** | Too Many Requests | Monthly operation limit reached, or an endpoint-specific cooldown. Application 429s are JSON and always carry a `Retry-After` header (seconds); see [Rate Limits](/errors/rate-limits) |
+| **500** | Internal Server Error | Unexpected server error |
+| **504** | Gateway Timeout | AI processing exceeded 30 minutes |
+
+## Common errors and fixes
+
+### Authentication errors (401)
+
+```json theme={null}
+{"detail": "Invalid authorization header format. Expected 'Bearer '"}
+```
+
+**Fix:** Use `Authorization: Bearer sk_YOUR_KEY` (include the `Bearer ` prefix).
+
+```json theme={null}
+{"detail": "Invalid API key"}
+```
+
+**Fix:** Check that your key is correct and hasn't been revoked. Generate a new key from Settings > API Keys.
+
+### Validation errors (400)
+
+```json theme={null}
+{"detail": "Message cannot be empty. Please provide a text message."}
+```
+
+**Fix:** Include a non-empty `message` in your request body.
+
+### Unsupported file type (415)
+
+```json theme={null}
+{"detail": "Unsupported file type: .xyz. Supported types: .pdf, .docx, .txt, .rtf, .md, .html, .htm"}
+```
+
+A legacy binary `.doc` upload returns the same `415`:
+
+```json theme={null}
+{"detail": "Unsupported file type: .doc. Supported types: .pdf, .docx, .txt, .rtf, .md, .html, .htm"}
+```
+
+**Fix:** Upload one of the supported formats GÇö `.pdf`, `.docx`, `.txt`, `.rtf`, `.md`, `.html`, `.htm`. Convert legacy `.doc` files to `.docx` first. RTF is supported as an upload **input** only; it is not an export target. Catch `415` (Unsupported Media Type) separately from `400` when handling upload-validation failures.
+
+### Rate limit (429)
+
+```json theme={null}
+{"detail": "Your monthly operation quota (500) is exhausted. Upgrade to Pro for uninterrupted pay-as-you-go overage."}
+```
+
+**Fix:** Upgrade your plan at Settings > Billing, or wait for your monthly reset. Honor the `Retry-After` header (seconds until the request can succeed) and branch on the `429` status and the `X-Usage-Limit` / `X-Usage-Used` / `X-Usage-Remaining` headers rather than the `detail` string (the wording may change). Check the `usage` object in chat responses to monitor remaining operations. A plain-text `429` with no `Retry-After` is an infrastructure surge response, not an application error; back off with jitter and retry (see [Rate Limits](/errors/rate-limits)).
+
+### Timeout (504)
+
+Both the synchronous `/v1/chat` and the async `/v1/chat/async` endpoints apply a 30-minute wall-clock cap, but they surface the timeout differently:
+
+**Synchronous `/v1/chat`** returns an HTTP `504` with a plain `detail` string:
+
+```json theme={null}
+{"detail": "The AI agent took longer than 30 minutes to process your request. For long edits or multi-step tasks, use chat_async."}
+```
+
+**Async `/v1/chat/async`** does not return a `504` on the poll GÇö the job completes and the friendly summary below is persisted as the job's AI response (read it from the job result):
+
+```
+GŦn+ŠYour request ran past my 30-minute cap and I had to wrap up. For very large
+documents, try splitting the task into multiple smaller turns GÇö e.g. one section
+at a time. Your prompt is still in your history above; you can revise it and
+re-send.
+```
+
+**Fix:** Use the async endpoint (`/v1/chat/async`) for long operations GÇö it streams `intermediate` progress events as the work runs and supports human-in-the-loop approval. Split large requests into smaller turns (one section per turn).
+
+### Request too large (413)
+
+The 413 response covers two distinct cases GÇö caller-too-large and document-too-large for the chosen export format GÇö with a structured JSON detail body so clients can react appropriately.
+
+**Caller payload exceeds the transport limit:**
+
+```json theme={null}
+{
+ "detail": "Request body too large",
+ "error_code": "request_too_large",
+ "message_user": "Your request is too large. The current limit is 100 MB.",
+ "request_size_mb": 142.3,
+ "max_size_mb": 100,
+ "suggested_action": "split_request",
+ "contact_email": "hello@superdocs.app",
+ "support_event_id": "abc1234567890"
+}
+```
+
+
+ A much smaller \~32 MiB platform limit sits in front of the application. A body over that ceiling is rejected at the gateway with a generic error page (HTML), **not** this structured JSON body GÇö so don't try to parse it as JSON. Keep request bodies well under that limit; for large exports, switch to the upload-then-export pattern below.
+
+
+**Fix:** For exports, switch to the upload-then-export pattern GÇö see [Large documents](/concepts/documents#large-documents). For other endpoints, split the work into smaller requests.
+
+**Export payload exceeds the format-specific cap:**
+
+```json theme={null}
+{
+ "detail": {
+ "error_code": "document_too_large",
+ "message_user": "This document is 142.3 MB, which exceeds the 100 MB export limit for the chosen format. Email hello@superdocs.app or use the in-app 'Email me a copy' button GÇö we'll send you the rendered file within 24 hours.",
+ "suggested_action": "email_request",
+ "contact_email": "hello@superdocs.app",
+ "document_size_mb": 142.3,
+ "max_size_mb": 100,
+ "format": "docx",
+ "support_event_id": "abc1234567890"
+ }
+}
+```
+
+**Fix:** Call `POST /v1/documents/export/email-request` instead GÇö the file will be rendered in the background and emailed as a 7-day signed download link. See [Email fallback for very large documents](/concepts/documents#email-fallback-for-very-large-documents).
+
+| Field | Description |
+| -------------------------------------- | -------------------------------------------------------------------------------- |
+| `error_code` | `request_too_large` (transport layer) or `document_too_large` (export cap). |
+| `message_user` | Human-readable explanation safe to surface in your UI. |
+| `suggested_action` | `split_request` or `email_request`. Use to switch your client between paths. |
+| `request_size_mb` / `document_size_mb` | Observed payload size. |
+| `max_size_mb` | Configured cap for the format / endpoint. |
+| `format` | Export format that triggered the cap (only on `document_too_large`). |
+| `contact_email` | Support contact for follow-up. |
+| `support_event_id` | Opaque correlation ID. Share with `hello@superdocs.app` when reporting an issue. |
+
+### Validation errors (422)
+
+```json theme={null}
+{"detail": [{"type": "literal_error", "loc": ["body", "format"], "msg": "Input should be 'docx', 'pdf', 'html', 'markdown', 'txt' or 'doc'"}]}
+```
+
+**Fix:** `format` is now a strict enum on `/v1/documents/export`. Use one of `"docx"`, `"pdf"`, `"html"`, `"markdown"`, `"txt"`. The value `"doc"` is accepted as a legacy alias and will be removed in a future release.
+
+```json theme={null}
+{"detail": [{"type": "string_pattern_mismatch", "loc": ["body", "session_id"], "msg": "String should match the expected pattern"}]}
+```
+
+**Fix:** `session_id` may contain only letters, digits, and the characters `_`, `-`, and `.`, up to a maximum of 256 characters. Drop spaces, slashes, and other punctuation from the id you choose.
+
+### Not found (404)
+
+```json theme={null}
+{"detail": "Job not found"}
+```
+
+**Fix:** Jobs are cleaned up 1 hour after completion. Check the job ID and timing. Use `/v1/jobs` to list active jobs.
+
+### Revert conflicts (409)
+
+```json theme={null}
+{"detail": "Cannot revert while a chat job is running for this session. Wait for it to complete or cancel it first."}
+```
+
+**Fix:** Wait for the in-flight job to finish (poll `/v1/jobs/{job_id}` or watch the SSE stream until you see `final`), or cancel it via `POST /v1/jobs/{job_id}/cancel` before retrying revert.
+
+### Revert target too old (422)
+
+```json theme={null}
+{"detail": "This message predates the revertability feature and cannot be reverted. Start a new chat to use revert."}
+```
+
+**Fix:** Per-message revert only works for chats started after the feature shipped. Older sessions remain readable but their messages don't carry the marker the rewind needs. Start a new session for full revert support.
+
+
+# Rate Limits
+Source: https://docs.superdocs.app/errors/rate-limits
+
+Operation limits per subscription tier, overage pricing, and how to handle 429 responses.
+
+# Rate Limits
+
+SuperDocs uses operation-based limits, not request-rate limits. Each AI action that modifies, analyzes, or searches a document counts as one operation.
+
+## Limits by tier
+
+| Plan | Included operations | At the limit | Max monthly bill |
+| -------------- | ------------------- | ---------------------------------------- | ---------------- |
+| **Free** | 500/mo | Pauses (429 error) | \$0 |
+| **Plus** | 2,000/mo | Overage at \$0.015/op, up to 4,000 total | \$50 |
+| **Pro** | 10,000/mo | Overage at \$0.015/op, no cap | Varies |
+| **Enterprise** | Custom | Custom | Custom |
+
+## Document scale limits
+
+Separate from operation quotas, the hosted service applies two scale limits so one pathological upload can never degrade the service for everyone:
+
+| Limit | Standard value | Error on crossing |
+| ------------------------------------------------------- | ---------------------------- | ---------------------------- |
+| Sections per document | 25,000 (roughly 6,000 pages) | `413` `DOCUMENT_TOO_COMPLEX` |
+| Sections per chat session (all open documents combined) | 30,000 | `413` `SESSION_TOO_FULL` |
+
+These are stability limits, not platform caps. The platform itself has no hard limit on document length or on the number of documents open in parallel; the standard values exist to keep the shared hosted service fast and stable. Both errors are structured: the `detail` carries `section_count` / `max_sections` (or `session_section_count` / `max_session_sections`), `limit_expandable: true`, and `contact_email`. Hitting a limit notifies the team automatically; email [hello@superdocs.app](mailto:hello@superdocs.app) and your limits are raised, typically within a day. Larger dedicated deployments are available on Pro and Enterprise.
+
+## Handling 429 responses
+
+There are two kinds of `429`, and they need different handling.
+
+**1. Application 429 (quota or cooldown).** Returned as JSON with a `detail` field, and always carries a `Retry-After` header giving the number of seconds to wait before the request can succeed. When the cause is quota exhaustion (hitting the limit on the free plan), the response also carries the three `X-Usage-*` headers:
+
+```
+HTTP/1.1 429 Too Many Requests
+Retry-After: 1209600
+X-Usage-Limit: 500
+X-Usage-Used: 500
+X-Usage-Remaining: 0
+
+{"detail": "Your monthly operation quota (500) is exhausted. Upgrade to Pro for uninterrupted pay-as-you-go overage."}
+```
+
+Honor `Retry-After`: wait at least that many seconds before retrying. For a monthly quota it is the time until your billing cycle resets, so upgrading is usually the better path than waiting. Branch your client on the `429` status and the headers rather than string-matching the `detail` wording (the exact message may change).
+
+**2. Infrastructure 429 (traffic surge).** Under heavy platform load, requests can be throttled before they reach the application. These responses are plain text (for example `Rate exceeded`), not JSON, and carry no `Retry-After` header. Treat any non-JSON `429` as transient: retry with exponential backoff plus jitter.
+
+**Best practices:**
+
+* Check the `usage` object in chat responses to track remaining operations before you hit the limit
+* On an application `429`, honor `Retry-After` and branch on the `X-Usage-Limit` / `X-Usage-Used` / `X-Usage-Remaining` headers. On successful responses, read the `usage` block in the JSON body (and the SSE `usage` event) instead; those headers are only present on the `429`.
+* On a plain-text `429` with no `Retry-After`, back off exponentially with jitter and retry
+* Upgrade to a paid plan for uninterrupted access
+
+## Check remaining operations
+
+### From API responses
+
+Every chat response includes usage data:
+
+```json theme={null}
+{
+ "usage": {
+ "monthly_used": 480,
+ "monthly_limit": 500,
+ "monthly_remaining": 20,
+ "was_billable": true,
+ "subscription_tier": "free"
+ }
+}
+```
+
+### In the browser
+
+View your usage bar and tier limits at [use.superdocs.app](https://use.superdocs.app) GåÆ Settings. The `/v1/users/me/limits` and `/v1/users/me/usage` endpoints are web-app-only (they reject `sk_` / `lce_` keys with a `401`); from an API-key context, use the `usage` block in chat responses above.
+
+## Monthly reset
+
+Operations reset at the start of each billing cycle. Free plans reset monthly from account creation. Paid plans reset on each billing renewal date.
+
+
+# curl Examples
+Source: https://docs.superdocs.app/examples/curl
+
+Copy-paste ready curl commands for every key SuperDocs API operation.
+
+# curl Examples
+
+Replace `sk_YOUR_API_KEY` with your actual API key in all commands.
+
+## Chat
+
+### Send a message with document
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Add a summary section at the top",
+ "session_id": "curl-demo",
+ "document_html": "
Report
Q4 revenue increased 15%.
"
+ }'
+```
+
+### Send a message without document
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Draft a project proposal outline",
+ "session_id": "curl-demo"
+ }'
+```
+
+### Chat with model selection
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Analyze this contract for potential risks",
+ "session_id": "curl-demo",
+ "document_html": "...",
+ "model_tier": "max",
+ "thinking_depth": "deep"
+ }'
+```
+
+## Async chat
+
+### Start an async request
+
+```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": "Rewrite this document in formal language",
+ "session_id": "async-demo",
+ "document_html": "..."
+ }'
+```
+
+## Jobs
+
+### Check job status
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/jobs/JOB_ID \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### List all jobs
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/jobs \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Cancel a job
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/jobs/JOB_ID/cancel \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## Sessions
+
+### List sessions
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Get session history
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions/SESSION_ID/history \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Get session jobs
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions/SESSION_ID/jobs \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Delete a session
+
+Session deletion lives under `/v1/users/me/sessions/{id}` GÇö that endpoint is part of the web-app account surface and accepts Firebase tokens only, not `sk_` API keys. To clear sessions programmatically with an API key, simply stop using the `session_id` (sessions persist server-side but are not billed unless used). To delete from the UI, use **Settings > Sessions** in [use.superdocs.app](https://use.superdocs.app).
+
+## Documents
+
+### Upload a document to the editor
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/documents/upload \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -F "file=@contract.docx" \
+ -F "session_id=my-session"
+```
+
+### Export a document
+
+```bash theme={null}
+# From session GÇö DOCX (default format)
+curl -X POST https://api.superdocs.app/v1/documents/export \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"session_id": "my-session", "format": "docx"}' \
+ --output document.docx
+
+# From HTML GÇö PDF with paper and watermark options
+curl -X POST https://api.superdocs.app/v1/documents/export \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "html": "
Title
Content
",
+ "format": "pdf",
+ "options": {"paper_size": "A4", "watermark_text": "DRAFT"}
+ }' \
+ --output document.pdf
+```
+
+Formats: `docx` (default), `pdf`, `html`, `markdown`, `txt`. See [Exporting documents](/concepts/documents#exporting-documents) for the full options reference.
+
+## Attachments
+
+### Upload a file
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/attachments/upload \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -F "file=@document.pdf" \
+ -F "session_id=my-session"
+```
+
+### Check attachment status
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/attachments/status/SESSION_ID \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Delete an attachment
+
+```bash theme={null}
+curl -X DELETE "https://api.superdocs.app/v1/attachments/ATTACHMENT_ID?session_id=my-session" \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## Templates
+
+### Upload a template
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/templates/upload \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -F "file=@template.docx"
+```
+
+### List templates
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/templates \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Delete a template
+
+```bash theme={null}
+curl -X DELETE https://api.superdocs.app/v1/templates/TEMPLATE_ID \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## HITL approval
+
+### Approve a single change
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat/SESSION_ID/approve \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "job_id": "JOB_ID",
+ "change_id": "CHANGE_ID",
+ "approved": true
+ }'
+```
+
+### Approve multiple changes
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat/SESSION_ID/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/SESSION_ID/approve \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "job_id": "JOB_ID",
+ "change_id": "CHANGE_ID",
+ "approved": false,
+ "feedback": "Keep the original wording but add a reference to GDPR"
+ }'
+```
+
+## User management
+
+The `/v1/users/*` endpoints (profile, usage, limits, sessions, programmatic API-key management) belong to the web-app account surface and accept **web-app session tokens only** GÇö `sk_` / `lce_` API keys are rejected with a `401`. View profile, usage, and tier limits from [use.superdocs.app](https://use.superdocs.app) GåÆ Settings.
+
+To check usage from an API-key context, the `usage` block is included in every `/v1/chat` and `/v1/chat/async` response (`monthly_used`, `monthly_limit`, `monthly_remaining`, `was_billable`, `subscription_tier`) and in every SSE `usage` event GÇö see [SSE Streaming](/guides/streaming).
+
+## Verify your API key
+
+The cheapest way to confirm an `sk_` key works:
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/sessions \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+A `200` with a JSON list of sessions (possibly empty) confirms the key. A `401` means the key is wrong, revoked, or the header didn't reach the server. **Do not use `/v1/users/me` to verify an API key** GÇö it's web-app-only and will return `401` even for a valid `sk_` key, which makes the key look bad when it isn't.
+
+## Health check
+
+```bash theme={null}
+curl https://api.superdocs.app/health
+```
+
+REST `GET /health` requires no authentication. The MCP `health` tool is different: it runs over the authenticated `/mcp/` connection like every MCP tool, so a `401` there signals an API-key problem rather than downtime.
+
+
+# JavaScript Examples
+Source: https://docs.superdocs.app/examples/javascript
+
+Complete working JavaScript examples for the SuperDocs API using fetch and EventSource.
+
+# JavaScript Examples
+
+All examples use the native `fetch` API and work in browsers and Node.js 18+.
+
+
+ For other languages (Python, Go, Ruby, .NET) used in server-side / batch / queue-worker integrations, see [Server Integration](/guides/server-integration). For AI agent tool registration patterns (OpenAI, Anthropic, LangChain, LlamaIndex), see [Agent Tool Integration](/guides/agent-tool-integration).
+
+
+## Basic chat with document
+
+```javascript theme={null}
+const API_KEY = "sk_YOUR_API_KEY";
+const BASE = "https://api.superdocs.app";
+
+async function chat(message, sessionId, documentHtml = null) {
+ const response = await fetch(`${BASE}/v1/chat`, {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${API_KEY}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ message,
+ session_id: sessionId,
+ document_html: documentHtml
+ })
+ });
+
+ return response.json();
+}
+
+// Usage
+const data = await chat(
+ "Add an introduction paragraph",
+ "js-demo",
+ "
My Document
Content here.
"
+);
+
+console.log("AI:", data.response);
+if (data.document_changes) {
+ console.log("Updated HTML:", data.document_changes.updated_html);
+}
+```
+
+## EventSource streaming
+
+```javascript theme={null}
+async function streamChat(message, sessionId, documentHtml) {
+ // 1. Start async job
+ const response = await fetch(`${BASE}/v1/chat/async`, {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${API_KEY}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ message,
+ session_id: sessionId,
+ document_html: documentHtml
+ })
+ });
+
+ const { job_id } = await response.json();
+
+ // 2. Open SSE stream
+ return new Promise((resolve, reject) => {
+ const url = `${BASE}/v1/chat/${sessionId}/stream?job_id=${job_id}&api_key=${API_KEY}`;
+ const eventSource = new EventSource(url);
+
+ 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);
+ eventSource.close();
+ resolve(data.result);
+ });
+
+ eventSource.addEventListener("usage", (event) => {
+ const data = JSON.parse(event.data);
+ console.log(`Operations: ${data.monthly_used}/${data.monthly_limit}`);
+ });
+
+ eventSource.addEventListener("error", (event) => {
+ eventSource.close();
+ if (event.data) {
+ reject(new Error(JSON.parse(event.data).error));
+ }
+ });
+ });
+}
+
+// Usage
+const result = await streamChat(
+ "Rewrite section 2 to be more formal",
+ "streaming-demo",
+ currentHtml
+);
+console.log("AI:", result.response);
+```
+
+## Upload document and export
+
+```javascript theme={null}
+const API_KEY = "sk_YOUR_API_KEY";
+const BASE = "https://api.superdocs.app";
+const headers = { Authorization: `Bearer ${API_KEY}` };
+
+// Upload a file as the active document
+async function uploadDocument(file, sessionId) {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("session_id", sessionId);
+
+ const res = await fetch(`${BASE}/v1/documents/upload`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${API_KEY}` },
+ body: formData,
+ });
+ return res.json(); // { html, session_id, filename, chunks_count, version_id }
+}
+
+// Export and download a document. Supported formats: "docx" (default),
+// "pdf", "html", "markdown", "txt".
+async function exportDocument(sessionId, format = "docx", options = {}) {
+ const res = await fetch(`${BASE}/v1/documents/export`, {
+ method: "POST",
+ headers: { ...headers, "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: sessionId, format, options }),
+ });
+
+ // Surface any non-fatal warnings from the export pipeline
+ const warningsHeader = res.headers.get("X-Export-Warnings");
+ if (warningsHeader) {
+ const warnings = JSON.parse(atob(warningsHeader));
+ console.warn(`Export completed with ${warnings.length} warning(s):`, warnings);
+ }
+
+ const blob = await res.blob();
+
+ // File extension mirrors the format value, with one exception
+ const ext = format === "markdown" ? "md" : format;
+
+ // Trigger download in browser
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `document.${ext}`;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+// Example: PDF export with paper-size override
+await exportDocument("my-session", "pdf", { paper_size: "A4", watermark_text: "DRAFT" });
+```
+
+See [Exporting documents](/concepts/documents#exporting-documents) for the full options reference and the upload-then-export pattern for documents over 20 MB.
+
+## File upload
+
+```javascript theme={null}
+async function uploadAttachment(file, sessionId) {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("session_id", sessionId);
+
+ const response = await fetch(`${BASE}/v1/attachments/upload`, {
+ method: "POST",
+ headers: { "Authorization": `Bearer ${API_KEY}` },
+ body: formData
+ });
+
+ return response.json();
+}
+
+// Browser usage
+const fileInput = document.querySelector('input[type="file"]');
+const result = await uploadAttachment(fileInput.files[0], "my-session");
+console.log("Job ID:", result.job_id);
+```
+
+## Job polling
+
+```javascript theme={null}
+async function pollJob(jobId) {
+ while (true) {
+ const response = await fetch(`${BASE}/v1/jobs/${jobId}`, {
+ headers: { "Authorization": `Bearer ${API_KEY}` }
+ });
+ const job = await response.json();
+
+ if (job.status === "completed") return job.result;
+ if (job.status === "failed") throw new Error(job.error);
+ if (job.status === "awaiting_approval") return job;
+
+ await new Promise(r => setTimeout(r, 2000));
+ }
+}
+
+// Usage
+const { job_id } = await uploadAttachment(file, "my-session");
+const result = await pollJob(job_id);
+console.log("Attachment processed:", result.attachment_id);
+```
+
+## HITL approval
+
+```javascript theme={null}
+async function chatWithApproval(message, sessionId, documentHtml) {
+ // Start with approval mode
+ const response = await fetch(`${BASE}/v1/chat/async`, {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${API_KEY}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ message,
+ session_id: sessionId,
+ document_html: documentHtml,
+ approval_mode: "ask_every_time"
+ })
+ });
+
+ const { job_id } = await response.json();
+
+ // Poll and approve
+ while (true) {
+ const jobResponse = await fetch(`${BASE}/v1/jobs/${job_id}`, {
+ headers: { "Authorization": `Bearer ${API_KEY}` }
+ });
+ const job = await jobResponse.json();
+
+ if (job.status === "completed") return job.result;
+ if (job.status === "failed") throw new Error(job.error);
+
+ if (job.status === "awaiting_approval") {
+ const changes = job.metadata.pending_changes;
+ console.log(`${changes.length} change(s) proposed`);
+
+ // Auto-approve all (replace with UI in production)
+ await fetch(`${BASE}/v1/chat/${sessionId}/approve`, {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${API_KEY}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ job_id,
+ approved: true,
+ changes: changes.map(c => ({ change_id: c.change_id, approved: true }))
+ })
+ });
+ }
+
+ await new Promise(r => setTimeout(r, 2000));
+ }
+}
+```
+
+## HITL approval with SSE streaming (real-time progress)
+
+The polling pattern above is fine for server-to-server flows where no human is watching. For interactive editor integrations GÇö where a user is staring at the chat panel waiting for their edit GÇö open the SSE stream alongside the async job so progress events arrive in real time and the UI never goes silent for more than a few seconds.
+
+```javascript theme={null} theme={null}
+async function chatWithApprovalAndStreaming(message, sessionId, documentHtml, callbacks) {
+ // 1. Start async job
+ const r = await fetch(`${BASE}/v1/chat/async`, {
+ method: "POST",
+ headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message,
+ session_id: sessionId,
+ document_html: documentHtml,
+ approval_mode: "ask_every_time",
+ }),
+ });
+ const { job_id } = await r.json();
+
+ // 2. Open SSE stream and surface every event to the UI in real time
+ return new Promise((resolve, reject) => {
+ const es = new EventSource(
+ `${BASE}/v1/chat/${sessionId}/stream?job_id=${job_id}&api_key=${API_KEY}`
+ );
+
+ // Render in-flight progress so the UI never looks frozen
+ es.addEventListener("intermediate", (event) => {
+ const data = JSON.parse(event.data);
+ callbacks.onProgress?.(data.content);
+ });
+
+ // The whole batch of proposed changes arrives in one event GÇö even a
+ // single-change turn comes through here as a one-element changes[] array.
+ // (You may also register "proposed_change" defensively, but it never fires
+ // today GÇö proposed_change_batch is the event that's emitted.)
+ es.addEventListener("proposed_change_batch", (event) => {
+ const envelope = JSON.parse(event.data);
+ const batch = JSON.parse(envelope.content); // double-parse GÇö required
+ // Surface every change in the batch for review.
+ for (const change of batch.changes) {
+ callbacks.onProposedChange?.(change, async (decision) => {
+ // Send this change's approve/deny decision back. SSE stays open through the cycle.
+ await fetch(`${BASE}/v1/chat/${sessionId}/approve`, {
+ method: "POST",
+ headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
+ body: JSON.stringify({
+ job_id,
+ approved: true,
+ changes: [{ change_id: change.change_id, ...decision }],
+ }),
+ });
+ });
+ }
+ });
+
+ es.addEventListener("final", (event) => {
+ es.close();
+ resolve(JSON.parse(event.data));
+ });
+
+ es.addEventListener("error", (event) => {
+ es.close();
+ reject(new Error(event.data ? JSON.parse(event.data).error : "SSE stream error"));
+ });
+ });
+}
+
+// Usage in a chat UI:
+chatWithApprovalAndStreaming(
+ "Tighten the OBLIGATIONS section",
+ "session-abc",
+ document.querySelector("#editor").innerHTML,
+ {
+ onProgress: (text) => {
+ // Update the in-flight chat bubble GÇö see Streaming guide for the bubble pattern
+ updateInFlightBubble(text);
+ },
+ onProposedChange: (change, respond) => {
+ showInlineDiffCard({
+ chunkId: change.chunk_id,
+ oldHtml: change.old_html,
+ newHtml: change.new_html,
+ explanation: change.ai_explanation,
+ onApprove: () => respond({ approved: true }),
+ onDeny: (feedback) => respond({ approved: false, feedback }),
+ });
+ },
+ }
+).then((result) => {
+ // Replace the in-flight bubble with the final response, swap in result.updated_html if present
+ finalizeBubble(result);
+});
+```
+
+**When to use streaming vs polling.**
+
+* **Streaming** GÇö interactive editor UIs, any flow where a user is watching. Users see progress text within 1GÇô5 seconds, and the turn's proposed changes arrive together in one `proposed_change_batch` event ready to render as a review list.
+* **Polling** GÇö background batch processing, server-to-server flows, queues where the user isn't waiting. Simpler to implement; perfectly fine when latency feedback isn't a UX requirement.
+
+See [Streaming GåÆ Rendering intermediate events](/guides/streaming#rendering-intermediate-events-in-your-ui) for the in-flight bubble pattern, and [Async jobs GåÆ Latency expectations](/guides/async-jobs#latency-expectations-and-timeout-strategy) for typical operation durations.
+
+## Frontend layout
+
+Most products that integrate SuperDocs converge on a two-pane layout: the document editor on the left (primary area) and a chat panel on the right (fixed width, 380GÇô440 px). The skeleton below is framework-agnostic GÇö the same shape works in React, Vue, Svelte, or plain HTML.
+
+```html theme={null} theme={null}
+
+
+
+ Your App
+ session: my-session
+ 42 / 500 ops
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Notes on the pattern:
+
+* **Inline diff overlays** render inside the editor pane (see [Rendering diffs inline in your editor](/guides/human-in-the-loop#rendering-diffs-inline-in-your-editor)). The chat pane only holds the message log and the batch-approve bar.
+* **The chat panel reads editor HTML before every send** and writes it back on every `final` event. Expose this through a ref / store so the chat doesn't have to know about your editor's internals.
+* **One session per tab.** Generate a random `session_id` on page load and reuse it for every request in that tab. SuperDocs persists conversations across server restarts.
+* **API key stays server-side.** If you're building a browser app, proxy SuperDocs calls through your own backend so `sk_...` never reaches the browser. See the [streaming guide](/guides/streaming) for the `api_key` query-parameter workaround for `EventSource`, which cannot set custom headers.
+
+
+# Python Examples
+Source: https://docs.superdocs.app/examples/python
+
+Complete working Python examples for the SuperDocs API using the requests library.
+
+# Python Examples
+
+All examples use the `requests` library. Install it with `pip install requests`.
+
+## Basic chat with document
+
+```python theme={null}
+import requests
+
+API_KEY = "sk_YOUR_API_KEY"
+BASE = "https://api.superdocs.app"
+HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
+
+response = requests.post(f"{BASE}/v1/chat", headers=HEADERS, json={
+ "message": "Add an executive summary at the top",
+ "session_id": "python-demo",
+ "document_html": "
Q4 Report
Revenue grew 15% year over year.
"
+})
+
+data = response.json()
+print("AI:", data["response"])
+
+if data.get("document_changes"):
+ updated_html = data["document_changes"]["updated_html"]
+ print("Document updated. Version:", data["document_changes"]["version_id"])
+```
+
+## Handle response and preserve HTML
+
+```python theme={null}
+# First request
+response = requests.post(f"{BASE}/v1/chat", headers=HEADERS, json={
+ "message": "Create a project plan with 3 sections",
+ "session_id": "project-plan",
+ "document_html": ""
+})
+data = response.json()
+current_html = data["document_changes"]["updated_html"]
+
+# Second request GÇö send the HTML back as-is
+response = requests.post(f"{BASE}/v1/chat", headers=HEADERS, json={
+ "message": "Add deadlines to each section",
+ "session_id": "project-plan",
+ "document_html": current_html # preserve data-chunk-id attributes
+})
+data = response.json()
+current_html = data["document_changes"]["updated_html"]
+```
+
+## Upload document and edit it
+
+```python theme={null}
+# Upload a file as the active document
+upload = requests.post(f"{BASE}/v1/documents/upload",
+ headers={"Authorization": f"Bearer {API_KEY}"},
+ files={"file": open("contract.docx", "rb")},
+ data={"session_id": "doc-edit"}
+)
+doc = upload.json()
+print(f"Loaded {doc['filename']} ({doc['chunks_count']} sections)")
+
+# Edit it with AI
+response = requests.post(f"{BASE}/v1/chat", headers=HEADERS, json={
+ "message": "Simplify the language throughout",
+ "session_id": "doc-edit",
+ "document_html": doc["html"]
+})
+updated_html = response.json()["document_changes"]["updated_html"]
+```
+
+## Export a document
+
+```python theme={null}
+# Export from a session as DOCX (default format)
+response = requests.post(f"{BASE}/v1/documents/export", headers=HEADERS, json={
+ "session_id": "doc-edit",
+ "format": "docx",
+ "options": {"filename": "simplified-contract"},
+})
+
+with open("simplified-contract.docx", "wb") as f:
+ f.write(response.content)
+
+# Export from HTML directly as PDF with A4 paper and a watermark
+response = requests.post(f"{BASE}/v1/documents/export", headers=HEADERS, json={
+ "html": updated_html,
+ "format": "pdf",
+ "options": {
+ "paper_size": "A4",
+ "watermark_text": "DRAFT",
+ },
+})
+
+with open("export.pdf", "wb") as f:
+ f.write(response.content)
+```
+
+Supported formats: `docx` (default), `pdf`, `html`, `markdown`, `txt`. See [Exporting documents](/concepts/documents#exporting-documents) for the full options reference and large-document workflows.
+
+## Upload attachment and poll for completion
+
+```python theme={null}
+import time
+
+# Upload
+with open("reference.pdf", "rb") as f:
+ response = requests.post(
+ f"{BASE}/v1/attachments/upload",
+ headers={"Authorization": f"Bearer {API_KEY}"},
+ files={"file": ("reference.pdf", f, "application/pdf")},
+ data={"session_id": "my-session"}
+ )
+
+job_id = response.json()["job_id"]
+print(f"Upload started: {job_id}")
+
+# Poll until ready
+while True:
+ job = requests.get(f"{BASE}/v1/jobs/{job_id}", headers=HEADERS).json()
+ if job["status"] == "completed":
+ print("Attachment ready")
+ break
+ elif job["status"] == "failed":
+ print("Processing failed:", job["error"])
+ break
+ time.sleep(2)
+
+# Now chat GÇö the AI can reference the attachment
+response = requests.post(f"{BASE}/v1/chat", headers=HEADERS, json={
+ "message": "Summarize the key points from the uploaded reference document",
+ "session_id": "my-session"
+})
+print("AI:", response.json()["response"])
+```
+
+## Async job with polling
+
+```python theme={null}
+import time
+
+# Start async
+response = requests.post(f"{BASE}/v1/chat/async", headers=HEADERS, json={
+ "message": "Rewrite this entire document in formal legal language",
+ "session_id": "legal-rewrite",
+ "document_html": "
Agreement
We agree to work together on the project.
"
+})
+job_id = response.json()["job_id"]
+
+# Poll
+while True:
+ job = requests.get(f"{BASE}/v1/jobs/{job_id}", headers=HEADERS).json()
+ print(f"Status: {job['status']}")
+
+ if job["status"] == "completed":
+ result = job["result"]
+ print("AI:", result["response"])
+ if result.get("document_changes"):
+ print("Updated HTML:", result["document_changes"]["updated_html"][:200])
+ break
+ elif job["status"] == "failed":
+ print("Error:", job["error"])
+ break
+
+ time.sleep(2)
+```
+
+## SSE streaming
+
+```python theme={null}
+import json
+import requests
+
+# Start async job first
+response = requests.post(f"{BASE}/v1/chat/async", headers=HEADERS, json={
+ "message": "Write a detailed analysis of section 2",
+ "session_id": "streaming-demo",
+ "document_html": "..."
+})
+job_id = response.json()["job_id"]
+
+# Stream progress
+url = f"{BASE}/v1/chat/streaming-demo/stream?job_id={job_id}&api_key={API_KEY}"
+with requests.get(url, stream=True) as stream:
+ current_event = None
+ for line in stream.iter_lines():
+ if not line:
+ continue
+ decoded = line.decode("utf-8")
+ if decoded.startswith("event: "):
+ current_event = decoded[7:]
+ elif decoded.startswith("data: "):
+ data = json.loads(decoded[6:])
+ if current_event == "intermediate":
+ print(f"Progress: {data['content']}")
+ elif current_event == "final":
+ print(f"Done: {data['result']['response'][:100]}")
+ break
+ elif current_event == "error":
+ print(f"Error: {data['error']}")
+ break
+```
+
+## HITL approval flow
+
+```python theme={null}
+import time
+
+# Start with approval mode
+response = requests.post(f"{BASE}/v1/chat/async", headers=HEADERS, json={
+ "message": "Update the liability clause to cap damages at $1M",
+ "session_id": "contract-review",
+ "document_html": "...",
+ "approval_mode": "ask_every_time"
+})
+job_id = response.json()["job_id"]
+
+# Poll and approve
+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"]
+ print(f"\n{len(changes)} change(s) proposed:")
+ for c in changes:
+ print(f" - {c['operation']}: {c.get('ai_explanation', 'No explanation')}")
+
+ # Approve all
+ 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("Approved all changes")
+
+ time.sleep(2)
+```
+
+
+# Features
+Source: https://docs.superdocs.app/features
+
+Comprehensive capability reference for SuperDocs GÇö every shipped feature, grouped by what you'd reach for it for.
+
+# Features
+
+SuperDocs is a universal AI document platform GÇö one API for editing, drafting, searching, summarizing, generating visual content (images, diagrams, drawings, equations), and exporting styled documents (`.docx`, PDF, HTML, Markdown, plain text). Every capability below is live in production at `https://api.superdocs.app` and exposed identically over the REST API and the MCP server at `https://api.superdocs.app/mcp/` (38 tools + 4 user-invocable workflow prompts on a single endpoint).
+
+This page is exhaustive GÇö it's a reference, not a marketing summary. Skip to the group that matches what you're trying to build.
+
+***
+
+## Core editing intelligence
+
+### Section-precision editing
+
+Documents load as structured HTML where every paragraph, heading, table, row, and cell carries a unique identifier internally. The AI can target a specific section without touching anything else GÇö **"remove row 3 of the pricing table"**, **"bold the second paragraph in section 4"**, **"replace the governing-law clause"** all work as natural-language instructions.
+
+This survives across multi-turn editing, so a 100-page document edited 30 times still has the same coherent structure at the end as it did at the start. Plain-text rewrites lose this completely.
+
+**Best for:** Long documents, contracts, SOPs, formal reports GÇö anywhere targeted edits matter more than wholesale rewrites.
+
+### Style preservation on edit and export
+
+Tables (with borders, alternating row shading, merged cells), fonts, font sizes, colors, inline styling, lists, indentation, and headers/footers all survive both AI edits AND the round-trip to `.docx` / PDF / HTML. Most general-purpose AI tools strip or mangle these GÇö SuperDocs is built around preserving them.
+
+**Best for:** Branded templates, legal documents, formal correspondence GÇö anywhere format fidelity is part of the deliverable.
+
+***
+
+## Document intelligence
+
+### Search within documents
+
+Search isn't just full-text matching GÇö the AI understands semantic meaning. Ask **"find all indemnity clauses"** across a 100-page contract and get back the exact sections, even when they use different wording. Works against the active document or across all attachments at once. Results come back with surrounding context so you can verify the match without opening the file.
+
+**Best for:** Contract review, compliance audits, extracting specific terms from long documents, finding all references to a concept across multiple files.
+
+### Summarize sections on demand
+
+Ask **"summarize the force majeure section"** or **"give me a 2-line summary of the payment terms"** and the AI extracts just that section, then summarizes it at the level of detail you want. Pair with semantic search GÇö **"find and summarize all limitation-of-liability clauses"** works as a single instruction. No need to read line-by-line.
+
+**Best for:** Contract reviews, due diligence, quick understanding of specific parts of long documents, briefing materials.
+
+### Summarize entire documents at any length
+
+Get a concise summary of an entire document at a length you specify. **"Executive summary in three sentences"**, **"one-page overview"**, **"detailed bullet-point summary by section"** GÇö all work. The AI reads the full document and distills it efficiently. Summaries cover key sections, critical terms, and overall structure. Works on documents up to \~100 pages without context-window pressure.
+
+**Best for:** Quick understanding of newly-received documents, briefing executives, preparing for negotiations, extracting key terms from regulatory filings.
+
+### Cross-document reference and synthesis
+
+Upload multiple documents into a session and the AI references any of them while editing the active document. **"Port the indemnity clause from the attached template into this contract"**, **"compare these two NDAs and align the payment terms"**, **"find clauses in the attached precedent that handle this case better than what's currently in the draft"** GÇö all work as natural-language instructions. The AI searches across all attachments, surfaces matches, and adapts content to the current context.
+
+**Best for:** Standardizing language across contracts, porting terms from templates, comparing versions, drafting new docs based on prior precedents, building from clause libraries.
+
+***
+
+## Rich editing & formatting
+
+### Full rich-text formatting toolbar
+
+Beyond basic editing GÇö the AI applies bold, italic, underline, strikethrough, text color (10-color palette), text highlight, custom font selection, six preset font sizes (12pxGÇô30px), configurable line spacing (single, 1.15, 1.5, double), and text alignment (left, center, right, justify). All preserved on round-trip exports. The format painter lets you copy a style from one selection and apply it to another with a single click GÇö useful for matching styling across a long document.
+
+**Best for:** Visually distinctive documents, applying brand style guides, adapting documents for different audiences, preserving template styling on edit.
+
+### Heading and list hierarchies
+
+Apply heading styles (H1, H2, H3) with hierarchy preserved on export. Create bulleted and numbered lists with automatic numbering. Nest lists up to 8 levels deep. Convert between bullet and numbered formats by natural-language instruction. Add blockquotes for citations or emphasis. Horizontal rules to divide sections. The AI respects these structures GÇö **"convert the first three paragraphs to a bulleted list"** works without reformatting surrounding content.
+
+**Best for:** SOPs, structured documentation, proposals, outlines, academic papers, anywhere hierarchy matters.
+
+### Table editing with cell-level control
+
+Insert and delete tables and rows. Merge or split cells. Apply alternating row shading. Set border styles. All cell-level operations work via natural language: **"merge the first three columns of row 2"**, **"add a row after row 4 with these values"**, **"split the merged cell in A1"**. Tables survive round-trip to `.docx` and PDF with formatting intact. Useful for pricing tables, comparison matrices, and any data-heavy document.
+
+**Best for:** Documents with pricing or data GÇö proposals, contracts with pricing schedules, technical specifications, comparison charts.
+
+### Headers, footers, footnotes, comments, and hyperlinks
+
+Add headers and footers that appear on every page (or on the first page / odd / even pages only) GÇö page numbers, document titles, company names, dates. Hyperlinks work the same way: **"make this text link to our website"** or **"remove the broken link in section 4"**. Both internal document references and external URLs supported.
+
+**Best for:** Formal business documents, branded templates, compliance documents requiring page numbering, documents with cross-references or call-to-action links.
+
+### Edit headers, footers, footnotes, and comments by chat
+
+Headers, footers, footnotes, endnotes, and comments are first-class parts of the document, not flattened into the body GÇö and you can edit any of them by chatting. **"Change the footer to include the confidentiality notice"**, **"fix the typo in footnote 3"**, **"update the running header on the first page"**, **"add a comment on the payment clause asking legal to review it"** all work as natural-language instructions.
+
+These edits ride the **same review flow** as body-content edits: a single turn can propose both a body change and a header change, and in human-in-the-loop mode each arrives as its own before/after entry in the turn's review card so you approve them independently. Because parts live alongside the document, they survive edits, revert, multi-document sessions, and the round-trip to `.docx` and PDF GÇö a footnote you edit stays a real Word footnote on export, not inline text.
+
+**Best for:** Contracts and formal reports where footnotes and reviewer comments carry legal weight, branded templates with per-section running headers, editorial workflows where notes and comments are part of the deliverable.
+
+***
+
+## Visual content & media
+
+Generate, edit, and embed images, diagrams, drawings, and equations directly inside your document GÇö all by chatting. Every operation rides the same chat interface; no separate tools or modes to learn.
+
+### Generate images from a description
+
+Describe the image you want and the AI creates it and inserts it into the document. Photorealistic shots, illustrations, hero images, logos, infographics with text GÇö all by natural-language instruction. The AI picks the right generation model automatically: a photorealistic image generator by default, and a text-rendering-optimized model for prompts that need legible text on the image (logos, infographics, slide hero shots, anything where letterforms matter more than photographic realism).
+
+Generated images live in the document like any other photo GÇö they can be edited, replaced, or removed afterward by chat.
+
+**Best for:** Marketing pages, proposals, sales decks, blog posts, product specs, anything where finding stock imagery is friction.
+
+### Edit any image with natural language
+
+Click any image in the editor and tell the AI what to change. Supported edits include brightness, darkness, contrast, recolor (e.g., "warm sepia", "cooler blues"), circular mask with transparent corners, tight crop to the subject, semi-transparent fade, and full background removal GÇö plus anything else you'd describe in plain English to a photo editor.
+
+The AI generates new bytes and swaps them in. Subject identity (faces, geometry, branding) is preserved across the edit. Each edit takes about 20 seconds. You can iterate (**"a bit warmer"**, **"now crop tighter"**) and use **Reset to Original** to revert to the original bytes at any time.
+
+**Best for:** Marketing assets that need to match a campaign, reports and contracts where logos need a quick cleanup, design docs where reference shots need treatment, anywhere a small photo edit would otherwise mean leaving the document.
+
+### Replace an image with an attachment
+
+Attach a fresh image to chat (the new logo, the updated headshot, the corrected diagram screenshot) and ask the AI to swap it in for an existing image in the document. Pure URL swap GÇö no AI generation cost, no waiting. Works for one-off updates (**"replace the cover photo with this"**) and for recurring patterns where users supply a fresh asset on every iteration of the document.
+
+**Best for:** Branded templates that need per-customer assets, document workflows where users supply their own imagery, content updates after a rebrand.
+
+### Auto-rendered diagrams (Mermaid)
+
+Describe a flowchart, sequence diagram, org chart, ER diagram, mindmap, timeline, or Gantt and the AI emits the diagram code; the editor live-renders it as a clean SVG. Re-editing is instant and free of regeneration cost GÇö say **"add a Police Verification step after Background Check"** and the AI rewrites the diagram in place rather than regenerating the whole image.
+
+Diagrams export correctly in PDF and `.docx` (rendered to image at export time). The full Mermaid syntax catalog is supported GÇö flowchart, sequence, class, state, ER, user journey, gantt, pie, mindmap, timeline, xychart.
+
+**Best for:** Technical documentation, process docs, runbooks, architecture overviews, training materials GÇö anywhere a diagram beats a paragraph.
+
+### Hand-drawn sketches with a built-in canvas
+
+Open a freehand drawing canvas inside the editor GÇö sketch quickly with shapes, arrows, and freehand strokes, save, and the drawing lands in the document as an image. Click it later, hit **Redraw**, and the canvas reopens with your original strokes so you can tweak them.
+
+The same drawing can also be edited as an image via natural language (**"turn this rough sketch into a realistic logo"**) GÇö and the original strokes are still kept on the image, so you can redraw from scratch even after AI editing.
+
+**Best for:** Whiteboarding inside a doc, quick architecture sketches, annotating a screenshot, turning a hand-sketched logo into something polished without leaving the editor.
+
+### Math equations (LaTeX / KaTeX)
+
+Insert math equations using familiar LaTeX syntax GÇö inline (`$x^2$`) or block (`$$x^2 + y^2 = z^2$$`). The editor live-renders them with KaTeX. Equations survive `.docx` and PDF export.
+
+The toolbar has an equation button that prompts for LaTeX so you don't have to remember the delimiters; in chat you can also just say **"add the quadratic formula here"** and the AI will insert the right LaTeX in place.
+
+**Best for:** Academic papers, technical reports, finance documents with formulas, engineering specs, anywhere math notation needs to be written correctly the first time.
+
+### Auto-generated table of contents
+
+Drop a table-of-contents block into the document and it auto-populates from your H1/H2/H3 headings. Add or rename a heading later GÇö the TOC updates itself. Click a TOC entry to jump to that section. Survives export to PDF and `.docx` (rendered as a list of section titles).
+
+**Best for:** Long documents, manuals, books, regulatory filings GÇö anywhere 10+ pages where readers need to navigate.
+
+***
+
+## Knowledge & attachments
+
+### Multimodal vision on attachments
+
+Attach images (PNG, JPG, GIF, WebP), screenshots, scanned forms, diagrams, and charts GÇö the AI interprets them visually while editing the active document. Transcribe a screenshot into structured text, extract numbers from a chart, reference a diagram while drafting documentation, identify entities in a scanned form.
+
+**Best for:** Workflows that mix text documents with image references GÇö design docs, compliance docs that reference scanned forms, technical writing that references architecture diagrams.
+
+### Build a knowledge base from attachments
+
+Upload your organization's template library, style guides, past contracts, or SOP documents as attachments. The AI references them automatically when editing the active document GÇö **"make this sound like our standard tone"**, **"follow the format of our usual NDAs"**, **"use the indemnity language from our gold-standard contract"**. Builds an institutional memory that shapes AI behavior. Attachments are scoped per session, so a knowledge-base session can be reused as a starting point and copied for each new document.
+
+**Best for:** B2B teams with house styles or templates, legal teams with clause libraries, marketing teams with brand voice, compliance teams with corporate policies.
+
+### Semantic search across attachments
+
+Every attachment is semantically indexed when uploaded GÇö the AI knows what's in it, not just the words it contains. Ask **"find the data processing clause from the attached regulations"** and the search returns the matching section even when the attached doc uses entirely different wording. Useful for large attachments (50+ pages) where keyword search would miss relevant sections, and for reference documents you query repeatedly across sessions.
+
+**Best for:** Reference docs (regulations, industry standards, policy manuals), large attachments queried multiple times, building searchable knowledge over time.
+
+***
+
+## Conversation & robustness
+
+### Multiple open documents in one session
+
+A session can hold several open documents at once GÇö like editor tabs. The AI sees the full roster, edits whichever document your request targets (explicit `document_id`, named in the message, or the focused one by default), reads or searches across all of them, moves content between them, and can open a brand-new document on request ("put the summary in a new document"). Uploads choose their behavior via `open_mode` (`replace` / `new_focused` / `background`), and three endpoints manage the roster (list / focus / close) GÇö also exposed as MCP tools. See the [Multi-Document Sessions guide](/guides/multi-document).
+
+**Best for:** invoice + rate card workflows, contract + amendment pairs, splitting AI output into a separate deliverable, any flow where one conversation spans several files.
+
+### Persistent conversation context
+
+Every message in a session carries the full conversation history. Ask **"make that more formal"**, then **"add three paragraphs of detail to what you just wrote"**, then **"change the tone back to friendly"** GÇö the AI remembers all prior context, edits, and stated preferences. History persists across server restarts and redeploys. Reload a session weeks later and the AI still has the full context (the document, the attachments, every turn of the conversation, every change made).
+
+**Best for:** Iterative editing workflows, multi-turn refinements, long-running document projects, anything where the user comes back to continue work later.
+
+### Automatic error recovery with fallbacks
+
+When an edit fails on the first approach (e.g., the section the AI tried to find didn't exist with those exact keywords), it automatically tries broader strategies GÇö semantic search instead of keyword match, alternative phrasings of the user intent, looking in adjacent sections. Retries happen without user intervention. If all approaches genuinely fail, the AI explains what it tried and asks for clarification with specifics rather than a generic error. Dramatically reduces "I didn't understand your request" loops.
+
+**Best for:** Complex documents with varied terminology, ambiguous instructions, long documents where sections may have been edited since last viewed.
+
+***
+
+## Human control & approval
+
+### Human-in-the-loop approval
+
+For sensitive edits (legal contracts, financial filings, anything user-facing), set `approval_mode='ask_every_time'` on `chat_async` and the agent surfaces each proposed change as a structured diff (chunk-level before/after HTML + an explanation) for the user to approve, deny, or send back with feedback. Approved changes apply atomically; denied changes leave the document untouched. State persists across server restarts so multi-step approvals survive autoscaling.
+
+**Best for:** Multi-stakeholder workflows, regulated industries, anywhere the cost of a bad edit landing without review exceeds the cost of a confirmation click.
+
+### Compact response mode for long editing sessions
+
+On documents larger than \~20 pages, the default `chat` response includes the entire updated HTML on every turn GÇö that's \~130K tokens for a 100-page styled doc. Set `response_mode='compact'` and the response includes only `chunk_diffs` (per-section before/after for sections that actually changed) GÇö typically 1-3 chunks, \~500-2,000 tokens.
+
+For a 5-turn editing session on a 100-page doc, compact mode reduces total response context from \~650K tokens to \~3K tokens. To read sections in compact mode, just ask in natural language GÇö **"show me the force majeure clause"** returns the content in the chat reply text.
+
+**Best for:** AI agents editing documents larger than \~20 pages where context window pressure matters.
+
+### Real-time progress via SSE
+
+Subscribe to `/v1/chat/{session_id}/stream` to receive intermediate progress events while the agent works GÇö `intermediate` (status updates), `proposed_change_batch` (HITL diff for a turn's proposed changes delivered as one event GÇö used even for a single change; header / footer / footnote / comment edits arrive here too, each as its own entry), `document_sync` (chunk-ID sync after upload), `continue_prompt` (a large edit paused and is waiting for you to continue or stop), `documents_changed` (which documents an auto-applied turn touched, for multi-document sessions), `model_fallback` (the service automatically failed over to a different model tier), `final` (completed response), `usage` (operation count + tokens), `error`. Auto-reconnect on drop.
+
+**Best for:** Frontends that show live progress, status bars, or partial results before the full answer is ready.
+
+***
+
+## Multi-format I/O
+
+### Multi-format input and output
+
+| Input formats | Output formats |
+| ------------------------------------------------------------------------------------------------- | ---------------------------------------- |
+| `.docx`, `.doc`, `.odt`, `.rtf`, `.tex` (+ LaTeX project `.zip`), PDF, HTML, Markdown, plain text | `.docx`, PDF, HTML, Markdown, plain text |
+
+Same parsing pipeline regardless of input GÇö once a document is loaded into a session, every editing tool works identically. Legacy Word (`.doc`), OpenDocument (`.odt`), and Rich Text (`.rtf`) files are normalized into the same rich pipeline as `.docx`, so headings, tables, and styling carry through. LaTeX uploads (single `.tex` files or whole project archives with chapters, figures, and bibliographies) import with lossless math, real footnotes, rendered citations, and source-true page geometry GÇö see [Attachments](/concepts/attachments) for the details. Export from any session in any of the five output formats, with per-export customisation for paper size, orientation, margins, filename, image embedding, and optional PDF watermarks. Documents above 20 MB use a pre-signed upload pattern to bypass standard request body limits; documents above 100 MB are rendered asynchronously and delivered via email.
+
+**Best for:** Format conversion workflows (HTML GåÆ styled `.docx`, Markdown GåÆ PDF, `.docx` GåÆ Markdown), accepting user uploads in whatever format they have, or producing print-ready PDFs with custom paper sizes and watermarks.
+
+### High-fidelity export
+
+Exports reproduce the real structure of a document, not a flattened approximation. On `.docx` export, footnotes and endnotes come back as genuine Word footnotes/endnotes, comments as real Word comments, headers and footers as real header/footer parts (with live page-number and page-count fields), equations as native Word math you can keep editing in Word (not pictures of math), and page geometry GÇö margins, orientation, section breaks, and multi-column layout GÇö is preserved section by section. On PDF export, headers and footers render in the page margins with working page numbers, each section keeps its own page geometry, and math renders as typeset equations. Math (LaTeX), diagrams, and drawings round-trip in both formats. Bookmarks and internal cross-reference targets survive upload, editing, and export, and external hyperlinks in uploaded `.docx` files stay live links.
+
+Export defaults to a high-fidelity mode that preserves the source formatting faithfully (for example, it won't impose table borders or full-width tables that weren't in your document). A per-request `fidelity` option lets you choose `strict` (the faithful default) or `compat` (the previous rendering behaviour) if you need the older output for an existing pipeline.
+
+**Best for:** Legal and financial documents where footnotes, comments, and page layout are part of the record; branded templates that must survive the round-trip exactly; academic papers with equations and multi-column layouts.
+
+### High-fidelity PDF import
+
+PDFs upload with their full content preserved GÇö page text, **annotations** (sticky notes, highlights, FreeText callouts, reviewer comments), and **embedded images**. Every annotation is visible to the AI with the original author and date attached, so editing instructions like **"apply the corrections from the auditor's notes"** or **"reflect the reviewer's comments in section 3"** work natively. Embedded images (logos, photos, diagrams) become full first-class images you can replace, edit, or reference visually GÇö they don't get silently dropped to text-only.
+
+**Best for:** Compliance and audit workflows where PDFs carry reviewer annotations; brand and marketing collateral PDFs where embedded photos must round-trip; any enterprise GRC review cycle that uses annotated PDFs.
+
+### Scanned and complex-layout PDFs
+
+Scanned or image-only PDFs GÇö where the pages are pictures with no selectable text GÇö go through text recognition and come back as editable text. PDFs with harder layouts (multiple text columns, dense ruled tables) are read with their reading order and table structure preserved rather than collapsed into a jumble GÇö headings, links, tables, and images come through as structured, editable content, and hyphenated line breaks are rejoined automatically.
+
+**Best for:** Digitizing scanned contracts and forms, working with academic papers and reports in multi-column layouts, extracting tables from data-heavy PDFs.
+
+### Word track-changes import
+
+Word documents uploaded with **Track Changes** enabled keep all of it on import GÇö insertions, deletions, comments GÇö each tagged with the reviewer's name and date. The AI sees who edited what and can act on it: **"apply all the proofreader's insertions"**, **"reject the deletions in section 2"**, **"address the comments from the legal reviewer"**. Editorial workflows that depend on review markup (academic supervisor edits, legal redlines, teacher-corrected student work) translate straight from Word into SuperDocs.
+
+**Best for:** Editorial review cycles, redline workflows, teacher-student document review, multi-stakeholder review where preserving the trail of who-changed-what matters.
+
+### Reliable multi-file attachments
+
+Attach more than one file in the same message and reference each by filename GÇö **"compare the clauses in `contract_v2.pdf` against `contract_v1.pdf`"** or **"merge `outline.txt` into `proposal.docx`"**. Filename-based lookup is exact (case-insensitive, partial match) so the AI doesn't have to guess from content similarity which file you mean.
+
+**Best for:** Document comparison and merge workflows, multi-file review, proposal assembly from supporting attachments.
+
+### Document length awareness
+
+Ask **"how many pages is this?"** and get a real answer GÇö exact for paginated formats (`.docx`, PDF) and a clearly-labelled approximation ("approximately N pages") for unpaginated formats (HTML, Markdown, plain text). The page count is available as part of attachment metadata so any agent can read it without an extra round trip.
+
+**Best for:** Length-sensitive workflows (briefs with strict page limits, summaries that target a length, budgeting AI work against document size).
+
+### Pre-signed URL upload and download
+
+For files larger than \~100KB, the agent gets a 5-minute pre-signed PUT URL plus a ready-to-run curl example. The agent shells the file directly to cloud storage GÇö bytes never pass through the agent's context window. Same pattern in reverse for downloads (15-minute GET URL).
+
+For a 100-page styled `.docx`, this saves the agent \~70K tokens per upload (vs base64 inline). Five turns of editing on the same document drops from \~700K tokens of context overhead to \~3K tokens with the matching `response_mode='compact'` on chat.
+
+**Best for:** Production AI agents working with real-world document sizes (multi-page contracts, manuals, regulatory filings). Max file size 100 MB; up to 25,000 sections per document and 30,000 per session as standard, expandable on request (stability limits, not platform caps).
+
+***
+
+## Developer & integration
+
+### MCP server GÇö 38 tools, every major client
+
+The same capabilities are exposed as a Model Context Protocol server at `https://api.superdocs.app/mcp/` (Streamable HTTP transport). Compatible clients render the 38 tools natively:
+
+| Client | Status |
+| --------------------------------------- | ----------------- |
+| Claude Code | Tools + Prompts G£ô |
+| Claude Desktop | Tools + Prompts G£ô |
+| Cursor | Tools + Prompts G£ô |
+| VS Code (GitHub Copilot) | Tools + Prompts G£ô |
+| Zed, Continue, Amazon Q CLI, and others | Tools + Prompts G£ô |
+| Windsurf, Cline | Tools only |
+
+The same MCP server also exposes 4 user-invocable workflow templates (surfaced as `/superdocs:edit_styled_docx`, `/superdocs:convert_format`, etc. in clients that render MCP prompts as slash commands). One MCP server entry in your client config; both tools and prompts come together.
+
+**Best for:** AI-coding-tool users who want SuperDocs available in their editor without writing API integration code.
+
+### Three authentication paths
+
+| Method | Best for |
+| ------------------------------ | ------------------------------------------------------------------------------------- |
+| Web app login | The web app at `use.superdocs.app` (auto-issued via Google Sign-In or email/password) |
+| User API key (`sk_GǪ`) | Individual developers, MCP integrations, scripts |
+| Organization API key (`lce_GǪ`) | B2B integrations with shared usage limits |
+
+All three reach the same 38 MCP tools and the same REST API with identical scoping. Users own and manage their own keys; orgs manage theirs separately.
+
+### REST API works with any programming language
+
+One REST API works with any language that can make HTTP requests. No NPM packages to keep current, no language-specific SDKs to maintain, no framework dependencies to upgrade. Integrate with a few lines of code in whatever language your backend already speaks. Full OpenAPI specification published GÇö generate your own typed client with `openapi-generator`, Stainless, or any other codegen tool of your choice if you want type hints in your editor.
+
+**Best for:** Polyglot engineering teams, backend services across multiple languages, avoiding SDK lock-in, simple integrations that don't warrant a full SDK.
+
+### Real-time usage tracking and transparency
+
+Every API response includes usage data GÇö operation count consumed, tokens used. The SSE stream emits a `usage` event after each operation. Your dashboard shows remaining operations in your current tier and resets on your billing cycle. Promo allowances deplete before paid-tier operations so you always burn the cheapest credits first. Full transparency at every step GÇö no hidden costs, no monthly surprises, no need to call sales for usage data.
+
+**Best for:** Cost-conscious integrations, monitoring spend, forecasting overage, teams on a budget, self-service deployments at scale.
+
+***
+
+## Scale & operations
+
+### Sessions and persistence
+
+Every conversation is a `session_id` GÇö a string the caller chooses. The full document state, conversation history, attachments, pending HITL changes, and AI working memory persist across calls and across server restarts. Reload an old session days later and the AI still has the full context.
+
+**Best for:** Long-running document workflows, async editing where the user comes back hours later, multi-turn editing where state carries between turns.
+
+### Async jobs with HITL state
+
+Long-running edits and HITL workflows return a `job_id`; the client polls or subscribes to SSE updates. State persists in a database, so any backend instance can pick up a job mid-flight (autoscaling, restarts, redeploys all safe). Approved changes resume automatically.
+
+**Best for:** Any workflow that takes more than 30 seconds or needs human approval mid-flight.
+
+### Per-organization feature flags
+
+B2B deployments can toggle specific features on or off per organization. Offer one platform integration to all customers but let Enterprise org A use a custom branding skin while Startup org B sticks with the default. Different orgs can have different rate limits, feature sets, or experimental rollouts GÇö all from the same codebase, all controlled by API. Useful for staged feature rollouts, customer-specific customization, and enterprise tier differentiation.
+
+**Best for:** Multi-tenant B2B platforms serving different customer tiers, gradual feature rollouts to specific orgs, enterprise customization without per-customer deployments.
+
+### Promo codes and credit allowances
+
+Issue promo codes that grant temporary operation allowances ("LAUNCH50" = 50 ops valid for 30 days, max 200 redemptions). Users redeem in Settings. Promo operations deplete before paid-tier operations so users get the most out of their allowance. Every redemption is tracked and auditable. Useful for go-to-market campaigns, partner enablement, customer pilots, and time-limited free trial extensions.
+
+**Best for:** Product launches, partner programs, customer pilots, trials, conferences, hackathons.
+
+### Multi-language editing
+
+Natural-language instructions and document content both work across many languages GÇö production users have edited documents in English, Spanish, French, Hebrew, Korean, Mandarin, and others (16+ languages confirmed in real usage so far). Write your prompt in one language, edit a document in another, get the AI's reply in whichever language you wrote the request. Multilingual documents (e.g., bilingual contracts) handled correctly. Tone and formality conventions adapted per language.
+
+**Best for:** International teams, multilingual document workflows, organizations serving non-English markets, contract translation and adaptation, cross-border legal work.
+
+### Build vertical AI on SuperDocs
+
+Combine attachments (your domain knowledge), sessions (long-running workflows), and chat instructions to build domain-specific applications on top of SuperDocs. **Contract AI**: attach your standard clause library + draft instructions, get an AI that writes contracts in your house style. **Compliance AI**: attach your regulations + policy templates, get an AI that audits documents for compliance gaps. **Marketing AI**: attach your brand voice guides + past collateral, get an AI that produces on-brand content. Same platform, different domain GÇö all configurable per session or per organization.
+
+**Best for:** Vertical SaaS platforms, agencies serving specific industries, organizations with strong domain languages, anyone building specialized document workflows.
+
+***
+
+### Per-message chat revert
+
+Rewind a chat session to before any specific user message GÇö both the conversation **and** the document state snap back together. The reverted message text is returned so your UI can pre-fill a compose box for editing. The original branch is kept on the server for audit; the new branch becomes the active timeline.
+
+Available three ways:
+
+* **Web app**: a "Gå¦ Revert" button under every user message bubble, with a confirmation dialog before the rewind.
+* **REST**: `POST /v1/sessions/{session_id}/revert` with `{turn_index}` GÇö see [Sessions](/concepts/sessions#revert-a-session-to-a-previous-message).
+* **MCP**: the `revert_session_to_message` tool, callable by Claude Code, Cursor, Claude Desktop, and any MCP-compatible agent.
+
+If a chat job is currently running for the session, revert returns `409` GÇö wait for it to settle. Available on chats started after the feature shipped (older sessions don't carry the marker the rewind needs).
+
+**Best for:** "oops, undo that AI change" moments, exploring an alternate prompt without starting from scratch, recovering from a misunderstood instruction without losing the rest of your work.
+
+***
+
+## On the roadmap
+
+### Branch switcher
+
+Today the original branch is preserved on the server side after a revert, but it isn't visible in the UI GÇö your active timeline is always the new branch. A future update will surface a switcher so you can navigate between alternate conversation paths the way ChatGPT and Claude do.
+
+***
+
+## What ships when
+
+A live timeline of major capabilities and when they shipped. Older capabilities don't get less reliable over time GÇö once shipped, they stay covered by the regression suite and the production monitoring stack.
+
+| Date | Capability | Why it matters |
+| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-07-20 | **LaTeX upload** GÇö `.tex` files and whole LaTeX project `.zip` archives (root document + `\input` chapters + figures + `.bib` bibliography + custom class files; the shape LaTeX editors export) import as editable documents. The root file is detected automatically; math is **lossless** (every equation keeps its LaTeX source and exports as native Word math), footnotes become real footnotes, citations render against the bibliography, cross-references resolve to their numbers, tables (including merged cells and long tables) import as real tables, and the source's declared page size, margins, font size, and line spacing carry into the editor. Heavily visual custom-class documents GÇö many r+¬sum+¬ and poster templates GÇö are **compiled and imported with their visual layout preserved** instead of losing their content to an approximate text read. Anything unrepresentable (EPS figures, TikZ drawings, compile-time-only header macros) gets a visible placeholder plus a structured ingest warning; a genuinely broken source returns the compile error itself. | Papers, theses, r+¬sum+¬s, and math-heavy documents move from LaTeX into an editable, exportable document without retyping GÇö and nothing degrades silently: every fallback is visible and every failure message says what actually happened. |
+| 2026-07-17 | **Round-trip fidelity and honesty batch**: on `.docx` export, equations (LaTeX) become **native Word math**, editable in Word rather than exported as pictures or plain text; a construct that can't be fully converted keeps its LaTeX source inside the equation and surfaces a `math_partial_fidelity` export warning. **Bookmarks and internal cross-reference targets** now survive upload, editing, and export, and **external hyperlinks** in uploaded `.docx` files stay live links. Word lists that take their numbering from a linked style import as real lists instead of plain paragraphs, and RTF uploads containing emoji or other unusual character escapes import cleanly. **Honest no-op replies**: an edit request the document already satisfies gets an honest "already satisfied" answer, changes nothing, and bills 0; a turn where every attempted operation failed reports the failure instead of claiming success. **Review-state recovery**: `GET /v1/sessions/{session_id}/jobs` (the `get_session_jobs` tool) reliably lists a session's jobs, and an `awaiting_approval` job's `metadata` carries `pending_batch_decisions` alongside `pending_changes`, so a client can rebuild its review UI after a reload without losing partially-decided batches. | Equations, bookmarks, cross-references, hyperlinks, and styled lists no longer degrade silently on the round-trip; agents and integrations can trust a "done" reply (no false success, and no charge when nothing needed changing); pending reviews survive page reloads and client restarts. |
+| 2026-07-16 | **Document-fidelity upgrade** GÇö headers, footers, footnotes, endnotes, and comments become first-class parts you can **edit by chat**, proposed and approved through the same review flow as body edits (each part edit is its own before/after entry); **high-fidelity export** reproduces real Word footnotes/comments/headers and per-section page geometry (margins, orientation, section breaks, columns) on `.docx` and margin-box headers + typeset math on PDF, with a `fidelity` export option (`strict` default / `compat`); **legacy `.doc` / `.odt` / `.rtf` upload** normalized into the same rich pipeline as `.docx`; **scanned and complex-layout PDFs** read via text recognition and layout analysis; Google-Docs / Word-online paste preserves its formatting. | Formatting survives the full round-trip GÇö upload, edit, revert, multi-document, export GÇö with nothing flattened or dropped. Footnotes and comments stay real on export; page layout is preserved section by section; and headers/footers/notes are now editable by natural-language instruction. Existing integrations are unaffected GÇö parts editing rides the existing `chat` / `chat_async` tools and HITL flow, with no new tools, no new stream events, and no new required parameters. |
+| 2026-06-25 | **Clone-and-update** GÇö ask the AI to recreate an existing document with new data ("duplicate this invoice for a new client, change the amounts to GǪ") and it reproduces the original's exact layout, fonts, columns, and structure, changing only the values you name. **Reliable large-document generation** GÇö generating a long, content-heavy document from scratch in a single request GÇö for example a multi-hundred-row table or a long structured report GÇö now completes in full. | Reproducing a document with new data preserves its formatting exactly instead of re-creating it from scratch; long generated documents come back whole. Both work through the existing `chat` / `chat_async` tools GÇö no new parameters, so existing integrations need no changes. |
+| 2026-06-23 | **Durable Files, cross-session memory & search, concurrent-edit conflict resolution, and new MCP tools (35 total)** GÇö documents now persist across sessions as durable **Files**: `list_documents`, `get_document_detail`, `rename_document`, and `archive_document` (soft-delete, recoverable, purged after \~30 days) manage them, while `open_documents` loads saved files into a session and `init_session` creates a session and opens files in one call. Opt-in **cross-session memory and search** let a session draw on durable preferences and prior documents/chats GÇö four off-by-default `/v1/chat` + `/v1/chat/async` fields (`cross_session_memory`, `cross_session_search`, `cross_session_memory_key` for per-end-customer scoping, `cross_session_scope` to narrow which sessions are searched), with `clear_cross_session_memory` to wipe the saved note. A large edit that pauses can be resumed with the new `continue_chat` tool. Two more agent tools round out the set: `redo_revert` (undo a revert GÇö restore the session forward to its pre-revert state) and `unarchive_document` (restore an archived document). A new REST endpoint, `POST /v1/sessions/{session_id}/chunks/{chunk_id}/re-edit`, gives integrators explicit per-section conflict resolution (re-apply the AI's change over the user's current text, or blend the two) GÇö a billable AI edit, REST-only, not an MCP tool. All of this brings the MCP server to **35 tools**; all of cross-session is owner-scoped and never crosses the API-key owner. | Integrations get a persistent document library, optional long-term memory that an agent controls explicitly, deterministic resume for very large edits, undo-the-undo and restore-archived controls, deliberate per-section conflict handling for custom editors, and clean B2B2C isolation GÇö all opt-in, so existing integrations behave exactly as before. |
+| 2026-06-11 | **Multi-document sessions** GÇö a session now holds multiple open documents: `open_mode` on upload (`replace`/`new_focused`/`background`) with the document roster in the response, three new endpoints + MCP tools to list/focus/close session documents, optional `document_id` targeting on `/v1/chat` + `/v1/chat/async`, cross-document reads/search/edits in one turn, and AI-created new documents from chat. **SSE additions** GÇö `documents_changed` (multi-doc auto-apply badge signal) and `model_fallback` (automatic Pro-tier failover notice) events; every event now carries `sequence` and reconnects can pass `last_sequence` to replay only newer events. **Thinking depth on every tier** GÇö `thinking_depth` (`fast`/`balanced`/`deep`) is now honored on all four model tiers. **Page geometry** GÇö document payloads include a nullable `page_setup` (size/orientation/margins detected from DOCX/PDF). **Billing clarity** GÇö every request bills one operation; very large requests bill one operation per 25 sections edited; denied review-mode changes are never billed. | One conversation can orchestrate several files the way a person with multiple tabs would; integrations get deterministic per-document targeting and review grouping; streams resume cleanly after disconnects; reasoning control is uniform across tiers; true-size page rendering becomes possible; operation counting matches intuition. |
+| 2026-05-27 | Export pipeline overhaul GÇö multi-format export (`docx` default, `pdf`, `html`, `markdown`, `txt`) with per-request `options` (paper size, orientation, margins, filename, image embedding, PDF watermark + opacity), structured `X-Export-Warnings` header for non-fatal issues, three-tier flow for large documents (direct POST up to 20 MB GåÆ pre-signed upload for 20-100 MB via `upload_id` GåÆ email fallback for >100 MB via `POST /v1/documents/export/email-request`), structured 413 detail body with `error_code` and `suggested_action`, and full round-trip of Word track-changes on `.docx` export. | Integrators get five output formats from one endpoint, predictable size handling without a 413 surprise, and clear error contracts. The default-format flip from `doc` to `docx` is a soft compatibility change GÇö callers that omit `format` and switch on `Content-Type` now receive Open XML. |
+| 2026-05-26 | Batched HITL approvals GÇö a turn's proposed changes arrive in a single `proposed_change_batch` SSE event (with a `changes[]` array) rather than fanning out N individual events. This is the canonical approval event and is emitted even when a turn has a single change. | Editors approving large changesets render one approval card instead of N. Wire load scales with turns, not changes. **Listen for `proposed_change_batch`** to receive every approval, single or batched. |
+| 2026-05-25 | 30-minute wall-clock cap on chat turns (up from 10 minutes), parallel-edit speedup, latency improvements for very large documents. | Hundred-section edits in a single turn now finish inside the cap instead of timing out. Same 504 contract; the failure copy points users to splitting the work across smaller turns. |
+| 2026-05-17 | Context-aware AI inserts GÇö when you ask the AI to insert an image, diagram, or new section without naming a position ("insert an image of a circle", "add a flowchart showing X", "add a new section here"), it now lands right after the section the user's cursor was in instead of always appending at end-of-document. Send the new optional `cursor_context` field on `/v1/chat` and `/v1/chat/async` to enable this; omit it and prior end-of-doc behaviour is preserved. The web app at use.superdocs.app sends it automatically. **Vague-verb clarification** GÇö broad ambiguous requests like "improve this document" or "make this better" now trigger a clarifying question with concrete options instead of an unexpected wide rewrite. | Integrators get a single-param opt-in to a noticeable UX improvement; existing integrations behave exactly as before if they omit the field. Web-app users see new content land near where they were looking. Vague broad-edit prompts no longer produce surprise rewrites GÇö the AI checks intent first. |
+| 2026-05-16 | Multilingual + multi-cohort reliability GÇö the AI reasons about user intent across all languages (not enumerated phrase lists); RTL emails render correctly in Hebrew / Arabic / Persian / Urdu / etc.; per-cohort response tone (fresh vs returning vs committed users); style-attribute search ("change all the blue writing") matches inline HTML | Non-English and RTL-language users get parity quality; first-touch users see encouragement-shaped responses, returning users see direct-and-literal responses; visual-style queries resolve correctly across any HTML structure; safety-filtered AI responses degrade gracefully instead of crashing |
+| 2026-05-06 | High-fidelity ingest GÇö PDF annotations + embedded images + Word track-changes preserved on import; reliable multi-file attachment-by-filename; document length awareness ("how many pages?") | PDFs, annotated review documents, and Word redlines no longer get silently flattened on upload; multi-file workflows resolve files by name; users get real page-count answers |
+| 2026-05-06 | Web app first-paint upload affordances GÇö empty editor renders a drop zone for drag-drop, click-to-pick file picker, and a paste-as-content prompt when long content is pasted into a blank editor; "Starter Templates" relabel on the templates surface so it's clearly distinct from per-session attachments | First-time visitors find the upload path in seconds without asking; users who paste content as their first action get explicit "load as document content vs. treat as a chat instruction" choice |
+| 2026-04-30 | Visual content & media GÇö image generate / edit / replace by chat, auto-rendered Mermaid diagrams, in-editor drawing canvas, KaTeX equations, auto table of contents | Documents are no longer text-only; images, diagrams, drawings, and equations live in the same chat-driven editing flow as the rest of the content |
+| 2026-04-29 | Per-message chat revert (web app + REST + MCP `revert_session_to_message`) | "Oops, undo that" rewinds chat and document together; original branch preserved server-side |
+| 2026-04-25 | Expanded features documentation (this page) GÇö 30+ capabilities grouped by use case | Developers and AI agents form a complete mental model of what SuperDocs can do |
+| 2026-04-25 | MCP server unified GÇö 21 tools + 4 user-invocable workflow prompts on a single `/mcp` endpoint | Single MCP config entry covers both; discoverable slash commands for Cursor/Claude Code/Claude Desktop users |
+| 2026-04-25 | Pre-signed URL upload/download flow (`request_upload_url`, `process_uploaded_document`, `request_download_url`) | Bytes no longer pass through agent context window; viable for real-world file sizes |
+| 2026-04-25 | Compact response mode (`response_mode='compact'` + `chunk_diffs`) | \~140+ù token reduction for editing sessions on large documents |
+| 2026-04-25 | Capability-forward MCP tool descriptions across all 21 tools | Agents form correct mental models of when to use SuperDocs vs build from scratch |
+| 2026-04-23 | MCP HTTP transport reliability fix | Eliminates 5-minute hang clients (Claude Code, Cursor, Bun) saw on first connect |
+| 2026-04-22 | OAuth Protected Resource Metadata (RFC 9728) for MCP | Cursor 3.x / Claude Code 2.x / mcp-remote can now connect without 60s metadata-probe timeout |
+| 2026-04-19 | Editing latency improvements for large documents | 4m55s GåÆ \~10s for typical edit operations on large documents |
+| 2026-04-18 | Editing precision improvements for nuanced instructions | Eliminates over-broad edits when the user's instruction was narrowly-scoped |
+| Earlier 2026 | Async jobs + HITL durable state, SSE streaming, multimodal vision, multi-format export, MCP server, promo codes, billing | Foundation |
+
+***
+
+For schemas and parameters, open the **API Reference** tab in the top navigation (auto-generated from the OpenAPI spec). For code examples see [cURL](/examples/curl), [Python](/examples/python), and [JavaScript](/examples/javascript); for the MCP tools see the [MCP Tools Reference](/mcp/available-tools).
+
+
+# Agent Editing Playbook
+Source: https://docs.superdocs.app/guides/agent-editing-playbook
+
+How an AI agent edits documents reliably on SuperDocs: choosing the right operation, section rewrites, cheap verification, placement, page breaks, and budgeting operations.
+
+# Agent Editing Playbook
+
+This is the field guide for AI agents (and the developers wiring them) doing document work over the REST API or MCP. Everything here applies to both surfaces GÇö MCP tool names in parentheses.
+
+
+ **The one-sentence version:** say what you want changed the way you'd tell a colleague GÇö "add a Benefits section after Compensation", "rewrite Section 6", "move the cover image to the top" GÇö and verify with the free `structure` read. You don't need to micro-manage.
+
+
+## Choosing the operation (say what you mean)
+
+The AI picks the operation from your message. Three intents cover editing:
+
+| Your intent | Say it like | What happens |
+| ------------------------------ | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
+| **Add** new content | "Add an Appendix A withGǪ", "Insert a section on X after Y" | A new section is inserted at the position you named. The rest of the document is untouched. |
+| **Change** existing content | "Rewrite Section 6 toGǪ", "Fix the dates in the payment clause", "Translate the introduction" | The targeted content is edited in place. |
+| **Rebuild** the whole document | "Replace the whole document withGǪ", "Regenerate this from scratch asGǪ" | The entire document is replaced. Only happens when you explicitly ask for a full replacement. |
+
+Two behaviors worth knowing:
+
+* **Section rewrites are atomic.** "Rewrite Section 6" replaces the whole section GÇö heading to next heading GÇö as one unit. New content in, all old section content out. You will not get a half-merged section with stale paragraphs left behind.
+* **Find-and-modify is one request.** "Find the line that says X and replace it with Y" performs the edit. You don't need a separate search call first.
+
+## Verify for free (never export just to check)
+
+`GET /v1/documents/{id}` (`get_document_detail`) always includes a `structure` block GÇö headings with levels and positions, `section_count`, `block_count`, and media counts GÇö derived on read, **never billed**. When the document carries page headers, footers, footnotes, endnotes, or comments, `structure` also includes a `parts` list naming each one with its type, a short preview, and the `chunk_id` you can reference when asking for a change. Counts and heading positions describe the document body; parts are listed separately and never inflate `section_count`.
+
+```json theme={null}
+"structure": {
+ "headings": [{"level": 1, "text": "Employee Handbook", "position": 0},
+ {"level": 2, "text": "Section 1 GÇö Hours", "position": 2}],
+ "section_count": 18,
+ "block_count": 74,
+ "media": {"images": 3, "diagrams": 1}
+}
+```
+
+Check it after any edit you care about. Exports are for deliverables, not verification.
+
+## Keep your token footprint small
+
+For large documents, don't pull the full document body into your agent's context to work on it. The document lives server-side; targeted natural-language requests ("rewrite Section 6") edit it there, and the `structure` block above verifies the result without fetching a single body byte. Set `response_mode='compact'` on `chat`/`chat_async` so responses carry per-section diffs instead of the full HTML, and move file bytes with the pre-signed upload/download URL flows so they never enter your context either. A 100-page document can go through a full multi-turn editing session while your agent's context holds only a few thousand tokens.
+
+## Placement (images, diagrams, new sections)
+
+Name the position and it is honored: "at the top", "in Section 2", "before the conclusion", "replace the \[COVER] placeholder". If a named position can't be resolved, the operation fails honestly and the AI retries with the real location GÇö content is never silently dumped at the end of the document when you named a spot.
+
+To reposition existing content, ask for a move: "move the org chart into Section 2", "move the banner to the top". Moves relocate the original GÇö nothing is duplicated or regenerated.
+
+## Headers, footers, footnotes, and comments
+
+Out-of-flow parts are edited the same way as body content: "change the footer to include the confidentiality notice", "fix the typo in footnote 3", "add a comment on the payment clause". Part edits ride the same request, the same review flow, and the same billing as body edits; they never change the body `section_count`. On `.docx` export an edited footnote is still a real Word footnote, a comment a real Word comment. The free `structure` read lists every part with its id under `parts` (see above), so you can verify a part edit without exporting.
+
+## Page breaks and tables of contents
+
+* **Page breaks:** "start each section on a new page" / "page break before the appendix" inserts real page-break markers. DOCX exports carry true Word page breaks; PDF exports break pages there.
+* **Table of contents:** "add a table of contents" inserts a live TOC that updates itself from your headings GÇö in the editor and in every export. One request; the document structure is otherwise untouched.
+
+## Long generations: use async
+
+Synchronous `POST /v1/chat` is right for edits and normal-sized creations. For **document-scale generations** GÇö a whole handbook, a long report GÇö use `POST /v1/chat/async` (`chat_async`): it returns a `job_id` immediately and reports progress via `GET /v1/jobs/{job_id}` (`get_job`). Sync requests that run very long can hit the platform gateway timeout (\~300s) and return nothing. Long sync responses also carry an advisory `hint` telling you when to switch.
+
+## Operations and billing (what a request costs)
+
+* Most requests bill **1 operation**. Very large creations bill **1 per 25 sections** created (a 96-section handbook Gëê 4 operations).
+* **Failed operations bill 0.** If the AI couldn't complete your edit, the reply says so and you were not charged.
+* **Already-satisfied requests bill 0.** If the document already meets your instruction, the reply says so honestly; nothing is changed and nothing is charged. Don't re-issue the request expecting a rewrite.
+* Reads are free or flat: `structure`, listing documents/sessions, job polling GÇö none of it eats your document-edit budget. Searches within your documents bill 1.
+* Check remaining quota anytime: `get_account_status` / `GET /v1/agents/whoami`.
+
+## Session and Files ids
+
+`GET /v1/sessions/{id}/documents` (`list_session_documents`) returns both a session-local `document_id` and a permanent `durable_document_id` GÇö the same id `list_documents` (Files), `get_document_detail`, `rename_document`, and `archive_document` use. **Everything that takes a session document id also accepts the durable UUID**, so you can drive a whole workflow with one id form.
+
+Two honesty guarantees while cleaning up:
+
+* Archiving a document that's open in another session fails with **409 `document_in_use`** (nothing archived) GÇö re-call with `force=true` or close it there first.
+* Reverting to a turn that a previous revert already removed fails with **409 `turn_already_reverted`** and includes the current `active_turns` map so you can re-target immediately.
+
+## Credentials and handoff
+
+* **Before signing up, check `~/.superdocs/agent_credentials.json`.** If it exists, call `GET /v1/agents/whoami` with that key and reuse the account GÇö its documents and quota carry over.
+* Nearing the cap: `POST /v1/agents/handoff` with your human's email. Pass `working_context` (what you're working on, where you run) so the email is recognizable, and relay the returned **takeover code** to your operator in chat GÇö never by email. They open the link, enter the code, and adopt the account in place; you keep the same key and all your work.
+
+
+# Agent Tool Integration
+Source: https://docs.superdocs.app/guides/agent-tool-integration
+
+Register SuperDocs as a tool in your existing AI agent's tool registry GÇö OpenAI function-calling, Anthropic tool_use, LangChain, LlamaIndex, or any function-calling-compatible framework. Your AI is the consumer; SuperDocs is the document-editing capability it can call.
+
+# Agent Tool Integration
+
+**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 to register SuperDocs as an agent tool.
+
+If your product has an existing AI agent (built with OpenAI function-calling, Anthropic `tool_use`, LangChain, LlamaIndex, or a custom framework) that needs to edit documents, the right integration shape is to register SuperDocs as a tool the agent can call GÇö not to build a separate UI around SuperDocs.
+
+
+ **Your agent can pick `model_tier` per call based on user intent.** A simple "fix typo" call can use `core`; a contract-clause edit can switch to `max` mid-conversation. Add `model_tier` and `thinking_depth` to your tool's parameter schema and let the agent reason about which tier fits the current task. See [Model Selection](/guides/model-selection) for the full matrix.
+
+
+This guide covers the pattern + working snippets for the five most common agent frameworks, plus a generic JSON-schema definition you can adapt to any framework that supports tool-calling.
+
+## Why this shape is different from a UI integration
+
+In a typical SuperDocs UI integration, a human types in a chat panel, the chat sends a message to SuperDocs, and SuperDocs returns proposed edits the human reviews. The customer-facing app is the consumer of SuperDocs.
+
+In an agent-tool integration, your AI agent is the consumer. The agent decides GÇö based on its own reasoning, the user's query, and the conversation context GÇö that it needs to edit a document. It invokes SuperDocs as a tool, receives proposed changes as structured data, and either approves them itself (auto-decide), surfaces them through your existing UI for the user to review, or sends them out-of-band (Slack, email).
+
+The integration is server-side, the SuperDocs `sk_` key lives next to your other model API keys, and there is no SuperDocs-specific UI to build GÇö your agent's existing UI is where the result appears.
+
+## The pattern, in 4 steps
+
+1. **Define the SuperDocs tool** in your agent's tool registry. The two required parameters are `message` (the natural-language instruction for the AI) and `session_id` (a string that ties multiple turns together). `document_html` (the HTML the agent wants edited) is optional GÇö send it on the first turn, then omit it: the server persists document state by `session_id` across turns, so resending the full HTML every turn just wastes tokens.
+2. **Implement the tool function.** Call `POST /v1/chat` with the three parameters plus your chosen `approval_mode`. Return the response back to the agent GÇö either as the parsed proposed changes (for `ask_every_time`) or as the final updated HTML (for `approve_all`).
+3. **Decide approval shape.** Choose between `approve_all` (your agent's reasoning is the approval GÇö fastest), `ask_every_time` (the agent reviews each proposed change individually before deciding), or surface to a human through your UI.
+4. **Persist the result.** The agent receives the updated HTML and writes it back to wherever your documents live.
+
+That's the whole loop. The snippets below are the same loop in five different framework idioms.
+
+## Generic JSON-schema tool definition
+
+Every framework that supports tool-calling accepts a JSON-schema tool definition. Use this as the canonical version and adapt for your framework:
+
+```json theme={null}
+{
+ "name": "edit_document",
+ "description": "Edit a document with AI by sending the document HTML and a natural-language instruction. Returns the updated HTML. Use this when the user asks to modify, rewrite, expand, summarize, translate, or improve any document.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": "The natural-language instruction for what to do to the document. Examples: 'rewrite the introduction in plain English', 'add a section about pricing', 'tighten the conclusion to one paragraph', 'translate to French'."
+ },
+ "session_id": {
+ "type": "string",
+ "description": "A string that ties multiple turns together. Use the same session_id across multiple tool calls if you want the AI to remember earlier edits in the same conversation."
+ },
+ "document_html": {
+ "type": "string",
+ "description": "The current HTML of the document to be edited, as a complete HTML string. OMIT this once the session already holds the document GÇö the server persists document state across turns by `session_id`, so resending the full HTML every turn just burns tokens. Send it on the first turn (or when you're switching the session to a new document). The response includes `data-chunk-id` attributes GÇö preserve them on the next call so SuperDocs can target previously-edited sections precisely."
+ },
+ "approval_mode": {
+ "type": "string",
+ "enum": ["approve_all", "ask_every_time"],
+ "description": "Whether to apply all proposed changes automatically (approve_all GÇö best for autonomous agents) or pause for review on each change (ask_every_time GÇö best when a human is in the loop)."
+ }
+ },
+ "required": ["message", "session_id"]
+ }
+}
+```
+
+## OpenAI function-calling (Python)
+
+```python theme={null}
+import os, httpx, json
+from openai import OpenAI
+
+client = OpenAI()
+SUPERDOCS_KEY = os.environ["SUPERDOCS_API_KEY"]
+
+# 1. Tool definition GÇö registered when you call the model
+edit_document_tool = {
+ "type": "function",
+ "function": {
+ "name": "edit_document",
+ "description": "Edit a document with AI. Returns the updated HTML.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "message": {"type": "string", "description": "The instruction for what to do to the document."},
+ "session_id": {"type": "string", "description": "Ties multi-turn edits together."},
+ "document_html": {"type": "string", "description": "Current HTML to edit. Omit once the session already holds the document GÇö state persists by session_id."},
+ "approval_mode": {"type": "string", "enum": ["approve_all", "ask_every_time"]},
+ },
+ "required": ["message", "session_id"],
+ },
+ },
+}
+
+# 2. Tool function GÇö called when the model invokes the tool
+def edit_document(message: str, session_id: str, document_html: str, approval_mode: str = "approve_all") -> dict:
+ with httpx.Client(headers={"Authorization": f"Bearer {SUPERDOCS_KEY}"}, timeout=300) as c:
+ r = c.post("https://api.superdocs.app/v1/chat", json={
+ "message": message,
+ "session_id": session_id,
+ "document_html": document_html,
+ "approval_mode": approval_mode,
+ })
+ r.raise_for_status()
+ result = r.json()
+ return {
+ "updated_html": result["document_changes"]["updated_html"],
+ "ai_response": result.get("response", ""),
+ "usage": result.get("usage", {}),
+ }
+
+# 3. Standard agent loop GÇö the model decides when to call edit_document
+def run_agent(user_message: str, document_html: str, session_id: str):
+ messages = [
+ {"role": "system", "content": "You are an SOP-editor assistant. When the user asks to modify a document, use the edit_document tool."},
+ {"role": "user", "content": f"{user_message}\n\nCurrent document HTML available GÇö call edit_document with session_id={session_id}."},
+ ]
+ response = client.chat.completions.create(
+ model="gpt-5", # Your model choice GÇö SuperDocs is provider-agnostic
+ messages=messages,
+ tools=[edit_document_tool],
+ )
+ msg = response.choices[0].message
+ if msg.tool_calls:
+ for call in msg.tool_calls:
+ args = json.loads(call.function.arguments)
+ args["document_html"] = args.get("document_html") or document_html
+ result = edit_document(**args)
+ # Append result back to the conversation if you want a multi-turn flow
+ messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
+ return result
+ return {"ai_response": msg.content}
+```
+
+## Anthropic `tool_use` (Python)
+
+```python theme={null}
+import os, httpx, anthropic
+
+client = anthropic.Anthropic()
+SUPERDOCS_KEY = os.environ["SUPERDOCS_API_KEY"]
+
+edit_document_tool = {
+ "name": "edit_document",
+ "description": "Edit a document with AI. Returns the updated HTML.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "message": {"type": "string"},
+ "session_id": {"type": "string"},
+ "document_html": {"type": "string", "description": "Omit once the session already holds the document GÇö state persists by session_id."},
+ "approval_mode": {"type": "string", "enum": ["approve_all", "ask_every_time"]},
+ },
+ "required": ["message", "session_id"],
+ },
+}
+
+def edit_document(message, session_id, document_html, approval_mode="approve_all"):
+ with httpx.Client(headers={"Authorization": f"Bearer {SUPERDOCS_KEY}"}, timeout=300) as c:
+ r = c.post("https://api.superdocs.app/v1/chat", json={
+ "message": message, "session_id": session_id,
+ "document_html": document_html, "approval_mode": approval_mode,
+ })
+ r.raise_for_status()
+ return r.json()
+
+def run_agent(user_message, document_html, session_id):
+ messages = [{"role": "user", "content": f"{user_message} (session_id={session_id})\n\nDocument HTML: {document_html[:500]}..."}]
+ response = client.messages.create(
+ model="claude-opus-4-5", # Your model choice
+ max_tokens=4096,
+ system="You are an SOP-editor assistant. Call edit_document when the user asks to modify a document.",
+ tools=[edit_document_tool],
+ messages=messages,
+ )
+ for block in response.content:
+ if block.type == "tool_use" and block.name == "edit_document":
+ args = block.input
+ args["document_html"] = args.get("document_html") or document_html
+ result = edit_document(**args)
+ return result["document_changes"]["updated_html"]
+ return None
+```
+
+## LangChain `Tool` (Python)
+
+```python theme={null}
+import os, httpx
+from langchain.tools import tool
+from langchain.agents import create_react_agent
+from langchain_openai import ChatOpenAI
+
+SUPERDOCS_KEY = os.environ["SUPERDOCS_API_KEY"]
+
+@tool
+def edit_document(message: str, session_id: str, document_html: str, approval_mode: str = "approve_all") -> str:
+ """Edit a document with AI. Returns the updated HTML.
+
+ Args:
+ message: The natural-language instruction for what to do to the document.
+ session_id: A string that ties multiple turns together.
+ document_html: The current HTML of the document to be edited.
+ approval_mode: 'approve_all' to auto-apply, 'ask_every_time' to pause per change.
+ """
+ with httpx.Client(headers={"Authorization": f"Bearer {SUPERDOCS_KEY}"}, timeout=300) as c:
+ r = c.post("https://api.superdocs.app/v1/chat", json={
+ "message": message, "session_id": session_id,
+ "document_html": document_html, "approval_mode": approval_mode,
+ })
+ r.raise_for_status()
+ return r.json()["document_changes"]["updated_html"]
+
+# Wire into your agent
+llm = ChatOpenAI(model="gpt-5")
+tools = [edit_document] # Plus whatever other tools your agent has
+agent = create_react_agent(llm, tools, prompt="...")
+```
+
+## LlamaIndex `FunctionTool` (Python)
+
+```python theme={null}
+import os, httpx
+from llama_index.core.tools import FunctionTool
+from llama_index.core.agent import ReActAgent
+from llama_index.llms.openai import OpenAI
+
+SUPERDOCS_KEY = os.environ["SUPERDOCS_API_KEY"]
+
+def edit_document(message: str, session_id: str, document_html: str, approval_mode: str = "approve_all") -> str:
+ """Edit a document with AI. Returns the updated HTML."""
+ with httpx.Client(headers={"Authorization": f"Bearer {SUPERDOCS_KEY}"}, timeout=300) as c:
+ r = c.post("https://api.superdocs.app/v1/chat", json={
+ "message": message, "session_id": session_id,
+ "document_html": document_html, "approval_mode": approval_mode,
+ })
+ r.raise_for_status()
+ return r.json()["document_changes"]["updated_html"]
+
+edit_tool = FunctionTool.from_defaults(fn=edit_document, name="edit_document",
+ description="Edit a document with AI. Returns updated HTML.")
+
+agent = ReActAgent.from_tools([edit_tool], llm=OpenAI(model="gpt-5"), verbose=True)
+```
+
+## TypeScript / Vercel AI SDK
+
+```typescript theme={null}
+import { tool } from "ai";
+import { z } from "zod";
+
+const SUPERDOCS_KEY = process.env.SUPERDOCS_API_KEY!;
+
+export const editDocument = tool({
+ description: "Edit a document with AI. Returns the updated HTML.",
+ parameters: z.object({
+ message: z.string().describe("The instruction for what to do to the document."),
+ session_id: z.string().describe("Ties multi-turn edits together."),
+ document_html: z.string().describe("Current HTML to edit."),
+ approval_mode: z.enum(["approve_all", "ask_every_time"]).default("approve_all"),
+ }),
+ execute: async ({ message, session_id, document_html, approval_mode }) => {
+ const res = await fetch("https://api.superdocs.app/v1/chat", {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${SUPERDOCS_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ message, session_id, document_html, approval_mode }),
+ });
+ if (!res.ok) throw new Error(`SuperDocs ${res.status}`);
+ const data = await res.json();
+ return {
+ updated_html: data.document_changes.updated_html,
+ ai_response: data.response,
+ };
+ },
+});
+
+// Wire into your agent's tool registry alongside your other tools
+```
+
+## Approval modes GÇö choose based on who decides
+
+### `approve_all` GÇö your agent's reasoning is the approval
+
+The simplest pattern. Pass `approval_mode: "approve_all"` and SuperDocs returns the final updated HTML in one call. Use this when your agent is autonomous and you trust it to make the right call. Most appropriate for:
+
+* Backend AI agents processing a queue without human oversight.
+* Agents whose user already approved the high-level intent (e.g., "rewrite all sections in plain English") and doesn't need per-change review.
+* Server-to-server workflows.
+
+### `ask_every_time` GÇö your agent reviews each proposed change
+
+Pass `approval_mode: "ask_every_time"` and SuperDocs returns the turn's proposed changes via SSE. The whole turn arrives as one `proposed_change_batch` event whose `changes[]` array holds every proposed change (a single-change turn arrives as a one-element array). Your agent reads that event (parse `event.data` then `JSON.parse` the `content` field again GÇö see the [SSE Streaming guide](/guides/streaming) for the double-parse warning), loops over `changes[]` deciding whether to approve each based on its own reasoning, and POSTs to `/v1/chat/{session_id}/approve` to accept or reject. (You may also register a `proposed_change` listener defensively for forward-compatibility, but it never fires today GÇö build your review loop on `proposed_change_batch`.)
+
+Use this when:
+
+* Your agent has higher reasoning capability than SuperDocs' edit suggestions and wants to filter them.
+* The user is reviewing the agent's overall plan but trusts the agent to filter individual edits.
+* You want an audit log of every accepted vs rejected change for compliance.
+
+The decision flow inside the agent looks like:
+
+```python theme={null}
+# Pseudocode GÇö adapt to your framework
+# Each batch is the whole turn: parse the proposed_change_batch event, then
+# loop over its changes[] array (one element for a single-change turn).
+for batch in stream_proposed_change_batches(job_id, session_id):
+ decisions = []
+ for change in batch["changes"]:
+ # The agent reasons about whether to approve each change
+ decision = await agent.reason(
+ f"SuperDocs proposed: {change['ai_explanation']}\n"
+ f"Old: {change['old_html']}\n"
+ f"New: {change['new_html']}\n"
+ "Approve or deny?"
+ )
+ decisions.append({"change_id": change["change_id"], "approved": decision == "approve"})
+ httpx.post(
+ f"https://api.superdocs.app/v1/chat/{session_id}/approve",
+ headers={"Authorization": f"Bearer {SUPERDOCS_KEY}"},
+ json={"job_id": job_id, "approved": True, "changes": decisions},
+ )
+```
+
+### Surface to a human through your UI
+
+If your agent is in a chat with a human and SuperDocs proposes an edit, your agent can stop, surface the proposed change to the human via your existing chat thread (as a card, a button group, an inline question GÇö whatever your UI does), wait for the human's decision, and then POST the decision back to SuperDocs.
+
+This pattern keeps your AI agent in control of what gets surfaced and when, but defers the binary approve/deny decision to the human. The agent's UI is the only UI involved GÇö there's no SuperDocs-specific UI.
+
+## Document HTML round-trip GÇö same rule as everywhere else
+
+The `data-chunk-id` attributes that SuperDocs adds to block-level elements **must round-trip cleanly** when you send the HTML back on the next call. If your agent stores the HTML in a database, file, or in-memory variable between calls, those attributes must survive the storage cycle. Most string-based storage preserves them automatically; if you parse the HTML through a library that strips unknown attributes, you'll break the round-trip silently.
+
+If you're piping the HTML through a rich-text editor at any point in your agent's UI, see the [Editor Integration guide](/guides/editor-integration) for editor-specific preservation patterns.
+
+## Multi-document sessions for agents
+
+A session can hold several open documents. Your agent targets one explicitly by passing `document_id` on `chat`/`chat_async` (ids come from the `list_session_documents` tool / `GET /v1/sessions/{id}/documents`), names it in the message ("update the invoice"), or omits both to hit the focused document. Agents that juggle related files (a contract + its amendment, a report + its data appendix) should keep them in ONE session and let the AI read across them, instead of copying content between sessions. See the [Multi-Document guide](/guides/multi-document).
+
+## Letting your agent undo a step
+
+If your agent decides GÇö mid-workflow GÇö that a previous edit was wrong, it can rewind the session to before the user message that triggered the bad edit instead of trying to patch the result forward. Call `POST /v1/sessions/{session_id}/revert` with the `turn_index` of the user message you want to undo. SuperDocs returns the document state from before that turn plus the original message text, the agent can rewrite the instruction more clearly, and the session continues from the rewind point. The original conversation is preserved for audit but hidden from active reads.
+
+Concrete shape GÇö register revert as a second tool alongside `edit_document`:
+
+```python theme={null}
+def revert_session(session_id: str, turn_index: int) -> dict:
+ with httpx.Client(headers={"Authorization": f"Bearer {SUPERDOCS_KEY}"}, timeout=30) as c:
+ r = c.post(f"https://api.superdocs.app/v1/sessions/{session_id}/revert",
+ json={"turn_index": turn_index})
+ r.raise_for_status()
+ return r.json() # {compose_text, reverted_to_turn, document_state, editor_action, archived_turn_count}
+```
+
+The agent reasons "this edit went wrong, let me try a different prompt" and chains `revert_session` GåÆ `edit_document` with the corrected instruction. Returns `409` if a chat job is still in flight on the session GÇö wait for it to settle first.
+
+See the full revert contract in [Sessions GåÆ Revert a session to a previous message](/concepts/sessions#revert-a-session-to-a-previous-message).
+
+## Choosing between sync `/v1/chat` and async `/v1/chat/async`
+
+Most agent-tool integrations should use sync `/v1/chat`. It's a simpler integration (one call, one response) and matches the standard tool-call shape (function in, JSON out, agent reasons over the result).
+
+Use async `/v1/chat/async` + the SSE stream when:
+
+* Your agent has a UI that wants to show streaming progress events to the user as the AI works.
+* The document is very large and you want to fail fast on auth errors before committing to a long edit.
+* You want to use `ask_every_time` and react to each proposed change in real time.
+
+For everything else, sync is enough.
+
+## Stuck?
+
+If your agent framework isn't covered here or the tool-calling pattern doesn't map cleanly to your setup, email [hello@superdocs.app](mailto:hello@superdocs.app) or book a 15-minute integration call at [cal.com/superdocs](https://cal.com/superdocs). We'll talk through the pattern and add a snippet to this guide for the next person.
+
+## Related guides
+
+* [Server Integration](/guides/server-integration) GÇö if your agent is one of several services in a backend, the same patterns apply more broadly.
+* [Human-in-the-Loop](/guides/human-in-the-loop) GÇö if your agent surfaces proposed changes to a human via your UI, the rendering patterns there work for chat-thread cards too.
+* [SSE Streaming](/guides/streaming) GÇö if you choose `ask_every_time` and need to consume the turn's `proposed_change_batch` event.
+* [Async Jobs](/guides/async-jobs) GÇö if you want polling instead of SSE.
+* [Integration Starter Prompt](/guides/integration-starter-prompt) GÇö paste this into your coding agent to wire all the above up automatically.
+
+
+# AI-to-AI Integration
+Source: https://docs.superdocs.app/guides/ai-to-ai
+
+Connect your AI agent to SuperDocs for autonomous document editing without a browser.
+
+# AI-to-AI Integration
+
+Your AI agent can use SuperDocs as a document editing tool. No browser, no frontend GÇö just API calls. Your AI decides what to edit, SuperDocs executes.
+
+## How it works
+
+
+
+
+
+
+
+
+
+
+
+This creates a loop: your AI agent controls *what* to edit, SuperDocs handles *how* to edit it.
+
+## Example: Autonomous document workflow
+
+```python theme={null}
+import requests
+
+API_KEY = "sk_YOUR_API_KEY"
+HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
+BASE = "https://api.superdocs.app/v1/chat"
+
+# Your AI agent's workflow
+tasks = [
+ "Create a project proposal with 4 sections: Overview, Goals, Timeline, Budget",
+ "Add specific milestones to the Timeline section",
+ "Add cost estimates to the Budget section",
+ "Review the entire document and fix any inconsistencies"
+]
+
+session_id = "ai-agent-proposal"
+document_html = ""
+
+for task in tasks:
+ response = requests.post(BASE, headers=HEADERS, json={
+ "message": task,
+ "session_id": session_id,
+ "document_html": document_html
+ })
+
+ data = response.json()
+ print(f"Task: {task}")
+ print(f"AI: {data['response'][:100]}...")
+
+ # Use the updated document for the next task
+ if data.get("document_changes"):
+ document_html = data["document_changes"]["updated_html"]
+
+print("\nFinal document ready.")
+```
+
+## Session persistence
+
+Use the same `session_id` across calls. SuperDocs remembers the full conversation, so your agent can reference previous changes:
+
+```python theme={null}
+# First call
+requests.post(BASE, headers=HEADERS, json={
+ "message": "Draft a sales contract",
+ "session_id": "agent-contract",
+ "document_html": ""
+})
+
+# Later call GÇö SuperDocs remembers the contract it drafted
+requests.post(BASE, headers=HEADERS, json={
+ "message": "Add a payment terms section based on net-30",
+ "session_id": "agent-contract",
+ "document_html": current_html
+})
+```
+
+## Undoing a step
+
+If your agent realizes one of its earlier edits was wrong, it can rewind the session instead of trying to patch the result forward. Call `POST /v1/sessions/{session_id}/revert` with the `turn_index` of the user message you want to undo:
+
+```python theme={null}
+revert = requests.post(
+ f"https://api.superdocs.app/v1/sessions/agent-contract/revert",
+ headers=HEADERS,
+ json={"turn_index": 4}, # the user message you want to undo
+)
+result = revert.json()
+# result["compose_text"] GåÆ the original message text
+# result["document_state"] GåÆ the document HTML at the rewind point
+# result["reverted_to_turn"] GåÆ the turn the conversation now ends at
+```
+
+The agent receives the document state from before that turn plus the original message text, rewrites the instruction more carefully, and re-sends. The original conversation is preserved for audit but hidden from active reads. Returns `409` if a chat job is currently running on the same session GÇö wait for it to settle first. See [Revert a session to a previous message](/concepts/sessions#revert-a-session-to-a-previous-message).
+
+## With MCP
+
+If your AI agent supports MCP (like Claude), connect it directly to SuperDocs. See [MCP Setup](/mcp/setup) for setup. The AI agent gets native access to all 38 SuperDocs tools GÇö including `revert_session_to_message` GÇö without writing any API client code.
+
+
+# Async Jobs
+Source: https://docs.superdocs.app/guides/async-jobs
+
+Use async chat requests with job polling for long-running operations and background processing.
+
+# Async Jobs
+
+The sync endpoint (`POST /v1/chat`) waits for the AI to finish before responding. For long operations or background processing, use the async endpoint instead.
+
+
+ **For batch processing where speed dominates**, consider `model_tier: "turbo"` in your request body GÇö the fastest tier, optimised for high-volume workflows. For high-stakes documents in your batch, use `pro` or `max` instead. See [Model Selection](/guides/model-selection) for the full matrix.
+
+
+## When to use async
+
+| Use sync (`/v1/chat`) | Use async (`/v1/chat/async`) |
+| ------------------------- | ----------------------------- |
+| Quick edits and questions | Complex multi-step operations |
+| Interactive chat UI | Background processing |
+| Simple integrations | SSE streaming for progress |
+| | HITL approval workflows |
+
+## Async flow
+
+### 1. Start the job
+
+```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": "Restructure this entire document into 5 sections",
+ "session_id": "my-session",
+ "document_html": "..."
+ }'
+```
+
+Response:
+
+```json theme={null}
+{
+ "job_id": "550e8400-e29b-41d4-a716-446655440000",
+ "session_id": "my-session",
+ "status": "pending",
+ "message": "Chat request queued for processing"
+}
+```
+
+The `job_id` is an opaque UUID GÇö treat it as a string, don't parse it. Chat jobs and attachment-processing jobs share the same id space and the same `GET /v1/jobs/{job_id}` path.
+
+### 2. Poll for status
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/jobs/550e8400-e29b-41d4-a716-446655440000 \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### 3. Get the result
+
+When `status` is `"completed"`, the `result` field contains the AI response and document changes:
+
+```json theme={null}
+{
+ "job_id": "550e8400-e29b-41d4-a716-446655440000",
+ "status": "completed",
+ "result": {
+ "response": "I've restructured the document into 5 sections...",
+ "session_id": "my-session",
+ "document_changes": {
+ "updated_html": "
...
",
+ "version_id": "...",
+ "changes_summary": "Document updated by AI"
+ },
+ "usage": {
+ "monthly_used": 44,
+ "monthly_limit": 500,
+ "monthly_remaining": 456,
+ "was_billable": true,
+ "subscription_tier": "free"
+ }
+ }
+}
+```
+
+## Job statuses
+
+| Status | Meaning | What to do |
+| ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `pending` | Queued, waiting to start | Keep polling |
+| `in_progress` | AI is processing | Keep polling |
+| `awaiting_approval` | The job is paused for your input GÇö check `metadata.awaiting_kind` | **Change review** (HITL): read `metadata.pending_changes`, call `/approve`. **Continue prompt** (large edit): read `metadata.continue_prompt`, call `/continue` GÇö see [Continuing a large edit](#continuing-a-large-edit) |
+| `completed` | Finished | Read `result` |
+| `failed` | Error occurred | Read `error` field |
+| `cancelled` | Cancelled by you | No action needed |
+
+
+ **Revert vs. in-flight jobs.** [Revert](/concepts/sessions#revert-a-session-to-a-previous-message) returns `409` while a chat job on the same session is in `pending`, `in_progress`, or `awaiting_approval`. Wait for the job to land in `completed`/`failed`/`cancelled`, or cancel it explicitly via `POST /v1/jobs/{job_id}/cancel`, before retrying revert.
+
+
+## Continuing a large edit
+
+A very large edit can be too big to finish in a single turn. When that happens the AI applies as much as it can, keeps that work, and pauses with `status: "awaiting_approval"` and `metadata.awaiting_kind: "continue_prompt"` GÇö asking whether to continue with the rest. This is distinct from HITL change review (which instead sets `metadata.pending_changes`) and can occur in either approval mode.
+
+The pause carries a `metadata.continue_prompt` object:
+
+```json theme={null}
+{
+ "status": "awaiting_approval",
+ "metadata": {
+ "awaiting_kind": "continue_prompt",
+ "continue_prompt": {
+ "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
+ }
+ }
+}
+```
+
+Resume GÇö or stop GÇö by calling `/continue`:
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/chat/{session_id}/continue \
+ -H "Authorization: Bearer $SUPERDOCS_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"job_id": "550e8400-e29b-41d4-a716-446655440000", "continue": true}'
+```
+
+* `continue: true` GÇö resume; the job returns to `in_progress` and finishes the rest.
+* `continue: false` GÇö stop here; everything applied so far is kept.
+
+The job may pause again for the next segment, so handle `continue_prompt` in your polling loop just like repeated HITL rounds GÇö keep going until `status` is `completed`. If you never respond, the job is cleaned up after 1 hour, like any other `awaiting_approval` job.
+
+## Latency expectations and timeout strategy
+
+Knowing how long an operation *should* take helps you decide when to surface a "still processing" hint and when to treat a job as broken.
+
+### Typical latency by operation type
+
+| Operation | Expected duration | Surface "still processing" after | Treat as failed after |
+| --------------------------------------------- | ----------------- | -------------------------------- | ----------------------- |
+| Single-section edit | \< 10 seconds | 30 seconds | 2 minutes |
+| Multi-section edit (3GÇô5 sections) | 30GÇô90 seconds | 2 minutes | 5 minutes |
+| Full-document operation (10+ sections) | 60GÇô180 seconds | 3 minutes | 10 minutes |
+| Complex operation (merge, restructure, batch) | 120GÇô300 seconds | 4 minutes | 15 minutes |
+| Very large document (hundreds of sections) | 5GÇô25 minutes | 8 minutes | 30 minutes (server cap) |
+
+These are medians for typical documents (5GÇô20 sections). Larger documents, embedded images, or operations using `model_tier: "max"` with `thinking_depth: "deep"` may exceed these ranges. The `model_tier` and `thinking_depth` you choose materially affect latency GÇö see [Model Selection](/guides/model-selection). The server enforces a hard 30-minute wall-clock cap on every chat turn (sync and async); requests that exceed it return a graceful `504` with a suggestion to split the work across smaller turns.
+
+### Polling strategy that surfaces progress
+
+```python theme={null} theme={null}
+import time
+import requests
+
+MAX_WAIT = 300 # 5 minutes GÇö adjust per operation type
+WARN_AFTER = 60 # surface "still processing" after 60 seconds
+POLL_INTERVAL = 2 # seconds between status checks
+
+def wait_for_job(job_id: str, headers: dict) -> dict:
+ start = time.time()
+ last_warn = 0
+
+ while True:
+ r = requests.get(f"https://api.superdocs.app/v1/jobs/{job_id}", headers=headers)
+ r.raise_for_status()
+ job = r.json()
+ elapsed = time.time() - start
+
+ if job["status"] == "completed":
+ return job["result"]
+ if job["status"] == "failed":
+ raise RuntimeError(job.get("error", "Unknown error"))
+ if job["status"] == "cancelled":
+ raise RuntimeError("Job was cancelled")
+
+ if elapsed > MAX_WAIT:
+ raise TimeoutError(f"Job did not complete within {MAX_WAIT}s")
+
+ # Surface a "still processing" hint to the user every 30s after WARN_AFTER
+ if elapsed > WARN_AFTER and elapsed - last_warn >= 30:
+ print(f" Still processing... ({int(elapsed)}s elapsed)")
+ last_warn = elapsed
+
+ time.sleep(POLL_INTERVAL)
+```
+
+For real-time progress (instead of polling silence), open the SSE stream in parallel and surface every `intermediate` event to the user GÇö see [Streaming GåÆ Rendering intermediate events](/guides/streaming#rendering-intermediate-events-in-your-ui).
+
+### Job retention
+
+All jobs GÇö pending, in-progress, awaiting-approval, or terminal GÇö are automatically removed 1 hour after creation. For user-facing workflows that may run longer (or where the user steps away), keep `MAX_WAIT` well under 1 hour and persist the `job_id` so you can resume polling later.
+
+## Job types
+
+The `job_type` field on every job row tells you which lifecycle to expect.
+
+| `job_type` | Triggered by | Lifecycle |
+| ----------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `chat` | `POST /v1/chat/async` | Standard chat turn. Supports `awaiting_approval` for HITL. SSE stream available at `/v1/chat/{session_id}/stream?job_id=...`. |
+| `attachment_processing` | Background attachment ingestion | Internal; usually completes in seconds. |
+| `large_export` | `POST /v1/documents/export/email-request` | Background export of an oversize document. No SSE; poll `/v1/jobs/{job_id}`. On `completed`, `result.download_url` carries a 7-day signed link and an email has been sent to the recipient. 24-hour SLA. See [Email fallback for very large documents](/concepts/documents#email-fallback-for-very-large-documents). |
+
+`large_export` jobs are fire-and-forget GÇö the recipient receives an email when the render finishes, so polling is optional. Use it when you want to surface progress in your UI rather than letting the user check their inbox.
+
+## Cancel a job
+
+Only `pending` and `in_progress` jobs can be cancelled:
+
+```bash theme={null}
+curl -X POST https://api.superdocs.app/v1/jobs/550e8400-e29b-41d4-a716-446655440000/cancel \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## List all jobs
+
+```bash theme={null}
+curl https://api.superdocs.app/v1/jobs \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+## Polling example
+
+```python theme={null}
+import time
+import requests
+
+API_KEY = "sk_YOUR_API_KEY"
+HEADERS = {"Authorization": f"Bearer {API_KEY}"}
+
+# Start async job
+response = requests.post("https://api.superdocs.app/v1/chat/async",
+ headers={**HEADERS, "Content-Type": "application/json"},
+ json={"message": "Summarize this document", "session_id": "my-session", "document_html": "..."}
+)
+job_id = response.json()["job_id"]
+
+# Poll until complete
+while True:
+ job = requests.get(f"https://api.superdocs.app/v1/jobs/{job_id}", headers=HEADERS).json()
+
+ if job["status"] == "completed":
+ print("AI response:", job["result"]["response"])
+ break
+ elif job["status"] == "failed":
+ print("Error:", job["error"])
+ break
+ elif job["status"] == "awaiting_approval":
+ meta = job.get("metadata", {})
+ if meta.get("awaiting_kind") == "continue_prompt":
+ # Large edit paused mid-way GÇö resume it (send continue=false to stop instead).
+ print(meta["continue_prompt"]["message"])
+ requests.post("https://api.superdocs.app/v1/chat/my-session/continue",
+ headers={**HEADERS, "Content-Type": "application/json"},
+ json={"job_id": job_id, "continue": True})
+ # keep polling GÇö the job returns to in_progress and may pause again
+ else:
+ # HITL change review GÇö inspect the changes, then call /approve.
+ print("Changes need approval:", meta.get("pending_changes"))
+ break
+
+ time.sleep(2)
+```
+
+
+ Jobs are automatically cleaned up 1 hour after completion, failure, or cancellation. Jobs in `awaiting_approval` status are also cleaned up after 1 hour GÇö approve or deny changes within this window.
+
+
+
+# Concurrent & Cross-Session Editing
+Source: https://docs.superdocs.app/guides/concurrent-editing
+
+Keep a document in sync when more than one place edits it at once GÇö two browser sessions, a UI tab plus a background job, or any writer-plus-poller pair. Poll for other sessions' edits, autosave human typing, and merge instead of clobbering.
+
+# Concurrent & Cross-Session Editing
+
+A single document can be open in more than one place at the same time: two browser tabs, a user editing in your UI while a background job rewrites the same file, or any integration that both writes and reads a document on separate connections. Without coordination, the last writer silently wins and someone's work disappears.
+
+SuperDocs gives you the pieces to keep those copies converged GÇö a lightweight **change feed** to learn when another session edited a document you hold, and a **human autosave** endpoint so pure typing is persisted and announced. This guide is the integrator pattern for wiring them together.
+
+
+ You only need this guide if the **same** document can be edited from two places concurrently. If each session owns its own documents, skip it GÇö there's nothing to reconcile. For the per-section apply discipline this builds on, see [Applying AI updates safely](/guides/editor-integration#applying-ai-updates-safely-without-losing-user-edits).
+
+
+## The model
+
+* Every open document lives in a session and has a stable `document_id` and a committed version.
+* When any session commits a change to a document (an AI turn, or a human autosave), other sessions that hold the **same** document can learn about it by polling a change feed.
+* On a change, you re-fetch that one document and apply it **per section** onto your live editor GÇö never a whole-document reload, which would wipe whatever the user is typing right now.
+
+
+ **`window_id` (optional) GÇö disambiguate two windows of the *same* session.** `chat`, `chat_async`, `revert`, and `redo` accept an optional `window_id`. If you run two windows of the **same** `session_id` (rather than two separate sessions), set a stable per-window id so the server can tell concurrent writers apart and merge their edits instead of letting one overwrite the other. It's optional GÇö the per-section apply discipline in this guide works without it, and most integrators using one session per place don't need it GÇö but it's the precise lever for the same-session, multi-window case.
+
+
+This is soft, eventually-consistent collaboration GÇö converge-on-poll, not a real-time cursor-sharing CRDT. It's deliberately simple: a REST poll on a 1GÇô2 second cadence, no held connection per editor.
+
+## Polling for other sessions' edits
+
+```
+GET /v1/sessions/{session_id}/doc-events?after_id={cursor}
+```
+
+This returns the change events for documents **this session holds** that **other** sessions committed since `after_id`. Poll it roughly every 1GÇô2 seconds while a document is open.
+
+* **`after_id`** is your cursor. Start at `0` (or omit it), then pass back the highest event `id` you've seen so each poll only returns what's new.
+* **`include_own`** GÇö by default the feed **excludes this session's own writes** (you already have your own changes; no need to echo them back). A REST or MCP integrator that **writes on one connection and polls on another** GÇö or just wants a complete feed for every document it holds GÇö should pass `include_own=true`. Otherwise you'd never hear about edits your own background worker made on a different connection.
+
+
+ This feed is **REST-only by design**. It is intentionally not an MCP tool and not a held SSE connection GÇö it's a cheap stateless poll, which keeps long-lived connections free. (The SSE stream still carries *your own* turn's events like `documents_changed`; the doc-events feed is specifically for changes that originate in **other** sessions.)
+
+
+```bash theme={null}
+curl "https://api.superdocs.app/v1/sessions/my-session/doc-events?after_id=0" \
+ -H "Authorization: Bearer sk_YOUR_API_KEY"
+```
+
+### Event types
+
+Each event names a `document_id` and what happened to it:
+
+| Event | Meaning | What to do |
+| ------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `document_updated` | Another session committed content changes to this document. | Re-fetch the document and apply its changed sections (below). |
+| `document_removed` | Another session archived (soft-deleted) the document. | Drop the tab, or GÇö if the user is mid-edit in it GÇö offer a keep-editing choice (see [Restoring a removed document](#restoring-a-removed-document)). |
+| `document_restored` | A previously archived document was brought back. | Re-add the tab and load its current content. |
+| `document_renamed` | The document's title changed. | Update the tab label. No content fetch needed. |
+
+An event tells you *that* something changed, not the full new body GÇö re-fetch the one document it names rather than trusting any content in the event itself.
+
+## Applying a received change
+
+When you get a `document_updated` event:
+
+1. **Re-fetch that one document.** Read it from the roster with the body included GÇö `GET /v1/sessions/{session_id}/documents?include_html=true` GÇö or from session history. Fetch only the document the event named, not the whole roster's bodies.
+2. **Apply per section GÇö never whole-replace.** Replace only the `data-chunk-id` blocks that actually differ from what you currently render, and leave every other block GÇö including the section the user is typing in *right now* GÇö untouched. Reloading the entire editor would throw away unsaved local edits. This is the same section-level discipline used for AI results; see [Applying AI updates safely](/guides/editor-integration#applying-ai-updates-safely-without-losing-user-edits).
+3. **Advance your cursor** to the event's `id` so the next poll doesn't replay it.
+
+
+ **Don't write back a change you just received.** Applying an incoming `document_updated` into your editor will fire your editor's own change handler. If that handler autosaves, you'll re-commit the change you were just told about GÇö producing an echo loop and a spurious version bump for every other session. Suppress autosave while you apply a received change (a short "applying remote" flag around the transaction), or diff against what you already hold and save only genuinely local edits.
+
+
+## Saving human edits (autosave)
+
+Pure typing GÇö edits a human makes without an AI turn GÇö is persisted through the autosave endpoint:
+
+```
+POST /v1/sessions/{session_id}/documents/{document_id}/save
+```
+
+Send the current editor HTML as `html`. Critically, also send **`base_html`** GÇö the document HTML as it was *before* this round of edits (the last version you loaded or saved) GÇö so the change is recorded precisely (only what actually changed) instead of as a blunt whole-document overwrite. Omitting `base_html` still saves but loses that precision.
+
+```bash theme={null}
+curl -X POST \
+ https://api.superdocs.app/v1/sessions/my-session/documents/{document_id}/save \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"html": "
editedGǪ
", "base_html": "
originalGǪ
"}'
+```
+
+Practical rules:
+
+* **Debounce and coalesce per document.** Don't fire a save on every keystroke. Debounce (and save on blur), and collapse rapid edits into one save per document so you're not racing yourself. After a successful save, the HTML you just sent becomes the new `base_html` for the next round.
+* **This endpoint is non-AI and REST-only.** It saves typing without running a chat turn, and it isn't an MCP tool GÇö it's a UI affordance. The AI-edit flow is unaffected: an AI result is reconciled with the latest saved content, so a concurrent autosave is never silently overwritten.
+* A successful save is itself a change other sessions will see on their next `doc-events` poll GÇö which is why suppressing write-back on *received* changes (above) matters.
+
+## Resume awareness
+
+When a user (or a worker) comes back to a session after being away, you want to know whether anything changed while they were gone. The roster supports this on resume:
+
+```
+GET /v1/sessions/{session_id}/documents?report_changed=true
+```
+
+With `report_changed=true`, each document carries **`changed_since_seen`** GÇö `true` when its committed version is newer than the version *this* session last saw. Use it to show a one-time "changed since you were last here" notice on resume or when switching back to a session. Set it **only** on resume / session-switch, not on the ongoing 1GÇô2s poll GÇö the live `doc-events` feed already covers changes that happen while you're watching.
+
+## Conflict resolution
+
+The hard case is when the **same section** is edited by both sides at once GÇö the user typed in a paragraph while an AI turn (or another session) rewrote it. Don't silently pick a winner by reloading.
+
+* **Merge, keeping the user's in-progress edits.** Apply the incoming change to every section *except* the one the user is actively editing, and for the conflicted section keep the user's version and surface the other version as an accept-able suggestion. The user's keystrokes should win by default; they can accept the alternative explicitly. This is the same conflict posture as [applying AI results](/guides/editor-integration#applying-ai-updates-safely-without-losing-user-edits).
+* **When the same field gets two different values, keep the user's** GÇö never concatenate the two into one blob.
+* **Never text-merge a non-text node.** Tables, diagrams (Mermaid), drawings, and equations (KaTeX/LaTeX) are structured nodes GÇö splicing one side's text into the other corrupts them. Compare and resolve those **whole**: take one version or the other for the node, don't word-diff their markup. (This is the same rule as rendering and diffing them in an editor; see [Rendering visual content](/guides/editor-integration#rendering-visual-content-diagrams-and-equations).)
+
+### Explicit per-section conflict resolution
+
+If you run your own editor and want to resolve a single conflicted section deliberately GÇö rather than relying on the automatic merge above GÇö call:
+
+```
+POST /v1/sessions/{session_id}/chunks/{chunk_id}/re-edit
+```
+
+Use it when a user edited a section while the AI was also changing it. Pick one of two outcomes with `mode`:
+
+* **`"redo"`** (default) GÇö re-apply the AI's intended change on top of the user's *current* text. The user's edits stay; the AI's change is layered back over them.
+* **`"merge"`** GÇö blend the user's version and the AI's version into one combined section.
+
+| Field | Required | Meaning |
+| ---------------------- | -------- | -------------------------------------------- |
+| `user_current_html` | yes | The user's current version of the section. |
+| `ai_proposed_new_html` | GÇö | The AI's proposed version of the section. |
+| `ai_original_old_html` | GÇö | What the AI started from (its before-state). |
+| `ai_explanation` | GÇö | The AI's stated intent for the change. |
+| `mode` | GÇö | `"redo"` (default) or `"merge"`. |
+| `model_tier` | GÇö | Optional model-tier override. |
+
+The response returns the rewritten section HTML only GÇö apply it to that one `data-chunk-id` block the same way you apply any other section update.
+
+```bash theme={null}
+curl -X POST \
+ https://api.superdocs.app/v1/sessions/my-session/chunks/{chunk_id}/re-edit \
+ -H "Authorization: Bearer sk_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "user_current_html": "
the user's current textGǪ
",
+ "ai_proposed_new_html": "
the AI's versionGǪ
",
+ "ai_original_old_html": "
what the AI started fromGǪ
",
+ "ai_explanation": "Tighten the wording and fix the date.",
+ "mode": "redo"
+ }'
+```
+
+
+ **This is optional, and it's a billable AI edit.** It runs one AI edit and counts as **one billable operation**. The automatic merge described above already resolves most conflicts on its own GÇö reach for `re-edit` only when you want explicit, deliberate control over one section. It is a REST endpoint, **not** an MCP/agent tool.
+
+
+## Restoring a removed document
+
+If a `document_removed` event arrives for a document the user is mid-edit in, archiving it out from under them would lose their unsaved work. Offer a keep-editing choice instead of dropping the tab silently. Choosing "keep editing" restores the document and persists the user's current content so the document converges for everyone:
+
+```
+POST /v1/sessions/{session_id}/documents/{document_id}/unarchive
+```
+
+Send the editor's current `html` (same body shape as `/save`). It un-archives the document, re-links it to this session, and **saves the latest content** GÇö idempotent if another session already restored it. This session-scoped, keep-editing form is non-billable and isn't an MCP tool GÇö it's a UI affordance for the mid-edit case, because it persists the in-progress `html` as part of the restore.
+
+For a plain restore that doesn't carry editor content GÇö restoring an archived document by its durable id from anywhere GÇö use `POST /v1/documents/{document_id}/unarchive`, which is also the [`unarchive_document` MCP tool](/mcp/available-tools) (non-billable). An agent can restore a document with it; the session-scoped form above is the one to use only when you need to preserve a user's unsaved edits during the restore.
+
+## Putting it together
+
+A robust concurrent-editing loop, per open document:
+
+1. **Poll** `GET .../doc-events?after_id={cursor}` every 1GÇô2s (add `include_own=true` if you write on a separate connection). Advance the cursor.
+2. On `document_updated`, **re-fetch** that document and **apply changed sections** GÇö with autosave suppressed during the apply.
+3. **Debounce + coalesce** human edits and **autosave** them with `html` + `base_html`.
+4. On a same-section clash, **merge and keep the user's edits**; resolve non-text nodes whole.
+5. On **resume**, read the roster with `report_changed=true` and surface a one-time "changed while away" notice.
+
+## Stuck?
+
+If your collaboration shape isn't covered here GÇö a different conflict policy, presence indicators, true real-time sync GÇö email [hello@superdocs.app](mailto:hello@superdocs.app) or book a 15-minute integration call at [cal.com/superdocs](https://cal.com/superdocs).
+
+## Related guides
+
+* [Editor Integration](/guides/editor-integration) GÇö preserving `data-chunk-id` and applying updates per section.
+* [Multi-Document Sessions](/guides/multi-document) GÇö several documents open in one session, and applying a revert per document.
+* [SSE Streaming](/guides/streaming) GÇö your own turn's live events (`documents_changed`, `proposed_change_batch`).
+* [Server Integration](/guides/server-integration) GÇö when one of the concurrent writers is a headless backend job.
+
+
+# Editor Integration
+Source: https://docs.superdocs.app/guides/editor-integration
+
+Make any rich-text editor round-trip SuperDocs document IDs, so surgical edits work reliably. Drop-in snippets for ProseMirror, TipTap, Slate, Lexical, Quill, and CKEditor 5.
+
+# Editor Integration
+
+**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 to wire up your editor.
+
+SuperDocs identifies every block-level element in your document with a `data-chunk-id` attribute. Your editor must preserve those attributes across the round-trip GÇö HTML in, editor model, HTML back out GÇö for surgical edits to work.
+
+
+ This guide covers **attribute preservation in a rich-text editor**. For rendering inline diff overlays inside the editor, see [Human-in-the-Loop GåÆ Rendering diffs inline in your editor](/guides/human-in-the-loop#rendering-diffs-inline-in-your-editor). For streaming events, see [SSE Streaming](/guides/streaming). If your product doesn't have an editor GÇö your AI agent is the consumer of SuperDocs, or you're running server-side / batch GÇö see [Agent Tool Integration](/guides/agent-tool-integration) or [Server Integration](/guides/server-integration) instead.
+
+
+## Why this matters
+
+Every SuperDocs response returns HTML like this:
+
+```html theme={null}
+
Section 1
+
GǪ
+```
+
+The `data-chunk-id` values are how the AI references specific sections when it proposes edits. When your app sends the document back on the next turn, those same IDs must still be on the same blocks. If they aren't, the AI's "edit Section 2" request lands on the wrong block GÇö or nothing at all.
+
+**The failure mode is silent.** Most rich-text editors strip unknown HTML attributes by default when parsing input into their internal model, and/or don't emit them when serialising back to HTML. Edits work intermittently on blocks whose tags happen to survive, and fail mysteriously on the rest. No error is thrown. A five-minute check now saves hours of debugging later.
+
+## The pattern, in plain English
+
+Every editor integration follows the same three steps:
+
+1. **Parse** incoming HTML from SuperDocs into your editor's document model, preserving `data-chunk-id` on every block-level node.
+2. **Serialise** the editor model back to HTML, preserving the same `data-chunk-id` attributes.
+3. **Render** incoming updates. `document_sync.content` (which arrives before the AI starts) can safely replace the whole document. For the AI's *results*, prefer **section-level application** over whole-document replacement GÇö see [Applying AI updates safely](#applying-ai-updates-safely-without-losing-user-edits) below for why and how.
+
+The snippets below implement these three steps for the six most common editors. Pick the one matching your stack, paste it in, and verify the round-trip with the test at the bottom of this page.
+
+## Working snippets
+
+
+
+ Install the vanilla ProseMirror packages:
+
+ ```bash theme={null} theme={null}
+ npm install prosemirror-state prosemirror-view prosemirror-model \
+ prosemirror-schema-basic prosemirror-schema-list prosemirror-history \
+ prosemirror-keymap prosemirror-commands
+ ```
+
+ Your schema needs to do two things:
+
+ 1. Add `data-chunk-id` as a preserved attribute on every standard block node (paragraph, heading, list, blockquote, code block, horizontal rule).
+ 2. Register a **wrapper node** for `
GǪ
` elements. SuperDocs uses these wrappers when a single chunk spans multiple block elements (e.g. a heading plus the paragraphs that follow it). If your schema has no node that matches `div[data-chunk-id]`, the parser will descend into the children, the wrapper's `data-chunk-id` will be silently dropped, and any inline-diff or chunk-targeted edit referencing that ID will fail to render. The basic ProseMirror schema does not include a `
` node GÇö you must add one.
+
+ ```typescript theme={null} theme={null}
+ // schema.ts
+ import { Schema, NodeSpec } from "prosemirror-model";
+ import { schema as basicSchema } from "prosemirror-schema-basic";
+ import { addListNodes } from "prosemirror-schema-list";
+
+ // TS note: prosemirror-model's ParseRule.getAttrs signature has shifted
+ // across versions. If your compiler complains about the parameter type,
+ // leave the signature alone and add `as HTMLElement` at the usage site GÇö
+ // do not refactor the helper.
+ function withChunkId(spec: NodeSpec): NodeSpec {
+ const attrs = { ...(spec.attrs ?? {}), "data-chunk-id": { default: null } };
+
+ const parseDOM = (spec.parseDOM ?? []).map((rule) => ({
+ ...rule,
+ getAttrs: (node: string | HTMLElement) => {
+ const base =
+ typeof rule.getAttrs === "function"
+ ? rule.getAttrs(node as HTMLElement) ?? {}
+ : rule.attrs ?? {};
+ if (typeof node === "string") return base;
+ return { ...base, "data-chunk-id": node.getAttribute("data-chunk-id") };
+ },
+ }));
+
+ const originalToDOM = spec.toDOM;
+ const toDOM: NodeSpec["toDOM"] = originalToDOM
+ ? (node) => {
+ const out = originalToDOM(node);
+ if (!Array.isArray(out)) return out;
+ const [tag, maybeAttrs, ...rest] = out as [string, unknown, ...unknown[]];
+ const chunkId = node.attrs["data-chunk-id"];
+ if (!chunkId) return out;
+ const isAttrs =
+ maybeAttrs &&
+ typeof maybeAttrs === "object" &&
+ !Array.isArray(maybeAttrs) &&
+ !(maybeAttrs as { nodeType?: unknown }).nodeType;
+ return isAttrs
+ ? [tag, { ...(maybeAttrs as Record), "data-chunk-id": chunkId }, ...rest]
+ : [tag, { "data-chunk-id": chunkId }, maybeAttrs, ...rest];
+ }
+ : undefined;
+
+ return { ...spec, attrs, parseDOM, toDOM };
+ }
+
+ // Wrapper node for multi-element chunks:
GǪ
.
+ // Holds one or more block children; preserves the chunk-id on round-trip.
+ const chunkWrapperSpec: NodeSpec = {
+ group: "block",
+ content: "block+",
+ attrs: { "data-chunk-id": { default: null } },
+ parseDOM: [{
+ tag: "div[data-chunk-id]",
+ getAttrs: (node) =>
+ typeof node === "string"
+ ? {}
+ : { "data-chunk-id": node.getAttribute("data-chunk-id") },
+ }],
+ toDOM: (node) => [
+ "div",
+ node.attrs["data-chunk-id"]
+ ? { "data-chunk-id": node.attrs["data-chunk-id"] }
+ : {},
+ 0,
+ ],
+ };
+
+ let nodes = basicSchema.spec.nodes;
+ for (const name of ["paragraph", "blockquote", "heading", "horizontal_rule", "code_block"]) {
+ const spec = nodes.get(name);
+ if (spec) nodes = nodes.update(name, withChunkId(spec));
+ }
+ nodes = addListNodes(nodes, "paragraph block*", "block");
+ for (const name of ["ordered_list", "bullet_list", "list_item"]) {
+ const spec = nodes.get(name);
+ if (spec) nodes = nodes.update(name, withChunkId(spec));
+ }
+ nodes = nodes.addToEnd("chunk_wrapper", chunkWrapperSpec);
+
+ export const schema = new Schema({ nodes, marks: basicSchema.spec.marks });
+ ```
+
+ Use this `schema` when creating your `EditorState`. Use `DOMParser.fromSchema(schema).parse(htmlElement)` to load SuperDocs HTML and `DOMSerializer.fromSchema(schema).serializeFragment(doc.content)` to serialise it back out.
+
+ **Where this slots in:** typically inside a React / Vue / Svelte component that mounts ProseMirror via `new EditorView(domNode, { state })`. Expose two methods on a ref GÇö `getHtml()` and `setHtml(html)` GÇö so the chat panel can read the current document before each send and write the updated HTML after each `final` event.
+
+
+
+ Install TipTap:
+
+ ```bash theme={null} theme={null}
+ npm install @tiptap/react @tiptap/starter-kit
+ ```
+
+ You need two pieces:
+
+ 1. A **global-attribute extension** that preserves `data-chunk-id` on every standard block node.
+ 2. A **wrapper Node** for `
GǪ
` elements. SuperDocs uses these wrappers when a single chunk spans multiple block elements (e.g. a heading plus the paragraphs that follow it). TipTap's StarterKit has no node that matches a `
`, so without this extra Node the wrapper's `data-chunk-id` is silently dropped during parsing GÇö and any inline-diff or chunk-targeted edit referencing that ID will fail to render.
+
+ ```typescript theme={null} theme={null}
+ // chunk-id-preserver.ts
+ import { Extension, Node } from "@tiptap/core";
+
+ export const ChunkIdPreserver = Extension.create({
+ name: "chunkIdPreserver",
+ addGlobalAttributes() {
+ return [{
+ types: [
+ "paragraph",
+ "heading",
+ "blockquote",
+ "bulletList",
+ "orderedList",
+ "listItem",
+ "codeBlock",
+ "horizontalRule",
+ ],
+ attributes: {
+ "data-chunk-id": {
+ default: null,
+ parseHTML: (el) => el.getAttribute("data-chunk-id"),
+ renderHTML: (attrs) =>
+ attrs["data-chunk-id"]
+ ? { "data-chunk-id": attrs["data-chunk-id"] }
+ : {},
+ },
+ },
+ }];
+ },
+ });
+
+ // Wrapper Node for multi-element chunks:
GǪ
.
+ export const ChunkWrapper = Node.create({
+ name: "chunkWrapper",
+ group: "block",
+ content: "block+",
+ parseHTML() {
+ return [{ tag: "div[data-chunk-id]" }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["div", HTMLAttributes, 0];
+ },
+ addAttributes() {
+ return {
+ "data-chunk-id": {
+ default: null,
+ parseHTML: (el) => el.getAttribute("data-chunk-id"),
+ renderHTML: (attrs) =>
+ attrs["data-chunk-id"]
+ ? { "data-chunk-id": attrs["data-chunk-id"] }
+ : {},
+ },
+ };
+ },
+ });
+ ```
+
+ Register both on your editor:
+
+ ```typescript theme={null} theme={null}
+ const editor = useEditor({
+ extensions: [StarterKit, ChunkIdPreserver, ChunkWrapper],
+ content: superDocsHtml,
+ });
+ ```
+
+ Then `editor.getHTML()` returns the round-tripped HTML, and `editor.commands.setContent(html, false)` loads a new SuperDocs payload without breaking undo history.
+
+ **Where this slots in:** anywhere you already use `useEditor`. If you use additional node extensions (tables, images, custom blocks), add their type names to the `types` array.
+
+
+
+ Install Slate:
+
+ ```bash theme={null} theme={null}
+ npm install slate slate-react slate-history
+ ```
+
+ Declare your element types with an optional `data-chunk-id` field and provide HTML serialisation helpers:
+
+ ```typescript theme={null} theme={null}
+ // slate-chunk-id.ts
+ import { Element as SlateElement, Descendant } from "slate";
+
+ export type ChunkedElement = SlateElement & {
+ type: string;
+ "data-chunk-id"?: string | null;
+ children: { text: string }[];
+ };
+
+ // Render incoming SuperDocs HTML into Slate nodes.
+ export function fromHtml(html: string): ChunkedElement[] {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return Array.from(doc.body.children).map((el) => ({
+ type: el.tagName.toLowerCase(),
+ "data-chunk-id": el.getAttribute("data-chunk-id"),
+ children: [{ text: el.textContent ?? "" }],
+ })) as ChunkedElement[];
+ }
+
+ // Serialise Slate nodes back to HTML, preserving data-chunk-id.
+ export function toHtml(nodes: ChunkedElement[]): string {
+ return nodes
+ .map((n) => {
+ const id = n["data-chunk-id"] ? ` data-chunk-id="${n["data-chunk-id"]}"` : "";
+ const tag = n.type ?? "p";
+ const inner = n.children.map((c) => c.text ?? "").join("");
+ return `<${tag}${id}>${inner}${tag}>`;
+ })
+ .join("");
+ }
+ ```
+
+ In your `renderElement`, pass `data-chunk-id` through on the outer DOM element:
+
+ ```tsx theme={null} theme={null}
+ function renderElement({ attributes, children, element }: RenderElementProps) {
+ const chunkId = (element as ChunkedElement)["data-chunk-id"];
+ const Tag = (element as ChunkedElement).type as keyof JSX.IntrinsicElements;
+ return (
+
+ {children}
+
+ );
+ }
+ ```
+
+ **Where this slots in:** call `fromHtml()` whenever you receive a `document_sync` or `final` event, pass the result to `Editor.withoutNormalizing` or `Transforms.insertNodes` as appropriate, and call `toHtml(editor.children)` before every chat send.
+
+
+
+ Install Lexical:
+
+ ```bash theme={null} theme={null}
+ npm install lexical @lexical/react @lexical/html
+ ```
+
+ Lexical doesn't have a first-class "preserve unknown attributes" feature GÇö the cleanest pattern is a `WeakMap` that stores chunk IDs against nodes, with `exportDOM` overrides on the block node types you care about.
+
+ ```typescript theme={null} theme={null}
+ // chunk-id-store.ts
+ import { LexicalNode, ElementNode } from "lexical";
+ import { $generateNodesFromDOM, $generateHtmlFromNodes } from "@lexical/html";
+
+ const chunkIds = new WeakMap();
+
+ export const setChunkId = (node: LexicalNode, id: string | null) => {
+ if (id) chunkIds.set(node, id);
+ else chunkIds.delete(node);
+ };
+ export const getChunkId = (node: LexicalNode) => chunkIds.get(node) ?? null;
+
+ // Import: walk DOM + Lexical nodes in parallel and record chunk IDs.
+ export function importHtmlWithChunkIds(editor: any, html: string) {
+ editor.update(() => {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const nodes = $generateNodesFromDOM(editor, doc);
+ const blocks = doc.body.querySelectorAll("[data-chunk-id]");
+ blocks.forEach((el, i) => {
+ const id = el.getAttribute("data-chunk-id");
+ if (nodes[i] && id) setChunkId(nodes[i], id);
+ });
+ // Replace editor contents with nodes.
+ });
+ }
+
+ // Export: generate HTML, then inject data-chunk-id back onto block elements.
+ export function exportHtmlWithChunkIds(editor: any, root: ElementNode): string {
+ let html = "";
+ editor.getEditorState().read(() => {
+ html = $generateHtmlFromNodes(editor, null);
+ });
+ const doc = new DOMParser().parseFromString(`${html}`, "text/html");
+ const blocks = doc.body.children;
+ const children = root.getChildren();
+ for (let i = 0; i < blocks.length && i < children.length; i++) {
+ const id = getChunkId(children[i]);
+ if (id) blocks[i].setAttribute("data-chunk-id", id);
+ }
+ return doc.body.innerHTML;
+ }
+ ```
+
+ **Where this slots in:** call `importHtmlWithChunkIds` on `document_sync` and `final`, and `exportHtmlWithChunkIds` before every chat send. Lexical's model makes this the most custom wiring of any editor on this page GÇö verify the round-trip carefully with the test at the bottom.
+
+
+
+ Install Quill:
+
+ ```bash theme={null} theme={null}
+ npm install quill
+ ```
+
+ Register a Parchment block-level attributor for `data-chunk-id`:
+
+ ```typescript theme={null} theme={null}
+ // quill-chunk-id.ts
+ import Quill from "quill";
+ const Parchment = Quill.import("parchment") as typeof import("parchment");
+
+ const ChunkIdAttributor = new Parchment.Attributor.Attribute(
+ "data-chunk-id",
+ "data-chunk-id",
+ { scope: Parchment.Scope.BLOCK }
+ );
+ Quill.register(ChunkIdAttributor, true);
+ ```
+
+ Load SuperDocs HTML via the clipboard API (Parchment preserves block attributes by default once the attributor is registered):
+
+ ```typescript theme={null} theme={null}
+ quill.clipboard.dangerouslyPasteHTML(superDocsHtml);
+ ```
+
+ Read the round-tripped HTML with `quill.root.innerHTML` GÇö `data-chunk-id` stays on every block.
+
+ **Where this slots in:** register the attributor once at startup, before any editor is instantiated. Use `clipboard.dangerouslyPasteHTML` for every `document_sync` and `final` event.
+
+
+
+ Install the CKEditor 5 base packages:
+
+ ```bash theme={null} theme={null}
+ npm install @ckeditor/ckeditor5-core @ckeditor/ckeditor5-engine
+ ```
+
+ CKEditor 5 uses a schema + conversion model. Register `data-chunk-id` as an allowed attribute on every block element and set up conversion both ways:
+
+ ```typescript theme={null} theme={null}
+ // chunk-id-plugin.ts
+ import { Plugin } from "@ckeditor/ckeditor5-core";
+
+ export default class ChunkIdPreserver extends Plugin {
+ static get pluginName() { return "ChunkIdPreserver"; }
+
+ init() {
+ const editor = this.editor;
+ const schema = editor.model.schema;
+
+ // Allow the attribute on every block-level element.
+ schema.extend("$block", { allowAttributes: "chunkId" });
+
+ // Downcast: model GåÆ view
+ editor.conversion.for("downcast").attributeToAttribute({
+ model: "chunkId",
+ view: "data-chunk-id",
+ });
+
+ // Upcast: view GåÆ model
+ editor.conversion.for("upcast").attributeToAttribute({
+ view: { name: /.+/, key: "data-chunk-id" },
+ model: "chunkId",
+ });
+ }
+ }
+ ```
+
+ Register it on your editor:
+
+ ```typescript theme={null} theme={null}
+ ClassicEditor.create(element, {
+ plugins: [..., ChunkIdPreserver],
+ });
+ ```
+
+ Then `editor.getData()` returns HTML with `data-chunk-id` preserved, and `editor.setData(html)` loads SuperDocs HTML without stripping it.
+
+ **Where this slots in:** add `ChunkIdPreserver` to the `plugins` array of whichever CKEditor 5 build you're using. If you have custom block elements (tables, images, custom widgets), ensure `schema.extend` is called for their names too.
+
+
+
+## Applying AI updates safely (without losing user edits)
+
+The naive pattern GÇö replace the whole editor document with `final.updated_html` on every turn GÇö works for a demo and fails in production in two specific ways. We hit both building the SuperDocs web app; they're worth designing out of your integration from day one.
+
+**Failure 1 GÇö wiping concurrent user edits.** AI turns take seconds to minutes. If the user keeps typing while the AI works and you then `setContent` the AI's full document, everything they typed is silently gone. The fix is to apply changes **per section**: every result identifies exactly which chunks changed (`document_changes`, `chunk_diffs` in compact mode, or the per-change `old_html`/`new_html` in review mode), so you can replace only those `data-chunk-id` nodes and leave the rest of the document GÇö including the user's in-progress typing GÇö untouched. If the user edited the *same* section the AI changed, don't silently overwrite either version: keep the user's version and offer the AI's as a suggestion (or re-send the user's version on the next turn). The user's keystrokes should always win by default.
+
+**Protecting stored formatting on untouched sections.** Editor serialisers rarely reproduce a document's HTML byte-for-byte, and a purely cosmetic serialisation difference can look like a user edit. If your integration tracks which blocks the user actually modified, send that list as `touched_chunk_ids` alongside `document_html` on chat and save calls: a section not in the list whose text is unchanged keeps its stored formatting exactly, even if your serialiser emitted it differently. Sections in the list, and any section whose text changed, always take your submitted content. Omit the field for the default behaviour (any differing section is treated as an edit).
+
+**Failure 2 GÇö double-applying in review mode.** In `ask_every_time` mode, if your UI applies each change to the editor when the user accepts it, remember the job still finishes with a `final` event carrying the complete updated document. Applying accepted changes incrementally **and** then loading the final document re-applies everything GÇö newly created sections get inserted twice. Pick one strategy and stick to it: **either** apply per-change on accept and ignore the final document, **or** keep the editor untouched during review and load only the final. Never both.
+
+**Revert and redo flow through the same path.** When you rewind a session with `POST /v1/sessions/{id}/revert`, the response carries a per-document `revert_changes` map. Apply it section-by-section exactly like an AI result rather than reloading the whole document, so any in-progress typing survives and a clashed section can keep the user's version and surface the other as an accept-able suggestion. Redo (`POST /v1/sessions/{id}/redo`) returns the same shape. Dry-run revert (`"dry_run": true`) returns the changes without committing, so you can preview it first. Full detail in [Reverting without a whole-document swap](/guides/multi-document#reverting-without-a-whole-document-swap).
+
+Whole-document replacement remains fine for read-only/display integrations, or if you lock the editor while an AI turn is running.
+
+
+ If two of your surfaces (e.g. an editor tab and a server job, or two browser sessions) can edit the **same** document, the same section-level discipline applies across connections GÇö poll for other sessions' edits and re-apply them per section rather than reloading the whole document. See [Concurrent & Cross-Session Editing](/guides/concurrent-editing).
+
+
+## Rendering visual content (diagrams and equations)
+
+SuperDocs documents can contain diagrams (Mermaid), equations (LaTeX/KaTeX), drawings, and images. On the wire these arrive as HTML nodes that carry their **source in `data-` attributes** (e.g. the diagram's text spec, the equation's LaTeX) plus the node markup itself.
+
+* **Preserve the `data-` attributes on round-trip** GÇö exactly the same rule (and the same silent failure mode) as `data-chunk-id`. Most editor schemas and HTML sanitizers strip unknown attributes and `