> ## Documentation Index
> Fetch the complete documentation index at: https://docs.superdocs.app/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 — HTML in, editor model, HTML back out — for surgical edits to work.

<Note>
  This guide covers **attribute preservation in a rich-text editor**. For rendering inline diff overlays inside the editor, see [Human-in-the-Loop → 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 — your AI agent is the consumer of SuperDocs, or you're running server-side / batch — see [Agent Tool Integration](/guides/agent-tool-integration) or [Server Integration](/guides/server-integration) instead.
</Note>

## Why this matters

Every SuperDocs response returns HTML like this:

```html theme={null}
<h2 data-chunk-id="550e8400-e29b-41d4-a716-446655440000">Section 1</h2>
<p data-chunk-id="6a7b8c9d-e0f1-2345-6789-abcdef012345">…</p>
```

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 — 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 — 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

<Tabs>
  <Tab title="ProseMirror">
    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 `<div data-chunk-id="…">…</div>` 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 `<div>` node — 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 —
    // 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<string, unknown>), "data-chunk-id": chunkId }, ...rest]
              : [tag, { "data-chunk-id": chunkId }, maybeAttrs, ...rest];
          }
        : undefined;

      return { ...spec, attrs, parseDOM, toDOM };
    }

    // Wrapper node for multi-element chunks: <div data-chunk-id="…">…</div>.
    // 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 — `getHtml()` and `setHtml(html)` — so the chat panel can read the current document before each send and write the updated HTML after each `final` event.
  </Tab>

  <Tab title="TipTap">
    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 `<div data-chunk-id="…">…</div>` 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 `<div>`, so without this extra Node the wrapper's `data-chunk-id` is silently dropped during parsing — 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: <div data-chunk-id="…">…</div>.
    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.
  </Tab>

  <Tab title="Slate">
    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 (
        <Tag {...attributes} data-chunk-id={chunkId ?? undefined}>
          {children}
        </Tag>
      );
    }
    ```

    **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.
  </Tab>

  <Tab title="Lexical">
    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 — 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<LexicalNode, string>();

    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(`<body>${html}</body>`, "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 — verify the round-trip carefully with the test at the bottom.
  </Tab>

  <Tab title="Quill">
    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` — `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.
  </Tab>

  <Tab title="CKEditor 5">
    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 → view
        editor.conversion.for("downcast").attributeToAttribute({
          model: "chunkId",
          view: "data-chunk-id",
        });

        // Upcast: view → 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.
  </Tab>
</Tabs>

## Applying AI updates safely (without losing user edits)

The naive pattern — replace the whole editor document with `final.updated_html` on every turn — 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 — 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 — including the user's in-progress typing — 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 — 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 — 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.

<Note>
  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 — 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).
</Note>

## 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** — exactly the same rule (and the same silent failure mode) as `data-chunk-id`. Most editor schemas and HTML sanitizers strip unknown attributes and `<svg>` by default; allow them through, or diagrams will degrade to empty blocks after one edit cycle.
* **Render client-side after mount.** Use the standard libraries (Mermaid for diagrams, KaTeX for equations) to render the source into SVG/HTML once the node is in the DOM. Poll-free pattern: render on insert + on change of the source attribute.
* **Render into a container your UI framework doesn't manage.** If your framework (React, Vue, etc.) believes it owns the rendered markup, its next reconciliation pass can silently wipe the out-of-band SVG you just produced — this exact bug shipped in our own web app before we caught it. Mount the rendered output inside an element the framework treats as opaque (a ref'd container you never re-render declaratively), not via an HTML-binding prop.
* **You never need to rasterize for export** — `POST /v1/documents/export` pre-renders diagrams and equations server-side in every format.

## Out-of-flow parts (headers, footers, footnotes, comments)

A document with page headers, footers, footnote/endnote bodies, or reviewer comments carries them as ordinary blocks labeled with a `data-part-type` attribute (alongside their `data-chunk-id`). They hold out-of-flow content, so they aren't body text:

* **Preserve them like any other block.** Same parse/serialise rule (and the same silent failure mode) as `data-chunk-id`: keep the `data-part-type` attribute and the block intact on the round-trip.
* **Render them appropriately, or not at all.** A footer block is not a trailing paragraph. If your UI has no header/footer affordance, it's fine to hide these blocks; just keep them in the HTML you send back.
* **Leaving one out never deletes it.** The server treats an out-of-flow part's absence from `document_html` as normal (an editor view without the footer is the usual state), so a client that strips them can't destroy them. To actually delete one, pass its chunk id in the `deleted_part_chunk_ids` request field alongside `document_html`.
* **Users edit them by chat** like any other content ("fix the typo in footnote 3"). In review mode the proposal arrives as a normal entry in `proposed_change_batch`, targeting the part's `chunk_id`.

## Multi-document sessions in your editor

Sessions can hold several open documents (see the [Multi-Document guide](/guides/multi-document)). If your product surfaces this, the editor-side pattern is:

* Render **tabs** from `GET /v1/sessions/{id}/documents` (stable insertion order; use the `title` field each roster entry returns for the tab label). The roster is token-light by default — it returns metadata only and omits each document's HTML body (`null`); pass `include_html=true` when you actually need the content.
* Switch tabs with the focus endpoint; pass `document_id` on chat turns initiated from a specific tab.
* Listen for `documents_changed` to **badge background tabs** the AI touched, and to **add a tab** when an entry carries `created: true` (this fires in either approval mode for created documents).
* If a document payload includes `page_setup`, you can render true page geometry (size, orientation, margins) instead of a generic canvas.

## Other editors and custom implementations

The principle is the same regardless of editor: preserve unknown HTML attributes on block-level elements across parse and serialise.

Three things to verify in your editor's documentation:

1. Does the **parser** strip unknown attributes by default? Most do. Look for "custom attributes" or "attribute preservation" in the docs.
2. Does the **serialiser** emit them when converting back to HTML? If the parser accepted them, the serialiser usually does — but not always.
3. Does the **internal model** store them on every block type you plan to use? Often there's a schema or node definition that has to be extended per node type.

### Verification test

Paste any SuperDocs response into your editor, read the HTML back out, and diff against the input. Every `data-chunk-id` must survive.

```javascript theme={null} theme={null}
const incoming = `<h1 data-chunk-id="abc123">Title</h1><p data-chunk-id="def456">Body</p>`;

editor.setHtml(incoming);
const roundTripped = editor.getHtml();

const inIds = [...incoming.matchAll(/data-chunk-id="([^"]+)"/g)].map(m => m[1]);
const outIds = [...roundTripped.matchAll(/data-chunk-id="([^"]+)"/g)].map(m => m[1]);

console.assert(
  inIds.every(id => outIds.includes(id)),
  "Chunk IDs did not survive the round-trip",
  { inIds, outIds }
);
```

Run this on every block type your product uses — headings, paragraphs, lists, list items, blockquotes, code blocks, horizontal rules, tables. A gap in any single type will surface as occasional silent edit failures in production.

## Stuck?

If your editor isn't covered here, or chunk-id round-trip is failing despite following the pattern, 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 add your editor's pattern to this guide.
