Embed the editor
By the end of this guide, a document opens inside your own page. Your users sign in to your app, not to Proof. The editor runs in an iframe served from embed.proof.exalto.ai, and talks to Proof directly over its own socket. Your page tells it which document to open and how it should look, and it tells your page when something happens that your app should know about.
You need a secret test key (sk_test_…) from the console, and a document to open. The quickstart creates one.
How the pieces fit
Section titled “How the pieces fit”Your server is the only thing that holds your secret key. When your page wants to show a document, it asks your server for a session token: a short-lived token for one user and one document. The page hands that token to the editor, and the editor uses it to connect to Proof. When the token is about to lapse, the editor asks your page for a new one, and your page asks your server again.
Because the editor is in its own origin, nothing on your page can reach into it. That is what lets Proof tell typed words from pasted ones, and people from scripts. See the trust model for why that matters.
Embed a document
Section titled “Embed a document”-
Allow your origins. In the console, under Embed origins, or with
PATCH /v1/platform, add each origin your app runs on, such ashttps://acme.com. The editor only loads inside pages on these origins; anywhere else the browser refuses the frame. In test mode,http://localhostandhttp://127.0.0.1on any port are allowed without adding them. -
Add a session route to your server. Check your own sign-in first: this route is where your app vouches for who the user is. Then start a session for the user’s Proof id, creating the Proof user the first time.
server.ts import express from "express";import Proof from "@exalto/proof";const app = express();const proof = new Proof(process.env.PROOF_SECRET_KEY);// Your user's Proof id, created at Proof and stored on your user the first time.async function proofUserId(user) {if (user.proofUserId) return user.proofUserId;const proofUser = await proof.users.upsert(user.id, { name: user.name, avatar_url: user.avatarUrl });await db.users.update(user.id, { proofUserId: proofUser.id });return proofUser.id;}app.post("/proof/session", requireSignIn, async (req, res) => {const documentId = String(req.query.doc);try {const session = await proof.sessions.create({ user: await proofUserId(req.user), document: documentId });res.json({ token: session.token, role: session.role });} catch (error) {if (error.code === "no_access") return res.status(403).json({ error: "no_access" });throw error;}});Pass
groupwith the id of the person’s own sign-in session with you, and when they sign out, end every Proof session it started withproof.users.endSessions: their open editors stop at once.The session’s
roleis the user’s role in the document:owner,editor,suggesterorviewer. Proof refuses the session withno_accesswhen the user isn’t a member and the document’s link access doesn’t let them in. When someone changes their name or picture in your product, callproof.users.upsertagain with your id, and every open editor shows the change. See Sessions for every field, and Users for how your ids and Proof’s fit together. -
Mount the editor in your page. Give it an element, the document id, and a
getTokenfunction that calls your route.editor-page.ts import { mountEditor } from "@exalto/proof-embed";const documentId = "doc_8Hq2Lm4R";const editor = mountEditor(document.querySelector("#editor"), {document: documentId,getToken: async () => {const response = await fetch(`/proof/session?doc=${documentId}`, { method: "POST" });if (!response.ok) throw new Error("No access to this document");return (await response.json()).token;},});The editor calls
getTokenonce when it mounts and again two minutes before each token lapses (tokens last 15 minutes). Don’t cache tokens yourself.If you don’t use a bundler, load the script instead. It puts the same functions on
window.Proof:<script src="https://embed.proof.exalto.ai/v1.js"></script><div id="editor"></div><script>Proof.mountEditor(document.getElementById("editor"), { document: "doc_8Hq2Lm4R", getToken });</script> -
Remove it when you leave the page. In a single-page app, call
editor.destroy()when the route changes, so the socket closes and the last edits are saved first.
Size it
Section titled “Size it”The editor sizes itself in one of two ways:
height: "auto"(the default) grows the iframe with the text, so your page scrolls as it would for any long article. Use this when the document is the page.height: "fill"makes the editor fill its container and scroll inside it. Use this when the document sits in a fixed panel, such as beside a sidebar. Give the container a height.
mountEditor(panel, { document: documentId, getToken, height: "fill" });Where the frame is too narrow, comment and suggestion cards move into a panel whatever you choose for comments.
Make it look like your app
Section titled “Make it look like your app”Appearance is set with options, not CSS: your stylesheets can’t reach inside the iframe.
mountEditor(element, { document: documentId, getToken, colorScheme: "auto", // "light", "dark", or follow the system toolbar: "floating", // "header", "floating" or "none" comments: "right", // "right", "left" or "hidden" rails: "left", // the attribution rail: "left", "right" or "hidden"});The seal’s own colours never change: they mean the same thing on every platform. Keyboard shortcuts such as ⌘K (the editor’s command palette) and ⌘P (print) belong to the editor while it has focus, and to your page otherwise.
To change something after mounting, such as the colour scheme when your user flips your own theme switch, call editor.setOptions({ colorScheme: "dark" }). When the person changes a display setting inside the editor, it sends a settings event with the new options: keep them with your other preferences and pass them back next time.
Your own controls can sit over the editor’s header, as a menu button or a notification bell would: keep room for them with headerStart and headerEnd, in pixels, and add your own commands to the editor’s ⌘K palette with commands. See Options.
Choose a mode
Section titled “Choose a mode”People with the editor or owner role write directly by default. Pass mode: "suggest" to start them in suggestion mode instead, or switch later:
editor.setMode("suggest");People with the suggester role always suggest, and viewers only read and comment where their role allows. The mode never grants more than the role.
Wire it to your app
Section titled “Wire it to your app”The editor tells your page about the things your app owns. Listen with editor.on:
editor.on("title", ({ title }) => { // The document's name, or its first line when it has none. documentList.rename(documentId, title); document.title = `${title} · Acme Notes`;});
editor.on("open-document", ({ document: id }) => { // Someone followed a link to another of your documents. Open it your way. router.push(`/notes/${id}`);});
editor.on("mention", ({ user }) => { // A name was clicked in the text, a comment or the margin. router.push(`/people/${user}`);});
editor.on("save-state", ({ state, unsaved }) => { savingIndicator.show(state); // "saving", "saved" or "offline" window.onbeforeunload = unsaved ? () => true : null;});
editor.on("seal", ({ code, url, line }) => { // The owner sealed the document from the editor's Seal button. toast(`Sealed: ${url}`);});
editor.on("error", ({ code, message }) => { // For example "no_access" after the user was removed from the document. if (code === "no_access") router.push("/notes");});You can also drive the editor from your own buttons:
editor.focus();editor.print(); // the editor's own print layout, typeset for PDFconst markdown = await editor.export("markdown"); // or "html", "docx"editor.openBreakdown(); // the seal's breakdown: where the words came from, sittings, check-inseditor.openSeal(); // the owner's seal dialogEvery option, event and method is in the embed API reference.
What the editor doesn’t do
Section titled “What the editor doesn’t do”The iframe is what makes the record trustworthy, so some things stay inside it:
- No custom content. You can’t add your own block types, or put your own components inside the text. Links, images, tables, checklists and code are built in.
- No styling beyond the options. Your CSS doesn’t reach in, and the editor doesn’t read your fonts.
- No reaching into the page. Your scripts can’t read the editor’s DOM or send it keystrokes. Text your page puts in by other means, such as through the clipboard, counts as pasted.
- No cookies. Browsers wall off storage inside third-party frames, so everything rides on the session token. A user who is signed in to your app is signed in to the editor; nothing else is needed.
- No document list. The editor shows one document. Lists, folders and search across documents are yours to build, with
GET /v1/users/{user_id}/documents.
- Share documents with members and links.
- Connect agents so your users can bring Claude or Codex.
- Seal and verify when a document is done.