This is the abridged developer documentation for Proof API # Quickstart > From an empty Node project to a document written in the embedded editor and sealed, using test keys. By the end of this page you’ll have a small Node app that signs nobody in, creates a document for one test user, opens it in the Proof editor, and seals it. It takes about ten minutes. Everything runs in test mode, so nothing you make here is public, and seals are marked as test seals. You need Node 20 or later and a Proof console account. 1. **Get a test key.** In the [console](https://developer.proof.exalto.ai/console), create a platform. Call it anything; we’ll use Acme Notes, with the slug `acme`. Copy its test secret key, which starts with `sk_test_`, and keep it on your server only. ```bash export PROOF_SECRET_KEY=sk_test_51Hc… ``` Test mode lets the editor load on any `localhost` address, so you don’t need to set embed origins yet. 2. **Install the SDKs.** `@exalto/proof` is for your server; `@exalto/proof-embed` mounts the editor in the browser. ```bash mkdir acme-notes && cd acme-notes npm init -y && npm pkg set type=module npm install @exalto/proof @exalto/proof-embed express ``` 3. **Create a user and a document.** Proof has no accounts. You tell it who your users are under your own ids, and it gives each one a Proof id and stores a name to show in the editor and on seals. setup.js ```ts import Proof from "@exalto/proof"; const proof = new Proof(process.env.PROOF_SECRET_KEY); const ada = await proof.users.upsert("u_42", { name: "Ada Lovelace" }); // u_42 is your id for Ada const doc = await proof.documents.create({ owner: ada.id, name: "Notes on the Engine" }); console.log(ada.id, doc.id); ``` ```bash node setup.js # usr_5Kq2Lm8R doc_8Hq2Lm4R ``` In a real app you’d store `ada.id` beside your own user, as you would a Stripe customer id. 4. **Add a session route.** The browser never sees your secret key. Instead, your server starts an editor session for one user and one document, and hands the page its token, which lasts 15 minutes. In a real app you’d check who is signed in first; here everyone is Ada. server.js ```ts import express from "express"; import Proof from "@exalto/proof"; const proof = new Proof(process.env.PROOF_SECRET_KEY); const app = express(); app.post("/proof/session", express.json(), async (req, res) => { // Your own sign-in check goes here; it would give you the user's stored Proof id. const ada = "usr_5Kq2Lm8R"; // the id setup.js printed const session = await proof.sessions.create({ user: ada, document: req.body.document }); res.json({ token: session.token }); }); app.use(express.static("public")); app.listen(3000); ``` 5. **Mount the editor.** The editor fills the element you give it and asks `getToken` for a new token whenever the old one is about to lapse. public/index.html ```html Acme Notes
``` Run `node server.js` and open `http://localhost:3000`. Write a few paragraphs. Paste a sentence from somewhere else, too. The seal in the editor’s corner fills in as you go: graphite for what you typed, grey for what you pasted. 6. **Seal it.** Use the editor’s Seal button, or seal from your server: seal.js ```ts import Proof from "@exalto/proof"; const proof = new Proof(process.env.PROOF_SECRET_KEY); const seal = await proof.seals.create("doc_8Hq2Lm4R", { user: "usr_5Kq2Lm8R" }); console.log(seal.line); // proof.exalto.ai/TQ81KD2MA · 91% typed/shaped · 0% AI · 9% external · 0 prompts · 1 session, 6min ``` Open `seal.url`. That is what anyone you share it with sees: the seal, where the words came from, the session on a timeline, and a note that Acme Notes vouches for who Ada is. ## What you built [Section titled “What you built”](#what-you-built) Your server owns who Ada is and which documents Ada may open. Proof owns the editor and the record of what happened in it. The only thing that crossed from your server to the browser was a token for one document, good for 15 minutes. ## Next [Section titled “Next”](#next) * [Embed the editor](/guides/embed-the-editor/) properly: your own sign-in check, appearance, and events back from the editor. * [Share documents](/guides/share-documents/) between your users, with roles and link access. * [Connect agents](/guides/connect-agents/) so your users can bring Claude Code, Codex or any MCP client. * Read the [trust model](/concepts/trust-model/) before you tell your users what a seal proves. # How Proof works > What runs where when you build on Proof, the five connections between your product and Proof, and who owns what. Proof records how a document was written: who typed which words, what agents wrote, what was pasted, and whether a person was there. To do that it needs to be the one watching the writing. So the editor is Proof’s own, served from Proof’s domain and embedded in your page. Everything around it stays yours: accounts, sign-in, document lists, sharing screens and notifications. ## The five connections [Section titled “The five connections”](#the-five-connections) 1. **Your server calls the REST API** with your secret key. It tells Proof who your users are, creates documents, sets who may open them, starts editor sessions, approves agents and seals documents. See the [REST API reference](/reference/api/overview/). 2. **Your page mounts the editor** with the embed script. You pass a short-lived session token and a few options, and you hear events back: the title changed, someone followed a link to another document, a seal was made. See the [Embed API](/reference/embed/). 3. **The editor talks to Proof directly.** Every change goes from Proof’s code, on Proof’s domain, to Proof’s servers. Your page can’t see inside the iframe or type into it, and that is what lets a seal say where words came from. See the [trust model](/concepts/trust-model/). 4. **Proof sends webhooks to your server** when something happens that your users should hear about: a comment, a reply, a mention, a new seal, an agent connected. You turn them into your own notifications. See [Webhook events](/reference/webhooks/). 5. **Your users’ agents connect to Proof’s MCP server** at your platform’s own address. When someone connects one, Proof sends them to your consent page to approve it, then issues the agent’s tokens itself. See [Connect agents](/guides/connect-agents/). ## Who owns what [Section titled “Who owns what”](#who-owns-what) | | Proof | Your product | | ----------------------------- | ---------------------------------------------- | -------------------------------------- | | Accounts and sign-in | No accounts; a Proof id for each of your users | Yours | | Names and pictures | Shown in the editor and on seals | You send them, and vouch for them | | Who may open a document | Stored and enforced | You decide, and set it through the API | | Document text and its history | Stored | Read it through the API or exports | | Where each word came from | Recorded and classified | Read it through the API | | Check-ins and security keys | Stored, on Proof’s domain | You open the settings window | | Seals and seal pages | Made and served | You trigger them or hear about them | | Agent connections and tokens | Issued and enforced | You approve, list and revoke them | | Document lists, folders, pins | A user’s documents, by id | Your screens, your folders | | Notifications and email | Webhooks | Yours to send | ## From sign-in to seal [Section titled “From sign-in to seal”](#from-sign-in-to-seal) 1. Ada signs in to Acme Notes, your product, as usual. 2. Ada opens a document. Your server checks that Ada may, asks Proof for an editor session for Ada and that document, and sends the token to the page. 3. The page mounts the editor with the token, and Ada writes. Grace, who Ada shared the document with, suggests changes in a session of Grace’s own. Ada’s Claude Code, connected to Acme Notes last week, drafts a section through MCP, credited to “Claude Code (Ada Lovelace’s agent)”. 4. If Ada turned on check-ins, the editor asks now and then for a tap of Ada’s security key. Each tap is recorded with the writing around it. 5. Ada seals the document, from the editor’s Seal button or from a button in your product that calls the API. Proof takes a snapshot of the text and its record and gives it a short address, like `proof.exalto.ai/AH271NSHS`. 6. Your server hears `seal.created`. Ada pastes the essay and its seal line wherever it is going. Anyone who opens the link sees the seal, where the words came from, and that Acme Notes vouches for who Ada is. ## What Proof doesn’t do [Section titled “What Proof doesn’t do”](#what-proof-doesnt-do) * **Sign anyone in.** Proof never sees a password or an email address unless you send one as a user’s detail line. * **Send email.** Every notification goes to you as a webhook. * **Show lists of documents.** Your product decides how documents are found, filed and ordered. Proof can list a user’s documents for you, but the screens are yours. * **Decide who may open what.** Your sharing screens make that choice. Proof stores the result and enforces it, for people and agents alike. Proof of Thought, at app.proof.exalto.ai, is built on this same API. Its accounts, sidebar, folders and email are its own, and its editor is the one you embed. # Agents > An agent is an AI client a person connected to their documents on your platform, acting for that person alone. An **agent** is an AI client, such as Claude Code, Codex or any other MCP client, that one of your users approved to work in their documents. It reads, writes, suggests and comments through Proof’s MCP server, and everything it writes is credited to it. An agent acts for exactly one person on one platform. ## Your MCP address [Section titled “Your MCP address”](#your-mcp-address) Each platform has its own MCP server address, made from your slug: ```text https://mcp.proof.exalto.ai/acme ``` In test mode it ends `/test`, and reaches your test data. You show this address in your product, usually on a settings screen with a ready-made command or install link for each kind of agent. An agent needs nothing else: from the address, it finds Proof’s sign-in, registers itself and asks its person to approve it. An agent’s tokens work only at the address they were issued for. Someone who uses two platforms built on Proof connects their agent to each separately. Agent clients keep tools from different servers apart, so the two connections sit side by side. ## Approving an agent [Section titled “Approving an agent”](#approving-an-agent) Proof has no accounts, so the approval step happens on your site: 1. The agent asks Proof to connect, and Proof sends the person’s browser to your **consent URL** with a request id. 2. Your page signs the person in if needed and fetches the request: the agent’s name and its verified domain, such as “Claude Code (claude.ai)”. 3. The person allows or denies it. Your server tells Proof which user it is, and which documents the agent may reach. 4. Proof finishes the connection and issues the agent’s tokens straight to the agent. Your platform never holds an agent’s tokens, so it can’t act as the agent, and the agent’s name comes from the agent’s own registration, not from you. [Connect agents](/guides/connect-agents/) walks through building the consent page. ## What an agent may reach [Section titled “What an agent may reach”](#what-an-agent-may-reach) An agent’s access is one of two kinds: | Access | The agent reaches | | -------- | ------------------------------------------------------------------------------------------- | | `all` | Every document its person can open, by membership or by link, editing at most. The default. | | `chosen` | Only the documents its person gave it a role in: `editor`, `suggester` or `viewer`. | Whichever it is, an agent can do at most what its person can do right now. Proof checks the lower of the agent’s grant and the person’s current role on every call, so when Ada loses access to a document, Ada’s agents lose it too. Agents never own documents and never get more than `editor`. An agent can also start documents for its person. They’re owned by the person, with the agent as an editor, and Proof sends you `document.created` so the document shows up in your lists. ## Suggesting by default [Section titled “Suggesting by default”](#suggesting-by-default) Agents are told to propose changes as suggestions unless their person asked them to edit directly. Where an agent may only suggest, every edit it makes becomes a suggestion anyway. People accept or reject each one in the editor; an accepted suggestion an agent wrote counts as [AI](/concepts/provenance/) until a person changes its words. ## Credit [Section titled “Credit”](#credit) Every word an agent writes, every comment and every read is recorded under the agent, with its person’s name beside it: ```text Claude Code (Ada Lovelace's agent) ``` The agent’s name is the one it registered with, which its person saw when approving it. The person’s name is their current one, so it updates when you rename the user. The breakdown behind a seal lists each agent’s reads and edits. ## What agents can do [Section titled “What agents can do”](#what-agents-can-do) Agents work through Proof’s tools: listing and searching documents, reading them as Markdown, editing blocks and text, accepting and rejecting suggestions, commenting, mentioning people, and handling images. The [MCP tools reference](/reference/mcp/) lists them all. Proof’s tools cover what’s inside documents. Folders, pins and anything else your product keeps for itself aren’t there. If you want agents to reach those, register tools of your own, which Proof’s server lists beside its own and forwards to your server; see [Add your own agent tools](/guides/your-own-agent-tools/). When an agent and a person write in the same document at once, people’s edits always go first. Each agent request gets a short slice of work at a time, so a burst of agent edits never holds typing back. ## Removing an agent [Section titled “Removing an agent”](#removing-an-agent) `DELETE /v1/agents/{agent_id}` removes an agent and revokes its tokens at once. Its words stay in documents, still credited to it. People usually do this from a list of their agents in your settings, which `GET /v1/users/{user_id}/agents` provides. Proof sends `agent.connected` and `agent.revoked`, so the list stays current however an agent arrives or leaves. Deleting a user revokes their agents. [Merging](/concepts/users/) a user moves their agents to the user they join. ## Related [Section titled “Related”](#related) * [Connect agents](/guides/connect-agents/) * [Agents reference](/reference/api/agents/) and [Agent requests reference](/reference/api/agent-requests/) * [MCP tools](/reference/mcp/) # Documents and access > What a document holds, who may do what in it, and how people get into the editor. A **document** is one piece of writing and its whole record: the text, its images, comments and suggestions, who wrote each word, and the seals made from it. Proof stores all of that. Your product decides who can reach which document, and Proof enforces it in the editor and for agents. ## What Proof keeps and what you keep [Section titled “What Proof keeps and what you keep”](#what-proof-keeps-and-what-you-keep) Proof keeps what has to be on the record, and nothing about how your product is organised. | Proof keeps | You keep | | ------------------------------------------ | -------------------------------------------------------------- | | The text, images, comments and suggestions | Folders, pins, favourites and ordering | | The document’s name | Your document list and search screens | | Members, their roles, and link access | Invitations, share dialogs and emails | | Who wrote what, presence and check-ins | Notifications (Proof sends you [events](/reference/webhooks/)) | | Seals | Anything else your product shows around the editor | `GET /v1/users/{user_id}/documents` lists the documents a user is a member of, most recently edited first. Most platforms show that list with their own folders and pins laid over it, keyed by document id. ## Names and titles [Section titled “Names and titles”](#names-and-titles) A document’s **name** is set by an editor, in the editor or with `PATCH /v1/documents/{id}`. Its **title** is what to show: the name if it has one, otherwise the document’s first line. Show `title` in your lists and it stays right as people write. ## Roles [Section titled “Roles”](#roles) Each member has one role. Proof checks it on every change, in the editor and over MCP. | Role | May | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `owner` | Everything an editor may, and seal, withdraw seals, choose who reads its seals, trash and delete. A document has one or more owners, and always at least one. | | `editor` | Write, rename, accept and reject suggestions, comment. | | `suggester` | Propose changes as suggestions, and comment. Their edits never change the text until an editor accepts them. | | `viewer` | Read, and see comments. | You set roles with `PUT /v1/documents/{id}/members/{user_id}`. Setting someone’s role to `owner` makes them an owner too; to hand a document over, then lower the old owner’s role. Lowering or removing the last owner is refused. A role change reaches open editors at once: someone made a viewer reloads unable to type, and someone removed sees the document close. Agents act with at most `editor`, never `owner`, and never more than their person’s current role. See [Agents](/concepts/agents/). ## Link access [Section titled “Link access”](#link-access) A document can also be open to anyone you send through its link. `link_access` is one of: | Value | People who come by link get | | ------------ | ------------------------------------------------------- | | `restricted` | Nothing. Only members can open it. This is the default. | | `view` | `viewer` | | `suggest` | `suggester` | | `edit` | `editor` | Proof doesn’t know your document URLs, so “came by link” is your call: when someone who isn’t a member opens your page for a document, start their session with `via: "link"`. They get the link’s role, or are refused if access is `restricted`. A member always gets the higher of their own role and the link’s. People who come by link are still users. Give them an id and a name first, even a made-up one for someone signed out; see [Anonymous users](/guides/anonymous-users/). ## Sessions are the only way in [Section titled “Sessions are the only way in”](#sessions-are-the-only-way-in) Nobody opens a document in the editor without a **session**: a token your server asks for with `POST /v1/sessions`, naming one user and one document. Proof works out the user’s role when it issues the token and refuses one for a user who may not open the document. The token lasts 15 minutes; the embedded editor asks your page for a new one before it lapses, so your server can refuse when access has ended. Because a session is for one user and one document, a token that leaks from a page reaches only that document, as that person, for a few minutes. See [Embed the editor](/guides/embed-the-editor/). ## Trash and deletion [Section titled “Trash and deletion”](#trash-and-deletion) * **Trash.** An owner, or their agent, can move a document to the trash for everyone. It disappears from everyone’s lists, agents can’t reach it, its seals are down, and only its owners can still open it, to read. `POST /v1/documents/{id}/restore` brings it back. After 30 days in the trash it is deleted. Another member who trashes it puts it in their own trash: it leaves only their list, and after 30 days they leave the document. * **Delete.** `DELETE /v1/documents/{id}` deletes it for everyone at once, agents included. Open editors close, its images are deleted, and its seals are withdrawn: their pages say so. Deleting can’t be undone. Proof sends `document.trashed`, `document.restored` and `document.deleted` events for trashing for everyone, restoring and deleting, so your lists stay in step when an agent does it. ## Images [Section titled “Images”](#images) People drop or paste images into the text. Proof resizes each to fit 1.9 MB in the browser, stores up to 100 MB of images per document, and serves them only to people who may open the document. Deleting the document deletes them. ## Related [Section titled “Related”](#related) * [Share documents](/guides/share-documents/) * [Documents reference](/reference/api/documents/) and [Members reference](/reference/api/members/) * [Sessions reference](/reference/api/sessions/) # Platforms and keys > A platform is your product as Proof knows it, set up in the console and reached with secret keys. A **platform** is your product as Proof knows it. Everything you create through the API belongs to one platform: its users, documents, agents, seals and webhook endpoints. Platforms never see each other’s data. A user `usr_5Kq2Lm8R` on Acme Notes and a user `usr_5Kq2Lm8R` on another platform are two unrelated people. Proof of Thought, our own app, is a platform too. It uses the same API and the same keys you do. ## Console settings [Section titled “Console settings”](#console-settings) You create a platform in the [console](https://developer.proof.exalto.ai/console). Its settings decide how Proof presents and protects your product. | Setting | What it does | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Name | Shown wherever Proof speaks for you, most visibly on seal pages: “Written in Acme Notes”. | | Slug | A short, permanent id, such as `acme`. It is part of your MCP address, `https://mcp.proof.exalto.ai/acme`, so pick it once. | | Embed origins | The origins allowed to show the editor, such as `https://acme.com`. The editor refuses to load in a frame anywhere else. | | Consent URL | The page on your site where people approve an agent, such as `https://acme.com/proof/connect`. See [Agents](/concepts/agents/). | | Reader URL | The page on your site where readers of private seals sign in. See [Seals](/concepts/seals/#who-reads-it). | | Document URL | Where your product shows a document, such as `https://acme.com/docs/{document}`, for links between documents and in agents’ answers. | | Agent tools | Tools of your own that agents get beside Proof’s. See [Add your own agent tools](/guides/your-own-agent-tools/). | | Webhook endpoints | Where Proof sends events. You can also manage these [through the API](/reference/api/webhook-endpoints/). | | Keys | Your secret keys, for test and live mode. | Everything but the slug and keys can be set from your server too, with [`PATCH /v1/platform`](/reference/api/platform/) and `PUT /v1/platform/tools`. The consent, reader and document URLs and the agent tools are per mode. ## Secret keys [Section titled “Secret keys”](#secret-keys) Your server calls the API with a secret key in the `Authorization` header: ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R \ -H "Authorization: Bearer sk_test_51Hx…" ``` A secret key can do anything on your platform, including start an editor session as any of your users. Keep it on your server. The browser never needs it: it gets a short-lived [session token](/reference/api/sessions/) for one document instead. Every key has a public id, such as `key_3Fq9`, shown beside it in the console. Proof records which key started each writing session, and a seal lists the key ids behind it in `attested_by`. If a key ever leaks, that record tells you and anyone checking a seal which sessions it could have touched. The [trust model](/concepts/trust-model/) explains why this matters. ## Test and live mode [Section titled “Test and live mode”](#test-and-live-mode) Each platform has two sets of keys and two separate sets of data. * **Test keys** (`sk_test_…`) see only test objects. Use them in development and CI. Test seals get codes beginning with `T`, and their pages say “Test seal”, so they can’t be passed off as real. * **Live keys** (`sk_live_…`) see only live objects. Live seals are the ones your users share. Nothing moves between the two modes. A document created with a test key can’t be opened with a live key, and the same `external_id` in each mode is two separate users, with different Proof ids. Webhook endpoints are set per mode too. ## Rolling a key [Section titled “Rolling a key”](#rolling-a-key) Roll a key in the console when someone who held it leaves, or on a schedule. Rolling creates a new key and lets you keep the old one working for up to 7 days while you deploy the new one. Revoking ends the old key at once. When you revoke a key because it leaked, every request with it stops at once. Seals record the ids of the keys that started their writing sessions, so you can find every seal it had a hand in. ## Related [Section titled “Related”](#related) * [Test and go live](/guides/test-and-go-live/) * [REST API overview](/reference/api/overview/): authentication, errors, limits and versions * [Trust model](/concepts/trust-model/) # Presence and check-ins > How Proof measures whether a person was there while a document was written, and how security-key check-ins verify it. [Provenance](/concepts/provenance/) says where words came from. **Presence** says whether a person was there while they were written. A seal carries both: the outer ring is provenance, the inner ring is presence. ## Presence signals [Section titled “Presence signals”](#presence-signals) While a person has a document open and the page is visible, the editor reports every half minute whether they typed or pointed in it since the last report. These come from events the browser itself sent, not from anything your page can trigger: the editor runs on Proof’s own origin, and your page can’t script it. Proof keeps one record a minute for each person. Nothing here records what people type outside the editor, their screen, or their camera. Presence is about attention to this document, nothing more. ## Sittings and the session [Section titled “Sittings and the session”](#sittings-and-the-session) A **sitting** is a stretch of work on a document with no gap longer than 30 minutes. A document’s **session** is all the minutes in which anyone edited it, or someone writing in it was present. The seal line reports sittings and their total time: `2 sessions, 1hr48min`. ## The Presence share [Section titled “The Presence share”](#the-presence-share) **Presence** is the share of the session with a person present: editing or moving in the editor, and the five minutes after, which covers reading back and thinking, in sittings where they edited. Minutes when only an agent was working, or when nobody was looking, aren’t present. A sitting someone only read in counts neither way: reading alone isn’t writing, and it doesn’t count against anyone. ## Check-ins [Section titled “Check-ins”](#check-ins) Presence signals show that someone was at the editor. **Check-ins** show it was someone with a particular security key, and they are the one signal neither a script nor a platform can produce. People add security keys and choose an interval in presence settings, which run on Proof’s own domain (`embed.proof.exalto.ai`) so that keys are registered to Proof, not to any one platform. You frame them in your settings with `mountPresenceSettings()`, or open them in a window with `openPresenceSettings()`; see [Set up check-ins](/guides/set-up-check-ins/). | Interval | Meaning | | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | Never | No check-ins. The default. | | About every 1, 5, 10 or 30 minutes | Proof asks for a tap at random times between half and one and a half intervals apart, while they write. | How asks work: * The first ask comes with their first edit after half an hour away from writing anywhere on your platform; the next at a random time within the interval. * Only editing asks. Reading, pointing and clicking ask nothing; accepting or rejecting a suggestion is an edit, so it can. * A card at the foot of the editor asks for a tap. It’s answered within a minute or recorded as missed. Nothing stops them writing either way. * Check-ins belong to the person, not to a document. One tap vouches for their writing in every document they work in, and a miss counts against all of them. Your users can also set the interval through your own settings screen with `PUT /v1/users/{user_id}/checkins`, once they have a key. Adding keys always happens in Proof’s presence settings. ## Key tiers [Section titled “Key tiers”](#key-tiers) When someone adds a key, Proof sorts it into a tier and keeps the tier with each check-in it answers: | Tier | Keys | | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | Attested | Hardware that proves its model is on the FIDO Metadata Service’s list: security keys such as a YubiKey, Windows Hello on a TPM. | | Claimed | Built-in authenticators that send no proof of their model, such as Touch ID through iCloud Keychain or Google Password Manager. | | Refused | Third-party password managers and unknown software. These can’t be added. | The tier is kept as data. Today it doesn’t change the seal; a later method could read it. ## Verified Presence [Section titled “Verified Presence”](#verified-presence) Check-ins turn presence into **Verified Presence**, one sitting at a time. A person’s sitting is verified when: * every minute they edited in it came soon enough after a check-in they passed (within roughly one and a half of their intervals), and * no check-in they left unanswered was followed by more of their edits. Then their writing in that sitting, and their reading within five minutes of it, is verified. So: * a miss costs only the sittings they went on writing in soon after it; * reading long after they stopped writing isn’t verified, however often they check in; * turning check-ins off stops vouching for what follows; * someone who never checks in simply goes unverified. Nothing counts against them. When every sitting anyone wrote in was verified, the seal shows a shield, Presence reads **Verified Presence**, and the seal line ends `(verified)`. When some was, it ends with the share, such as `(73% verified)`. ## Real browsers [Section titled “Real browsers”](#real-browsers) Each editor connection is also asked, invisibly, to prove it’s a real browser rather than a script, when it first writes and every five minutes after. Proof keeps the answers as data with the record. They aren’t shown on the seal. ## Related [Section titled “Related”](#related) * [Set up check-ins](/guides/set-up-check-ins/) * [Seals](/concepts/seals/) * [Trust model](/concepts/trust-model/) # Provenance > How Proof knows where every word in a document came from, and what the four origins mean. **Provenance** is where a document’s words came from. Proof records it as the writing happens, not by reading the finished text, so it doesn’t guess: every character that survives in the document carries who wrote it and how it got there. ## Who wrote each character [Section titled “Who wrote each character”](#who-wrote-each-character) Every change in the editor arrives at Proof from a known writer: a person in a session you started, or an agent with a token Proof issued. Proof writes the credit itself. A browser can’t claim another person’s or an agent’s words, and edits sent under someone else’s identity are refused. That credit follows the text. When someone deletes a sentence, it leaves the count; when someone else rewrites it, the new words are theirs. ## Four origins [Section titled “Four origins”](#four-origins) Each surviving word falls into exactly one of four origins. Together they make up 100% of the document. | Origin | What counts | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Typed** | Words people wrote themselves in the editor. | | **Shaped** | Text an agent wrote, in sentences a person has since changed. A person’s change to the words of a sentence makes the agent’s words around it shaped, for as long as the change lasts. | | **AI** | Text an agent wrote that people kept as written. An agent’s suggestion accepted without changes is AI. | | **External** | Text pasted in, imported from a file, or inserted with no key pressed for it, as tools that drive a browser do. Its origin is unknown, so it counts toward neither people nor agents. | Seals show Typed and Shaped together as **Typed & Shaped**, the share people wrote or reworked. The breakdown behind a seal keeps them apart. ### Suggestions [Section titled “Suggestions”](#suggestions) A suggestion’s words are its author’s from the moment it’s proposed. When an editor accepts a person’s suggestion, those words count as that person’s typing; when they accept an agent’s suggestion unchanged, they count as AI. A rejected suggestion leaves nothing behind. ## Moving words keeps their credit [Section titled “Moving words keeps their credit”](#moving-words-keeps-their-credit) Rearranging text doesn’t change who wrote it. These all keep each word’s original writer and origin: * indenting a list item, changing a block’s type, splitting or joining paragraphs, dragging text; * undo, which puts back what was there; * cutting text and pasting it back, or an agent deleting a block and inserting it again; * copying several whole words from elsewhere in the same document: a copied agent paragraph is still AI, a copied pasted quote still external. Moved words count as neither written nor deleted in the activity below. ## Activity [Section titled “Activity”](#activity) Surviving text can’t show everything, so Proof also records what happened along the way: * **Revisions:** who deleted whose text. * **Rounds:** each back-and-forth between agents and people. The seal line counts these as prompts. * **Minutes:** when people were writing or reading, which [presence](/concepts/presence-and-check-ins/) is built from. * **Traces:** every read and edit an agent made. ## What provenance can’t tell [Section titled “What provenance can’t tell”](#what-provenance-cant-tell) Provenance records how text arrived in the editor. It can’t know what was in someone’s head or on another screen: a person retyping an AI answer by hand is typing. That’s why a seal carries [presence and check-ins](/concepts/presence-and-check-ins/) beside the four origins, and why it has no single score. Readers see each part and judge for themselves. ## Reading it through the API [Section titled “Reading it through the API”](#reading-it-through-the-api) `GET /v1/documents/{id}/provenance` returns the live reading under the current method: ```json { "document": "doc_8Hq2Lm4R", "method": "v0.2", "composition": { "typed": 71.4, "shaped": 15.9, "ai": 7.8, "external": 4.9 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min" } ``` Each person’s and agent’s part is in the editor’s breakdown and on seal pages. A [seal](/concepts/seals/) freezes these numbers, rounded, at the moment of sealing. ## Related [Section titled “Related”](#related) * [Seals](/concepts/seals/) * [Presence and check-ins](/concepts/presence-and-check-ins/) * [Documents reference](/reference/api/documents/) # Seals > A seal freezes a document and its record at a short link anyone can open and check. A **seal** is a snapshot of a document as it stands, with everything Proof recorded about how it was written, at a short link such as `https://proof.exalto.ai/AH271NSHS`. Your users copy the text wherever it’s going and send the link with it. Anyone can open the link and see where the words came from, without an account on your platform or ours, unless the document’s owners made its seals [private](#who-reads-it). ## What sealing captures [Section titled “What sealing captures”](#what-sealing-captures) * **The text**, read-only, with its images, as it stood at that moment. A document with pending suggestions can’t be sealed until they’re accepted or rejected. * **The numbers**: the four [origins](/concepts/provenance/) as shares of the text, [Presence](/concepts/presence-and-check-ins/) and how much of it was verified, prompts, sittings and time, frozen under the method of the day. * **The record behind them**: who did what, agent reads and edits, the sittings on a timeline, and which signals were on. * **The names** people had when it was sealed. Later name changes don’t reach it. * **Who vouches for identity**: your platform’s name and the ids of the keys that started the writing sessions. An owner seals, from the editor’s Seal button or with `POST /v1/documents/{id}/seals`. Proof sends a `seal.created` event either way. ## Versions [Section titled “Versions”](#versions) Sealing never stops the writing. People keep editing after they seal, and sealing again makes a new seal with its own code and the next version number. Each seal’s link always shows the version it was made from. When a newer seal of the same document exists, the page says so and links to it. A seal can’t be changed. If the text needs fixing, edit and seal again. ## The seal line [Section titled “The seal line”](#the-seal-line) Every seal has one line of text that sums it up, made for pasting under the work it travels with: ```text proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min ``` | Part | Meaning | | ---------------------- | --------------------------------------------------------- | | `87% typed/shaped` | Typed and Shaped together: what people wrote or reworked. | | `8% AI` | Agent text kept as written. | | `5% external` | Pasted or imported text of unknown origin. | | `4 prompts` | Rounds of back-and-forth between people and agents. | | `2 sessions, 1hr48min` | Sittings, and their total time. | When check-ins verified every sitting people wrote in, the line ends `(verified)`; when they verified some, it ends with the share, such as `(73% verified)`. There is no overall score: readers see each part. The seal object returns the line ready to use, in `line`. ## The method [Section titled “The method”](#the-method) The rules that turn a record into numbers are versioned, and each seal keeps the version it was made under, such as `v0.2`. When the method improves, sealed numbers don’t change. The seal page shows a reading under the newer method beside the original, labelled as such, and `GET /v1/documents/{id}/provenance` always returns the reading under the current method. ## The seal page [Section titled “The seal page”](#the-seal-page) `proof.exalto.ai/{code}` is hosted by Proof, on our domain, so that what it shows can’t be edited by anyone who wrote the document or by the platform it was written in. It shows: * the text and the seal, with its breakdown one click away; * “Written in Acme Notes”, with your platform’s name from the console; * that Acme Notes vouches for who the writers are, and which keys started their sessions. Shared on the web, a public seal’s link unfurls into a preview image with the title, who wrote it, the seal and the seal line. ## Who reads it [Section titled “Who reads it”](#who-reads-it) A document’s owners choose, in the editor’s seal dialog, who reads its seals, every version alike: * **Anyone with the link**, the default. * **Private**: the document’s members, readers they name by email or as `*@domain`, and whoever has the password, if they set one. Readers prove who they are by signing in with your platform, which vouches for their verified email with a [reader request](/reference/api/seals/#reader-requests). A private seal has no link preview. ## Withdrawing [Section titled “Withdrawing”](#withdrawing) An owner can withdraw a seal with `DELETE /v1/seals/{code}`. Its page then says it was withdrawn, and nothing else; the link keeps answering so that anyone who has it learns why. Deleting a document withdraws all its seals. While a document is in its owners’ trash, its seals are down, and come back if it’s restored. ## Test seals [Section titled “Test seals”](#test-seals) Seals made with test keys have codes beginning with `T`, such as `TQ4R82KMW`, and their pages say “Test seal” across the top. They work like live seals in every other way, so you can build and check your flow end to end. ## The seal object [Section titled “The seal object”](#the-seal-object) ```json { "code": "AH271NSHS", "url": "https://proof.exalto.ai/AH271NSHS", "document": "doc_8Hq2Lm4R", "version": 3, "sealed_at": "2026-10-13T23:31:00Z", "sealed_by": "usr_5Kq2Lm8R", "method": "v0.2", "line": "proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min", "composition": { "typed": 71, "shaped": 16, "ai": 8, "external": 5 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min", "attested_by": { "platform": "acme", "key_ids": ["key_3Fq9"] }, "withdrawn_at": null } ``` ## Related [Section titled “Related”](#related) * [Seal and verify](/guides/seal-and-verify/) * [Seals reference](/reference/api/seals/) * [Trust model](/concepts/trust-model/) # Trust model > What a seal proves, who vouches for each part of it, and what it can't tell anyone. A seal is only worth what the party who watched the writing can’t fake. This page sets out who watches what when you build on Proof, so you can say accurately what a seal means to your users and to the people who read their work. ## Two parties, two claims [Section titled “Two parties, two claims”](#two-parties-two-claims) Every seal made on your platform rests on two claims from two parties: * **Proof observes the writing.** Every change in the editor travels from Proof’s own code, on Proof’s own domain, to Proof’s servers. Proof decides which words were typed, shaped, written by an agent or pasted, and when a person was present. * **You vouch for who wrote it.** Proof has no accounts. When your server creates a user for your id `u_42`, named Ada Lovelace, and starts editor sessions for them, Proof takes your word for it. The seal page says both. A seal made in Acme Notes reads “Written in Acme Notes”, and the people on it are named as Acme Notes named them, with a note that Acme Notes vouches for who they are. ## What Proof observes directly [Section titled “What Proof observes directly”](#what-proof-observes-directly) | Signal | How Proof knows | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Typed text | Each change arrives over the editor’s own connection, from keys pressed in the editor. | | Pasted and imported text | Text that arrives without keys pressed for it, from a paste, a drop, or a tool driving the browser, is counted as external. | | Agent text | Agents write only through Proof’s MCP server, under their own tokens, so their words carry their own credit. | | Shaped text | Words of an agent’s sentence that a person changed afterwards, while the change lasts. | | Presence | Every half minute while a writer’s editor is visible, the browser reports whether they typed or pointed there, from events the browser itself sent. | | A real browser | Each editor connection earns a Cloudflare Turnstile token when it first writes and every few minutes after. Proof keeps whether it passed. | | Check-ins | Taps of a security key registered on Proof’s domain, answered within a minute of being asked. | Your page can’t reach any of these. The editor is a cross-origin iframe: your scripts can’t read it, type into it or change what it sends. Embed origins in the console decide which sites may show it at all. ## What you vouch for [Section titled “What you vouch for”](#what-you-vouch-for) * **Identity.** Names, pictures and the detail line come from you. If your sign-in is weak, so is the name on a seal. * **Access.** Who may open, edit or suggest in a document is your decision, stored at Proof through your API calls. * **Agent approvals.** A person approves an agent on your consent page, signed in to your product. Proof records that you said this person approved it. Your secret key can start an editor session as any of your users. That is what lets you build without Proof accounts, and it’s also the main thing to protect. Each seal records which of your keys started the sessions behind it, so if a key leaks you can revoke it and find the seals whose sessions it started. ## What nobody can fake easily [Section titled “What nobody can fake easily”](#what-nobody-can-fake-easily) A check-in needs a physical tap on a security key at the moment Proof asks. A script can’t do it, and neither can your server. That makes check-ins the strongest signal on a seal: when every sitting with writing in it is covered, Presence reads Verified Presence and the seal line ends “(verified)”. A tap shows that someone with that key was at the keyboard when asked. It doesn’t show which person. Keys are registered in a window on Proof’s domain, opened with a session you start, so who registered a key rests on your sign-in too. ## What a seal can’t tell anyone [Section titled “What a seal can’t tell anyone”](#what-a-seal-cant-tell-anyone) * **Where ideas came from.** Someone retyping text from another window, by hand, is typing. A seal shows how the words got into the editor, not whose thinking they were. * **What happened outside the editor.** Drafts written elsewhere and pasted in count as external, with their origin unknown. * **Who a person is beyond your word.** See above. Seals don’t change after they’re made. Each records the version of the method that produced its numbers; when the method improves, a seal page shows the new reading beside the old one and never rewrites it. A seal can be withdrawn, and its page then says so. ## Attacks and what stops them [Section titled “Attacks and what stops them”](#attacks-and-what-stops-them) | Someone tries to | What stops it | What’s left | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Inject text from your page into the editor | The iframe is cross-origin; your page can’t script it | Nothing, short of compromising the browser | | Pass agent text off as typed | Agents write through MCP under their own credit; text inserted without key presses counts as external | Retyping by hand | | Type with a script driving a browser | No key presses means external; Turnstile results are kept | A script faking key events in a real browser; check-ins catch the absence of a person | | Start sessions as your users with your key | Seals record which key started each session | You remain the authority on identity | | Edit a document after sealing it | Seals are snapshots; a version can’t change | Sealing again makes a new version, which is shown as new | | Rescore an old seal | Each seal keeps its method version | Newer readings appear beside, labeled | ## What to tell your users [Section titled “What to tell your users”](#what-to-tell-your-users) Keep it to what is true for everyone: a seal shows where each word in the document came from and how much of the writing time a person was present, as recorded by Proof, for a person your product signed in. With check-ins on, it also shows that someone with their security key was there throughout. # Users > Users are your people; Proof gives each one its own id, keeps yours beside it, and takes your word for who they are. Proof has no accounts, no passwords and no sign-in. A **user** is one of your people. Your server tells Proof who they are, and Proof records what they do in the editor. ## Two ids [Section titled “Two ids”](#two-ids) Every user has two ids: | Id | Example | Whose | | ------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `id` | `usr_5Kq2Lm8R` | Proof’s. It never changes, and it is what documents, seals, members, sessions and webhooks use. | | `external_id` | `u_42` | Yours: whatever your product already calls this person, up to 128 characters. Unique on your platform, and you can change it. | Proof’s records refer only to its own ids, so the history of who wrote what never depends on a key you might change, and your ids, which may be emails or database keys, never end up in documents or on seals. Keep the Proof id beside your user, the way you’d keep a Stripe customer id: a `proof_user_id` column is enough. When you have only your own id, look the user up by it. ## Creating and updating [Section titled “Creating and updating”](#creating-and-updating) `PUT /v1/users/external/{external_id}` creates the user the first time you send an `external_id` and updates them after that, and returns the user with its Proof id. Call it when someone signs up, and again whenever their profile changes: ```json { "name": "Ada Lovelace", "avatar_url": "https://acme.com/avatars/42.png", "detail": "ada@acme.com" } ``` | Field | Meaning | | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `name` | How the person appears to others: beside their cursor, on comments, in the seal’s breakdown. Up to 48 characters. | | `avatar_url` | Optional. A picture Proof fetches once and serves itself: PNG, JPEG or WebP, at most 1 MB. | | `detail` | Optional. A second line under the name in mention lists, such as an email or a team, to tell two people with the same name apart. | A user needs a name before they can open the editor. See the [Users reference](/reference/api/users/) for every field. ## Changing your ids [Section titled “Changing your ids”](#changing-your-ids) If your ids change, for example in a migration or when someone switches login method, point the Proof user at the new one with `PATCH /v1/users/{id}` and a new `external_id`. Nothing else moves: documents, seals and agents follow the Proof id. ## You vouch for who people are [Section titled “You vouch for who people are”](#you-vouch-for-who-people-are) Proof takes your word for a user’s identity. When Ada’s seal says “Ada Lovelace”, it means “Acme Notes says this is Ada Lovelace”, and the seal page says so. Proof can vouch for what happened in the editor; only you know who was signed in. The [trust model](/concepts/trust-model/) covers what each side vouches for. ## Name changes [Section titled “Name changes”](#name-changes) Documents name people by their Proof id, not by the name they had when they wrote. When you change a user’s name or picture, every open editor shows the new one at once, and so do comments, the breakdown and agents’ reads. Nothing in the documents is rewritten. Seals are the exception: a seal is a snapshot, so it keeps the name each person had when it was sealed. ## Deleting a user [Section titled “Deleting a user”](#deleting-a-user) `DELETE /v1/users/{id}` removes a user from your platform. * Documents they wrote in keep their last name, without the picture, so what they wrote stays credited. * Documents they owned pass to their other members with the highest role; one with no other member, and nobody else who wrote in it, is deleted, with its seals. * Their agents are revoked at once. * Their security keys and check-in history go with them. Their `external_id` is free again afterwards. Sending it to `PUT /v1/users/external/{external_id}` creates a new person with a new Proof id: nothing from before comes back. ## Merging users [Section titled “Merging users”](#merging-users) Some products let people write before they have an account, then sign up later. `POST /v1/users/{id}/merge` moves everything from one user to another: * their documents and memberships, * the credit for everything they wrote, * their security keys and check-ins, and their check-in interval unless the user they join has one, * their agents; one for a client the user they join already connected joins that agent. The first user is deleted afterwards. See [Anonymous users](/guides/anonymous-users/) for the whole flow. ## Related [Section titled “Related”](#related) * [Users reference](/reference/api/users/) * [Documents and access](/concepts/documents-and-access/) * [Anonymous users](/guides/anonymous-users/) # Build with an agent > Give Claude Code, Codex or another coding agent these docs and a prompt that adds Proof to your app. These docs are written to be read by coding agents as well as people. Every page is plain Markdown at its own address plus `.md`, and the whole set is gathered in a few files agents can fetch in one go. | File | What’s in it | | ------------------------------------ | ------------------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | The map: what Proof is, and links to the sets below | | [`/llms-full.txt`](/llms-full.txt) | Every page, in full | | [`/llms-small.txt`](/llms-small.txt) | Every page, with examples and asides trimmed | | One file per section | Concepts, guides, the REST API, and the rest of the reference, linked from `llms.txt` | | `/.md` | One page, e.g. [`/quickstart.md`](/quickstart.md) | Each page also has a **Copy as Markdown** button under its title, for pasting one page into a chat. ## A prompt that adds Proof to your app [Section titled “A prompt that adds Proof to your app”](#a-prompt-that-adds-proof-to-your-app) Paste this into Claude Code, Codex or your agent of choice from the root of your project. Put a test key in your environment first, so the agent never needs to see it. prompt.txt ```text Add the Proof of Thought editor to this app using the Proof API. Read https://developer.proof.exalto.ai/llms-full.txt first. Then: 1. Install @exalto/proof (server) and @exalto/proof-embed (browser). 2. Keep every Proof call on the server, with the key from PROOF_SECRET_KEY. Never send the key to the browser. 3. Add a proof_user_id column to our users. When a user signs up or changes their name or picture, PUT them to /v1/users/external/{our user id} and store the Proof id it returns. 4. Store a Proof document id beside each of our documents. Create the Proof document when ours is created, with our user's Proof id as owner. 5. Add a POST route that checks our own session and permissions, then creates a Proof editor session for that user and document and returns the token. 6. Mount the editor where we show a document, with getToken calling that route. 7. Add a webhook route that verifies the Proof-Signature header and logs events. Use our existing auth, routing and styles. Show me the plan before writing code. ``` Adjust step 4 to how your app stores documents. If your app already has sharing, ask the agent to mirror it into [members](/reference/api/members/) as well. ## Keep the docs in reach [Section titled “Keep the docs in reach”](#keep-the-docs-in-reach) Add a line to the instructions file your agent reads (`CLAUDE.md`, `AGENTS.md` or similar), so it checks the docs instead of guessing: AGENTS.md ```md ## Proof We embed the Proof of Thought editor. Before changing anything that calls the Proof API, read the relevant page from https://developer.proof.exalto.ai/llms.txt (each page is also at its path plus .md). Use test keys (sk_test_) locally; never log or expose PROOF_SECRET_KEY. ``` ## Things agents tend to get wrong [Section titled “Things agents tend to get wrong”](#things-agents-tend-to-get-wrong) * **Calling Proof from the browser.** Everything but the editor itself goes through your server. The browser only gets editor session tokens. * **Treating Proof as the account system.** Proof has no sign-in. Your session check comes first, then the Proof call. * **Passing your own ids where Proof ids go.** Your id for a user goes in one place, `PUT /v1/users/external/{external_id}`. Everything else takes the Proof id (`usr_…`) that call returns, so store it beside your user. * **Skipping signature checks on webhooks.** Verify `Proof-Signature` before trusting an event. The Node SDK’s `proof.webhooks.verify` does it in one call. * **Mixing up the two MCP servers.** Your users’ agents connect to `mcp.proof.exalto.ai/` to work in documents. That is a feature for your users, not a tool for building your app. # Changelog > What changed in the Proof API, the embed script, the Node SDK and the MCP server. Changes to `/v1` are additive: new endpoints, new optional parameters, new fields in responses and new event types. Your code should ignore fields and event types it doesn’t know. A change that would break existing code ships as a new version, with at least 12 months in which both work. See [Versioning](/reference/api/overview/#versioning). ## v1 [Section titled “v1”](#v1) The first release of the Proof API. * **REST API** at `api.proof.exalto.ai/v1`: users, documents, members, sessions, agents, agent requests, seals and reader requests, webhook endpoints, and platform settings with your own agent tools, in test and live mode. * **Embed script** `v1.js` and `@exalto/proof-embed` 1.0: the editor with suggestions, comments, the live seal and check-ins, and presence settings, framed or in a window. * **Node SDK** `@exalto/proof` 1.0. * **MCP server** at `mcp.proof.exalto.ai/` (`//test` in test mode) for each platform, with the same document, editing, suggestion and comment tools as Proof of Thought’s own agents, and your own tools beside them. * **Webhooks** for threads, mentions, documents, seals and agents, signed with `Proof-Signature`. Suggestion and missed check-in events are reserved, not yet sent. * **Seal method** v0.2, the same method Proof of Thought seals with. # Anonymous users > Let people write before they sign up, then carry their documents, credit, keys and agents over to their account when they do. Many apps let people start writing before they make an account, or let a visitor who opened a shared link comment without signing up. Proof doesn’t mind who your users are, only that you give each one an id of yours, so an anonymous visitor is just another user to Proof. By the end of this guide, visitors can write straight away, and everything they did moves to their account when they sign up. ## Give visitors a user [Section titled “Give visitors a user”](#give-visitors-a-user) 1. **Make an id when a visitor first needs one**, such as when they start a document or open a shared link, and keep it in your own session. It becomes the user’s `external_id` at Proof. Any string up to 128 characters works; a prefix makes these easy to spot. session.ts ```ts async function currentUser(req) { if (req.user) return req.user; if (!req.session.anonymousId) { req.session.anonymousId = `anon_${crypto.randomUUID()}`; req.session.anonymousName = randomName(); // "Brisk Heron" } return { id: req.session.anonymousId, name: req.session.anonymousName, anonymous: true }; } ``` 2. **Create the user in Proof** with a name that reads well in the margin and in comments, and keep the Proof id in the session beside your own. A made-up name is friendlier than “Anonymous”, and lets people tell two visitors apart. ```ts const visitor = await currentUser(req); if (!req.session.proofUserId) { const proofUser = await proof.users.upsert(visitor.id, { name: visitor.name }); req.session.proofUserId = proofUser.id; } ``` 3. **Use them like any other user.** Create documents they own, start editor sessions, and start link sessions for shared documents. Everything they write is credited to their made-up name. Anonymous users can do everything signed-in users can in Proof: write, comment, add security keys, connect agents and seal. Which of those your app allows before sign-up is up to you. ## Merge when they sign up [Section titled “Merge when they sign up”](#merge-when-they-sign-up) When a visitor signs up or signs in, merge their anonymous user into their account: sign-in.ts ```ts app.post("/sign-in/complete", async (req, res) => { const account = await finishSignIn(req); // your own sign-in if (req.session.proofUserId) { // proofUserId: the helper from Embed the editor, which creates the account's Proof user if needed. await proof.users.merge(req.session.proofUserId, { into: await proofUserId(account) }); delete req.session.anonymousId; delete req.session.proofUserId; } res.redirect("/notes"); }); ``` Merging moves, from the anonymous user to the account: * **Documents and memberships.** Where both were members of the same document, the account keeps the higher role. * **Credit.** Everything the visitor wrote, commented and suggested now shows under the account’s name, in open editors too. * **Security keys** and their check-ins, and the check-in interval unless the account already has one. * **Agents**, still reaching the same documents. One for a client the account already connected joins that agent. The anonymous user is then gone: its Proof id answers `404`, and its `external_id` is free. Existing seals keep the name they were sealed with, as seals always do. Merge before you start the next editor session. An editor still open as the anonymous user loses its session when the merge completes and asks your page for a new token; once your session route starts sessions as the account, it picks up where it left off. ## Clean up visitors who never come back [Section titled “Clean up visitors who never come back”](#clean-up-visitors-who-never-come-back) Anonymous users stay until you delete them. Proof of Thought removes anonymous accounts 30 days after their last visit, by the Proof id it kept for each: ```ts await proof.users.delete("usr_2Hd7Np4Q"); ``` Deleting a user deletes documents nobody else is a member of or wrote in, with their seals. In documents shared with others, what the visitor wrote keeps the name they last had, without a picture. ## Next [Section titled “Next”](#next) * [Users](/concepts/users/): how Proof names people. * [Users](/reference/api/users/) in the API reference, including `merge`. # Connect agents > Let your users connect Claude, Codex or any MCP client to their documents, with your sign-in on the consent page and Proof crediting every word the agent writes. Your users can bring their own AI agents to the documents they keep in your app. An agent reads, suggests, edits and comments through Proof’s MCP server, and everything it writes is credited to it on the record and in the seal. You build two things: a page that tells people how to connect an agent, and a consent page where they approve one. Proof does the rest. It runs the MCP server and the OAuth sign-in, issues the agent’s tokens, and checks what the agent may do on every call. Your server never holds an agent’s token, which is why an agent’s words can’t be passed off as a person’s, or a person’s as an agent’s. ## How connecting works [Section titled “How connecting works”](#how-connecting-works) Each platform has its own MCP address. For Acme Notes it is: ```plaintext https://mcp.proof.exalto.ai/acme ``` In test mode it is `https://mcp.proof.exalto.ai/acme/test`, with your test consent URL. Your server can read the address for the key’s mode as `mcp_url` from [`GET /v1/platform`](/reference/api/platform/). When someone adds that address to their agent: 1. The agent calls Proof, learns it needs to sign in, and registers itself with Proof’s OAuth server. Proof checks where the agent comes from: its **host**, such as `claude.ai`, which the agent can’t claim falsely. 2. The agent opens the person’s browser at Proof, which sends it straight on to your **consent URL** with a request id: `https://acme.com/proof/connect?request=areq_5tNc81`. 3. Your consent page signs the person in, as any page of your app would, and shows which agent is asking. 4. When they click **Allow**, your server approves the request with your secret key, naming the signed-in user. Proof answers with a URL, and you send the browser there. 5. Proof finishes the sign-in and gives the agent its tokens. From then on the agent talks only to Proof, as your user’s agent. Tokens for one platform’s address don’t work on another’s. Someone who uses two platforms built on Proof connects their agent to each separately, and the agent keeps the two apart. ## Set your consent URL [Section titled “Set your consent URL”](#set-your-consent-url) In the console, under **Agents**, or with [`PATCH /v1/platform`](/reference/api/platform/), enter the URL of your consent page, such as `https://acme.com/proof/connect`, for each mode. Until you set one, Proof answers every connection attempt with an error telling the person that this platform doesn’t accept agents yet. ## Show people how to connect [Section titled “Show people how to connect”](#show-people-how-to-connect) Give your settings an Agents section that shows your MCP address and how to add it to the agents people use most. The address is the same for all your users; who they are is settled on the consent page. * Claude Code ```bash claude mcp add --transport http acme https://mcp.proof.exalto.ai/acme ``` Then run `/mcp` inside Claude Code and choose **acme** to sign in. * Codex ```bash codex mcp add acme --url https://mcp.proof.exalto.ai/acme codex mcp login acme ``` * VS Code ```bash code --add-mcp '{"name":"acme","type":"http","url":"https://mcp.proof.exalto.ai/acme"}' ``` VS Code asks to sign in the first time the agent uses the server. A `vscode:mcp/install?…` link with the same JSON does this in one click from your settings page. * Gemini CLI ```bash gemini mcp add --transport http acme https://mcp.proof.exalto.ai/acme ``` Then run `/mcp auth acme` inside Gemini CLI to sign in. * Other clients Any client that speaks MCP over Streamable HTTP with OAuth works. Add a server with the URL `https://mcp.proof.exalto.ai/acme`; the client finds the sign-in on its own. In every case, signing in opens the person’s browser on your consent page. Name the server after your product (`acme` above). Agents show tools grouped by server, so your users see `acme` rather than a name they don’t recognise. ## Build the consent page [Section titled “Build the consent page”](#build-the-consent-page) The consent page is a normal page in your app, behind your normal sign-in. 1. **Look up the request.** Proof tells you which agent is asking. `client.host` is verified; `client.name` is what the agent calls itself. connect.ts ```ts app.get("/proof/connect", requireSignIn, async (req, res) => { const request = await proof.agentRequests.retrieve(String(req.query.request)); const documents = await proof.users.listDocuments(req.user.proofUserId); res.render("connect-agent", { requestId: request.id, agentName: request.client.name, // "Claude Code" agentHost: request.client.host, // "claude.ai" documents: documents.data, }); }); ``` If the person isn’t signed in, `requireSignIn` should send them through your sign-in and back to this URL, query string included. Requests last 10 minutes; an expired one returns `409 request_expired`, and the person starts again from their agent. 2. **Ask the person.** Show the agent’s name and host together, such as “Claude Code (claude.ai) wants to work in your Acme Notes documents”, and let them choose what it reaches: * **All documents**: every document they can open, editing at most. This is what most people want. * **Chosen documents**: only the ones they pick, each as an editor, suggester or viewer. Keep it to one screen with **Allow** and **Deny**. 3. **Approve or deny on your server.** Always name the signed-in user from your session, never from the form. connect.ts ```ts app.post("/proof/connect", requireSignIn, verifyCsrf, async (req, res) => { const { requestId, decision, access, documents } = req.body; if (decision !== "allow") { const { redirect_url } = await proof.agentRequests.deny(requestId); return res.redirect(303, redirect_url); } const { redirect_url } = await proof.agentRequests.approve(requestId, { user: req.user.proofUserId, access, // "all" or "chosen" documents, // with "chosen": { "doc_8Hq2Lm4R": "suggester", ... } }); res.redirect(303, redirect_url); }); ``` 4. **Send the browser on.** The `redirect_url` returns the person to Proof, which finishes the sign-in and hands them back to their agent. The page they land on tells them they can close it. Caution Approve only from a `POST` your own page sent, with your usual cross-site request protection. A request id alone proves nothing about who is approving it: someone could send another person a link to your consent page with their own request id. Showing the verified host, and asking before approving, is what protects your users. ## Let people manage their agents [Section titled “Let people manage their agents”](#let-people-manage-their-agents) List a person’s agents in the same Agents section, with what each reaches and a way to remove it. ```ts const agents = await proof.users.listAgents("usr_5Kq2Lm8R"); // [{ id: "agt_Qe71", name: "Claude Code", host: "claude.ai", access: "all", // connected_at: "2026-10-29T16:02:11Z", last_used_at: "2026-10-30T09:14:52Z" }] ``` Show the host beside the name: two agents can share a name, but not a host. **Remove an agent.** Its tokens stop working at once, mid-task too. ```ts await proof.agents.revoke("agt_Qe71"); ``` **Narrow what it reaches.** Switch it to chosen documents, then give it a role in each: ```ts await proof.agents.update("agt_Qe71", { access: "chosen" }); await proof.agents.putDocument("agt_Qe71", "doc_8Hq2Lm4R", { role: "suggester" }); await proof.agents.deleteDocument("agt_Qe71", "doc_3pXw9KdA"); ``` A good place for per-document choices is your Share dialog: a row for each of the person’s agents, with the same roles you offer people. Whatever you set, the agent never gets more than its person’s own role in that document. See [Share documents](/guides/share-documents/#what-agents-get). ## Hear about agents [Section titled “Hear about agents”](#hear-about-agents) Two webhook events keep your Agents section current without polling: * `agent.connected` when someone approves an agent they hadn’t connected before. * `agent.revoked` when an agent is removed, by your call or because its user was deleted. See [Send notifications](/guides/notifications/) for receiving events, and the [event reference](/reference/webhooks/) for their fields. ## What agents see [Section titled “What agents see”](#what-agents-see) Agents get Proof’s document tools: listing, searching, reading, writing in Markdown, suggesting, and commenting. They’re told to suggest unless their person asked them to edit directly. Each edit is credited with the agent’s name and its person’s, such as “Claude Code (Ada Lovelace’s agent)”, in the margin, in the record and in the seal. Folders, tags and anything else your app keeps itself aren’t in Proof’s tools. To give agents those, register tools of your own, which Proof’s server lists beside its own and forwards to you; see [Add your own agent tools](/guides/your-own-agent-tools/). ## Next [Section titled “Next”](#next) * [MCP tools](/reference/mcp/): every tool and what it may do. * [Agents](/concepts/agents/): how agents are credited and limited. * [Agent requests](/reference/api/agent-requests/) and [Agents](/reference/api/agents/) in the API reference. # Embed the editor > Put the Proof of Thought editor in your page, signed in as your user, sized and styled to fit, and wired to your app. 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](https://developer.proof.exalto.ai/console), and a document to open. The [quickstart](/quickstart/) creates one. ## How the pieces fit [Section titled “How the pieces fit”](#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](/concepts/trust-model/) for why that matters. ## Embed a document [Section titled “Embed a document”](#embed-a-document) 1. **Allow your origins.** In the console, under **Embed origins**, or with [`PATCH /v1/platform`](/reference/api/platform/), add each origin your app runs on, such as `https://acme.com`. The editor only loads inside pages on these origins; anywhere else the browser refuses the frame. In test mode, `http://localhost` and `http://127.0.0.1` on any port are allowed without adding them. 2. **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 ```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 `group` with the id of the person’s own sign-in session with you, and when they sign out, end every Proof session it started with [`proof.users.endSessions`](/reference/api/sessions/#end-a-users-sessions): their open editors stop at once. The session’s `role` is the user’s role in the document: `owner`, `editor`, `suggester` or `viewer`. Proof refuses the session with `no_access` when 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, call `proof.users.upsert` again with your id, and every open editor shows the change. See [Sessions](/reference/api/sessions/) for every field, and [Users](/concepts/users/) for how your ids and Proof’s fit together. 3. **Mount the editor in your page.** Give it an element, the document id, and a `getToken` function that calls your route. editor-page.ts ```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 `getToken` once 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`: ```html
``` 4. **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”](#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. ```ts 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”](#make-it-look-like-your-app) Appearance is set with options, not CSS: your stylesheets can’t reach inside the iframe. ```ts 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](/reference/embed/#options). ## Choose a mode [Section titled “Choose a mode”](#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: ```ts 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”](#wire-it-to-your-app) The editor tells your page about the things your app owns. Listen with `editor.on`: ```ts 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: ```ts editor.focus(); editor.print(); // the editor's own print layout, typeset for PDF const markdown = await editor.export("markdown"); // or "html", "docx" editor.openBreakdown(); // the seal's breakdown: where the words came from, sittings, check-ins editor.openSeal(); // the owner's seal dialog ``` Every option, event and method is in the [embed API reference](/reference/embed/). ## What the editor doesn’t do [Section titled “What the editor doesn’t do”](#what-the-editor-doesnt-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`](/reference/api/users/). ## Next [Section titled “Next”](#next) * [Share documents](/guides/share-documents/) with members and links. * [Connect agents](/guides/connect-agents/) so your users can bring Claude or Codex. * [Seal and verify](/guides/seal-and-verify/) when a document is done. # Send notifications > Receive Proof's webhooks, check their signatures, and turn comments, mentions and seals into your own notifications and emails. Proof doesn’t send email or keep a notification list: people are yours, so telling them things is too. Instead, Proof sends your server a webhook whenever something happens that someone might want to hear about, such as a comment, a reply, a mention or a seal. By the end of this guide, your server receives those events safely and turns them into your own bell and emails. ## Set up an endpoint [Section titled “Set up an endpoint”](#set-up-an-endpoint) 1. **Add the endpoint.** In the console under **Webhooks**, or from code: ```ts const endpoint = await proof.webhookEndpoints.create({ url: "https://acme.com/proof/webhooks", events: ["thread.created", "thread.replied", "thread.message_edited", "thread.message_deleted", "seal.created"], }); // endpoint.secret is "whsec_…": store it with your other secrets. It's shown only once. ``` Use `["*"]` to receive every event, including ones added later. 2. **Receive events with the raw body.** The signature covers the exact bytes Proof sent, so read the body before any JSON parser touches it. webhooks.ts ```ts import express from "express"; app.post("/proof/webhooks", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = proof.webhooks.verify(req.body, req.get("Proof-Signature"), process.env.PROOF_WEBHOOK_SECRET); } catch { return res.sendStatus(400); } await queue.add("proof-event", event); // do the work elsewhere res.sendStatus(200); }); ``` `verify` checks the HMAC and that the timestamp is within five minutes, then returns the parsed event. If you can’t use the SDK, the [event reference](/reference/webhooks/) shows how to check the `Proof-Signature` header yourself. 3. **Answer quickly.** Respond with any `2xx` within 10 seconds, and do the real work afterwards. Anything else, or no answer, counts as a failure: Proof tries again with growing gaps for three days. ## Handle each event once [Section titled “Handle each event once”](#handle-each-event-once) Proof may send an event more than once, and doesn’t promise any order. Two habits cover both: * **Keep the ids you’ve handled.** Every event has an `id` such as `evt_1Nf3Qa8Z`. Skip any you’ve seen. * **Don’t trust order.** If you keep state from events, such as whether a thread is resolved, compare `created_at` before overwriting, or read the current state from the API. worker.ts ```ts worker.process("proof-event", async (event) => { const fresh = await db.handledEvents.insertIfMissing(event.id); if (!fresh) return; await handle(event); }); ``` ## Turn events into notifications [Section titled “Turn events into notifications”](#turn-events-into-notifications) Every event names who did it in `actor`: a person (`{ type: "person", user }`) or an agent acting for one (`{ type: "agent", agent, user }`). Don’t notify people about what they or their own agents did. Thread events say whom they concern, so you needn’t ask the API: the document’s `owners`, the thread’s earlier `participants`, the `suggestion_author` of a thread on a suggestion, and the people the message `mentions`. Here is how Proof of Thought fills its bell from them: notify.ts ```ts async function handle(event) { const { data } = event; switch (event.type) { case "thread.created": case "thread.replied": { const message = event.type === "thread.created" ? data.thread.messages[0] : data.message; const threadId = event.type === "thread.created" ? data.thread.id : data.thread_id; // Owners, those who wrote in the thread, whose suggestion it's about, and those mentioned; never its author or their agent's person. const audience = new Set([...data.owners, ...data.participants, data.suggestion_author, ...data.mentions].filter(Boolean)); audience.delete(message.author.user); for (const user of audience) { await bell.add(user, { document: data.document, thread: threadId, message: message.id, kind: data.mentions.includes(user) ? "mention" : event.type === "thread.created" ? "comment" : "reply", from: message.author.name, excerpt: message.excerpt, }); } break; } case "thread.message_edited": { // Keep the excerpt current; those it newly mentions hear of it now. await bell.updateExcerpt(data.message.id, data.message.excerpt); for (const user of data.newly_mentioned) await bell.add(user, { document: data.document, thread: data.thread_id, message: data.message.id, kind: "mention" }); break; } case "thread.message_deleted": { if (data.thread_deleted) await bell.removeThread(data.document, data.thread_id); else await bell.removeMessage(data.message_id); break; } } } ``` `mention.created` comes too, one per person mentioned, for a platform that only wants mentions. Use one or the other, or skip a mention you’ve already told someone of by its `message_id`. A mention looks like this: ```json { "id": "evt_1Nf3Qa8Z", "type": "mention.created", "created_at": "2026-10-30T14:12:09Z", "mode": "live", "actor": { "type": "agent", "agent": "agt_3Vn8Qx2K", "user": "usr_5Kq2Lm8R" }, "data": { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message_id": "msg_9Xc1", "mentioned": "usr_9Tb3Wc1X", "excerpt": "@Grace Hopper does this match your notes?" } } ``` Here an agent wrote the comment for Ada (`usr_5Kq2Lm8R`), so it reaches Grace (`usr_9Tb3Wc1X`) but not Ada. Show it as Proof does, crediting both: “Claude Code (Ada Lovelace’s agent) mentioned you”. ## Link to the right place [Section titled “Link to the right place”](#link-to-the-right-place) Events carry ids, not URLs, because the document lives at your URL. Build the link in your app, such as `https://acme.com/notes/doc_8Hq2Lm4R?thread=thr_6Pz3`, and open the editor at the comment with the `target` option: ```ts mountEditor(element, { document: "doc_8Hq2Lm4R", getToken, target: { kind: "comment", id: "thr_6Pz3" } }); ``` When the person reads a thread in the editor, by opening it or writing in it, the editor sends a `thread-read` event: mark your notifications of it read. Before showing notifications, check the person can still open each document: [`GET /v1/users/{id}/documents?ids=…`](/reference/api/users/#list-a-users-documents) answers with those they can, and their titles. ## Email [Section titled “Email”](#email) Most people want email for some of these, not all. Proof of Thought gathers what’s still unread in a document after 10 minutes into one email. You can do the same from your bell: when an item is still unread after a while, send it. ## Keep your lists current [Section titled “Keep your lists current”](#keep-your-lists-current) Some events aren’t notifications but tell you to refresh something you keep: * `document.created`, `document.renamed`, `document.trashed`, `document.restored`, `document.deleted`: an agent or a person changed a document your lists show. * `agent.connected`, `agent.revoked`: a person’s agents changed. * `seal.withdrawn`: a seal link now says it was withdrawn. ## Next [Section titled “Next”](#next) * [Webhook events](/reference/webhooks/): every event and its fields. * [Webhook endpoints](/reference/api/webhook-endpoints/) in the API reference. # Seal and verify > Seal a document from the editor or your server, show the seal line in your app, and know what a reader sees when they open it. Sealing takes a snapshot of a document as it stands, together with the record of how it was written, and gives it a short link at `proof.exalto.ai`. The text can then go anywhere, such as an email, a submission form or a pull request, and the link goes with it. Anyone who opens the link sees the sealed text, where its words came from, and whether a person was there, unless its owners [made its seals private](#private-seals). By the end of this guide, your users can seal from the editor or from your own button, your app knows about every seal, and you know what readers will see. ## Seal from the editor [Section titled “Seal from the editor”](#seal-from-the-editor) Owners see a **Seal** button in the editor (your own button can open it with `editor.openSeal()`). It seals on confirmation, and is where owners choose who reads the document’s seals and withdraw them. You don’t need to build anything for this, but your app should hear about it: ```ts editor.on("seal", ({ code, url, line }) => { sealList.add({ code, url, line }); }); ``` The same seal also arrives as a `seal.created` webhook, which is the one to rely on for anything you store: the page may be closed before the event reaches it. See [Send notifications](/guides/notifications/). ## Seal from your server [Section titled “Seal from your server”](#seal-from-your-server) To seal from your own button, or as part of your own flow such as submitting an assignment, call the API with the user who is sealing. Only one of the document’s owners can seal, and not while it has pending suggestions. submit.ts ```ts app.post("/assignments/:id/submit", requireSignIn, async (req, res) => { const assignment = await db.assignments.find(req.params.id); const seal = await proof.seals.create(assignment.documentId, { user: req.user.proofUserId }); await db.submissions.insert({ assignment: assignment.id, seal: seal.code, at: seal.sealed_at }); res.json({ url: seal.url, line: seal.line }); }); ``` The seal comes back with everything a reader will see: ```json { "code": "AH271NSHS", "url": "https://proof.exalto.ai/AH271NSHS", "document": "doc_8Hq2Lm4R", "version": 1, "sealed_at": "2026-10-13T23:31:04Z", "sealed_by": "usr_5Kq2Lm8R", "method": "v0.2", "line": "proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min", "composition": { "typed": 71, "shaped": 16, "ai": 8, "external": 5 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min", "attested_by": { "platform": "acme", "key_ids": ["key_2wLx7Hq"] }, "withdrawn_at": null } ``` Sealing a document that reads exactly as its newest seal is refused with `409 unchanged`, so a form submitted twice doesn’t seal twice. ## Before sealing [Section titled “Before sealing”](#before-sealing) To show where a document stands before anyone seals it, such as on a submit screen, read its live provenance. It uses the same method a seal would, but nothing is kept: ```ts const reading = await proof.documents.provenance("doc_8Hq2Lm4R"); reading.composition; // { typed: 71.4, shaped: 15.9, ai: 7.8, external: 4.9 } reading.presence; // { share: 97, verified_share: 0, verified: false } ``` The editor’s own seal, in the corner, shows the same reading as people write. ## Show the seal line [Section titled “Show the seal line”](#show-the-seal-line) The seal line is one string meant to travel with the text: the link, then the shares. Show it with a copy button wherever your users finish their work. ```plaintext proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min ``` When check-ins vouched for every sitting, the line ends `(verified)`; when they vouched for some, it says how much, such as `(73% verified)`. Show `seal.line` as it comes: don’t rebuild it from the numbers, since rounding and wording are part of the method. Public seal links unfurl in chat apps and social sites with an image of the seal and its numbers. ## Seal again [Section titled “Seal again”](#seal-again) A seal never changes. People can keep editing after sealing, and seal again whenever they like: each seal gets its own code and link, and its `version` counts up for that document. List them all with: ```ts const seals = await proof.seals.list("doc_8Hq2Lm4R"); ``` ## Withdraw a seal [Section titled “Withdraw a seal”](#withdraw-a-seal) An owner can withdraw a seal, for example one shared by mistake. Its link then says it was withdrawn, instead of showing the text. A seal can’t be edited, only withdrawn. ```ts await proof.seals.withdraw("AH271NSHS"); ``` Deleting a document withdraws all its seals. You hear about each through `seal.withdrawn`. While a document is in its owners’ trash, its seals are down, and come back when it’s restored. ## Private seals [Section titled “Private seals”](#private-seals) In the seal dialog, owners choose who reads a document’s seals, every version alike: anyone with the link, or only its members, the readers they name by email (or `*@domain` for everyone at a domain), and whoever has a password, if they set one. A private seal’s page offers the password, and signing in with your platform. To offer that, set a reader URL with [`PATCH /v1/platform`](/reference/api/platform/), and give it a page that works like your consent page: read.ts ```ts app.get("/proof/read", requireSignIn, async (req, res) => { const user = req.user; const { redirect_url } = await proof.readerRequests.approve(String(req.query.request), { user: await proofUserId(user), email: user.emailVerified ? user.email : null, }); res.redirect(redirect_url); }); ``` Proof lets them in if they’re a member or their email is on the list, and remembers them in that browser for 30 days. Pass the email only if you’ve verified it. See [Reader requests](/reference/api/seals/#reader-requests). ## What a reader sees [Section titled “What a reader sees”](#what-a-reader-sees) 1. **The sealed text**, exactly as it stood, so a reader can compare it with what they were sent. 2. **The seal.** The outer ring is the whole document by where its words came from: typed by people, AI text people shaped, AI text kept as written, and external (pasted or imported). The inner ring is presence, in electric blue where check-ins vouched for it. 3. **The breakdown.** Prompts and agent work, who did what, the sittings on a timeline, which signals were on, and the date and method the seal was made under. 4. **Who vouches for what.** The page names your platform as where it was written, which vouches for who wrote it. Proof vouches for how the text was written, because it recorded that itself. Your platform vouches for who the people are, because it signs them in. Check-ins are shown apart, as the part neither of you could produce. Each seal records the method version it was made under (`v0.2` above). When Proof’s method improves, existing seals keep their numbers; the page adds the reading under the newer method beside them, labelled as such, and never rewrites a seal. Each seal keeps the ids of the secret keys that started its writing sessions (`attested_by.key_ids`), so a seal written with a key you later find leaked can be traced to it. See [Test and go live](/guides/test-and-go-live/#roll-your-keys). ## Next [Section titled “Next”](#next) * [Seals](/concepts/seals/): what each number means. * [Trust model](/concepts/trust-model/): what a seal proves, and what it doesn’t. * [Seals](/reference/api/seals/) in the API reference. # Set up check-ins > Let your users add security keys and check in while they write, so their seals can show Verified Presence. A check-in is a tap on a security key while someone writes. It shows that a person with the key was there when asked, which is the one signal on a seal that neither an agent nor your own server can produce. When check-ins vouch for every sitting anyone wrote in, the seal’s presence ring turns electric blue, shows a shield, and reads **Verified Presence**. By the end of this guide, your users can add a key and choose how often to check in, from your own settings page. Check-ins are optional and belong to the person, not the document: once someone turns them on, they apply wherever that person writes in your app. A message needs none; a take-home exam might ask for them. ## Why the settings are Proof’s [Section titled “Why the settings are Proof’s”](#why-the-settings-are-proofs) Security keys are tied to the website that registered them. For a key to vouch for writing recorded by Proof, it has to be registered on Proof’s own domain, not yours. So adding keys and choosing an interval happen in Proof’s presence settings, served from `embed.proof.exalto.ai`: framed in your own settings page, or in a small window your page opens. Everything else about check-ins happens inside the editor. ## Add presence settings to your app [Section titled “Add presence settings to your app”](#add-presence-settings-to-your-app) 1. **Add a presence session route.** It’s like the editor’s session route, with `purpose: "presence"` instead of a document. server.ts ```ts app.post("/proof/presence-session", requireSignIn, async (req, res) => { // proofUserId: the helper from Embed the editor. const session = await proof.sessions.create({ user: await proofUserId(req.user), purpose: "presence" }); res.json({ token: session.token }); }); ``` 2. **Show them in your settings page.** Frame them where your settings are, with your platform’s slug and the key’s mode: settings.ts ```ts import { mountPresenceSettings } from "@exalto/proof-embed"; mountPresenceSettings(document.querySelector("#presence")!, { getToken: async () => (await (await fetch("/proof/presence-session", { method: "POST" })).json()).token, platform: "acme", mode: "live", }, ({ keys, everyMinutes }) => { presenceLabel.textContent = everyMinutes ? `Every ${everyMinutes} minutes` : "Off"; }); ``` Or open them in a window of their own, which browsers that won’t create a key in a frame need: settings.ts ```ts import { openPresenceSettings } from "@exalto/proof-embed"; document.querySelector("#presence-settings").addEventListener("click", () => { openPresenceSettings({ getToken: async () => { const response = await fetch("/proof/presence-session", { method: "POST" }); return (await response.json()).token; }, }); }); ``` Call it from a click: browsers only let a page open a window in answer to one. 3. **Let people choose.** People add, order and remove keys, try a check-in (recorded nowhere), and pick how often to check in while writing: about every 1, 5, 10 or 30 minutes, or never. Framed, your callback hears each change; in the window, `openPresenceSettings` resolves with their choice once they close it. ```ts const { everyMinutes, keys } = await openPresenceSettings({ getToken }); presenceLabel.textContent = everyMinutes ? `Every ${everyMinutes} minutes` : "Off"; ``` To show the current setting without opening the window, read it on your server: ```ts const { every_minutes } = await proof.users.retrieveCheckins("usr_5Kq2Lm8R"); ``` You can also set it, for example when someone turns on check-ins from a toggle of your own: ```ts await proof.users.updateCheckins("usr_5Kq2Lm8R", { every_minutes: 10 }); ``` A person needs at least one key before you turn check-ins on this way: without one, `updateCheckins` answers `409 no_security_keys`. When the person asks for presence settings in the editor, from a check-in or the breakdown, it sends a `presence-settings` event: open them for them. ## Which keys work [Section titled “Which keys work”](#which-keys-work) Proof sorts each key when it’s added: * **Hardware keys**, such as a YubiKey or Windows Hello on a TPM, prove the model they are. These count fully. * **Built-in authenticators** that can’t prove their model, such as Touch ID through iCloud Keychain or Google Password Manager, are accepted as they are. * **Password managers and unknown software** are refused, because the key could be anywhere. The kind is kept with each check-in, so a reader of the seal’s breakdown can see which keys vouched. ## What people see while writing [Section titled “What people see while writing”](#what-people-see-while-writing) Nothing changes until a check-in is due. Proof asks on someone’s first edit after half an hour away from writing, then at a random time between half and one and a half times their interval. A small card appears at the foot of the editor asking for a tap. Reading, pointing and clicking never ask; writing, and accepting or rejecting suggestions, do. A tap within a minute passes. No tap is recorded as missed. Nothing stops writing either way: a miss only means the writing that follows it isn’t verified, until the next check-in passes. ## How check-ins show on seals [Section titled “How check-ins show on seals”](#how-check-ins-show-on-seals) The seal’s inner ring is presence: the share of the session with a person there, writing or reading as they write. The part vouched for by check-ins is drawn in electric blue. * When every sitting anyone wrote in was verified, the ring is blue all round, the shield shows, and the seal line ends `(verified)`. * When only some were, the line says how much, such as `(73% verified)`. * Someone who doesn’t check in only goes unverified. Nothing counts against them. The seal object carries the same numbers, so your app can show them: ```ts const seal = await proof.seals.retrieve("AH271NSHS"); seal.presence; // { share: 97, verified_share: 97, verified: true } ``` See [Presence and check-ins](/concepts/presence-and-check-ins/) for exactly what counts as verified. ## Next [Section titled “Next”](#next) * [Seal and verify](/guides/seal-and-verify/): what readers see on a sealed document. * [Trust model](/concepts/trust-model/): why check-ins are the signal your own server can’t produce. * [Embed API](/reference/embed/#presence-settings) for every option of `mountPresenceSettings` and `openPresenceSettings`. # Share documents > Turn your sharing screen into members, roles and link access in Proof, and know what agents can do as roles change. Your app owns the sharing screen: who to invite, how invitations are sent, and what a shared link looks like. Proof owns the result: who may open each document and what they may do in it. By the end of this guide, your Share dialog writes members and link access to Proof, and anyone who opens a shared link gets the right role in the editor. Proof checks roles on every session and every agent call, so what you write here takes effect at once, in open editors too. ## Roles [Section titled “Roles”](#roles) | Role | In the editor, can | | ----------- | ----------------------------------------------------------------------------------- | | `owner` | Everything an editor can, and seal the document. A document has one or more owners. | | `editor` | Write, accept and reject suggestions, and comment. | | `suggester` | Suggest changes and comment. Their suggestions wait for an editor. | | `viewer` | Read. | The first owner is whoever you name when you create the document. Who may share, change link access or delete is your app’s rule, enforced in your routes, since those actions go through your server. See [Documents and access](/concepts/documents-and-access/) for the full rules. ## Add and remove members [Section titled “Add and remove members”](#add-and-remove-members) When someone in your Share dialog invites a person, add them as a member with a role, by their Proof id. `proofUserId` is the helper from [Embed the editor](/guides/embed-the-editor/): it creates the Proof user the first time and returns the stored id after that. share.ts ```ts app.post("/notes/:id/members", requireSignIn, async (req, res) => { const { userId, role } = req.body; // from your Share dialog const invitee = await db.users.find(userId); await proof.members.put(req.params.id, await proofUserId(invitee), { role }); await sendInviteEmail(invitee, req.params.id); // yours to send res.sendStatus(204); }); ``` Proof doesn’t check who is allowed to share: your route should. A common rule is the one Proof of Thought uses, where editors and owners may invite, and nobody may give a role above their own. Look up the caller’s role with [`GET /v1/documents/{id}/members`](/reference/api/members/) if you don’t keep your own copy. To change someone’s role, `PUT` it again. To remove them: ```ts await proof.members.delete("doc_8Hq2Lm4R", "usr_9Tb3Wc1X"); ``` Removing a member ends their access straight away. If Grace has the document open, Grace’s editor closes it and fires an `error` event with the code `no_access`. A change of role that changes how they write, such as editor to suggester, reloads their editor with the new role. `detail` is the second line Proof shows under a name when someone types `@` to mention a person, here the email address. Leave it out if your app doesn’t show emails to other people. ## Share by link [Section titled “Share by link”](#share-by-link) Link access lets anyone who has the link open the document, with a role you choose, without being added as a member. It’s off (`restricted`) until the owner turns it on. 1. **Set the link’s role.** When the owner picks “Anyone with the link can suggest” in your dialog: ```ts await proof.documents.update("doc_8Hq2Lm4R", { link_access: "suggest" }); ``` The values are `restricted`, `view`, `suggest` and `edit`. 2. **Give the link a page in your app.** The link is your own URL, such as `https://acme.com/n/doc_8Hq2Lm4R`. Proof never hands out document links of its own. 3. **Start link sessions.** When someone who isn’t a member opens that page, start their session with `via: "link"`: ```ts const session = await proof.sessions.create({ user: await proofUserId(visitor), document: "doc_8Hq2Lm4R", via: "link", }); // session.role is "suggester" here: the link's role, or their member role if that's higher. ``` Proof refuses it with `no_access` when link access is `restricted`. Visitors still need to be users: if your app lets people in without signing up, see [Anonymous users](/guides/anonymous-users/). Opening a document by link doesn’t make someone a member, so it doesn’t appear in their [document list](/reference/api/users/). If your app keeps a list of documents people have opened by link, keep it yourself, or add them as members when they first write. Turning link access back to `restricted` ends every link session at once. ## Owners [Section titled “Owners”](#owners) Give someone the `owner` role to make them an owner too. To hand a document over, make the new person an owner, then lower the old owner’s role or remove them. A document always keeps an owner: lowering or removing the last one is refused with `409 last_owner`. ```ts await proof.members.put("doc_8Hq2Lm4R", "usr_9Tb3Wc1X", { role: "owner" }); await proof.members.put("doc_8Hq2Lm4R", "usr_5Kq2Lm8R", { role: "editor" }); ``` Seals already made keep the name of whoever sealed them. ## What agents get [Section titled “What agents get”](#what-agents-get) An agent acts for one person, and can never do more than that person can. Its role in a document is the lower of the role its person gave it and the person’s own current role. So your sharing changes reach agents with no extra work: * Remove Grace from a document, and Grace’s agents lose it at the same moment. * Make Grace a viewer, and Grace’s agents can only read, even if Grace gave them editing. * Turn on link access, and agents of people who have the link may reach the document too, by its id, with at most the link’s role: an agent with `access: "all"` reaches any document its person can open. Its `list_documents` lists only documents its person is a member of. Agents can never be owners. See [Connect agents](/guides/connect-agents/) for how people choose what each agent reaches. ## Next [Section titled “Next”](#next) * [Members](/reference/api/members/) and [Documents](/reference/api/documents/) in the API reference. * [Send notifications](/guides/notifications/) when someone comments on or is mentioned in a shared document. # Test and go live > Build against test mode end to end, then switch to live keys with a checklist covering origins, agents, webhooks, key rolling and rate limits. Everything in Proof works in test mode, from the editor to agents and seals, so you can build and try your whole flow before any real user writes a word. This guide covers what’s different in test mode, then what to check before you switch to live keys. ## Test mode [Section titled “Test mode”](#test-mode) Your platform has two sets of keys in the console: test (`sk_test_…`) and live (`sk_live_…`). Each sees only its own data. Users, documents, agents, seals and webhook endpoints made with a test key don’t exist for a live key, and the other way round. In test mode: * **The editor loads on `http://localhost`** and `http://127.0.0.1` on any port, as well as your embed origins. * **Seals are marked.** Codes start with `T`, such as `TQ83KD0PA`, and the seal page says **Test seal** at the top. Test seals don’t count toward billing. * **Agents connect to a test address**, `https://mcp.proof.exalto.ai/acme/test`, which uses the test consent URL you set in the console. It may be on `localhost`, since the person’s own browser opens it. * **Webhooks go to test endpoints only.** A test endpoint may be any URL, `http://localhost:3000/proof/events` included when Proof runs on your machine; otherwise point it at a tunnel such as `cloudflared tunnel --url http://localhost:3000`. Test data is yours to clear: **Delete test data** in the console removes every test object at once. ## Before you go live [Section titled “Before you go live”](#before-you-go-live) Work through these with your live keys, in a staging copy of your app if you have one. ### Keep the secret key on your server [Section titled “Keep the secret key on your server”](#keep-the-secret-key-on-your-server) Load `sk_live_…` from your secret store or an environment variable, on the server only. It never belongs in the browser, in a mobile app, or in your repository. The browser only ever needs session tokens, which your server starts for one user and one document at a time. Anyone holding your secret key can start a session as any of your users. That’s why seals name your platform as the one vouching for who wrote, and record which key was used. ### Set your embed origins [Section titled “Set your embed origins”](#set-your-embed-origins) List every origin your app serves the editor from, such as `https://acme.com`, under **Embed origins**. Leave out anything you don’t control. The editor won’t load anywhere else, which stops other sites from framing your users’ documents. ### Set your consent URL [Section titled “Set your consent URL”](#set-your-consent-url) Set the live consent URL under **Agents**, or with [`PATCH /v1/platform`](/reference/api/platform/) and a live key, and connect a real agent to your live address, `https://mcp.proof.exalto.ai/acme`, from start to finish. See [Connect agents](/guides/connect-agents/). ### Add your webhook endpoint [Section titled “Add your webhook endpoint”](#add-your-webhook-endpoint) Add the live endpoint, which must be `https`, and store its `whsec_…` secret apart from the test one. Check your server answers `2xx` to a real event, such as a comment in a live document. See [Send notifications](/guides/notifications/). ### Check your platform’s name [Section titled “Check your platform’s name”](#check-your-platforms-name) Seal pages name your platform as where the text was written, which vouches for who wrote it. Make sure the name under **Platform** is the one your users know. ### Roll your keys [Section titled “Roll your keys”](#roll-your-keys) Rolling a key gives you a new one while the old one keeps working for a time you choose, up to seven days, so you can deploy the new key without downtime: 1. In the console, choose **Roll key** and pick how long the old key keeps working. 2. Deploy the new key. 3. Check the console shows no recent requests with the old key, then expire it. If a key may have leaked, choose **Revoke now** instead. Every request with it stops at once. Seals record the ids of the keys that started their writing sessions (`attested_by.key_ids`), so you can find every seal a leaked key had a hand in. ### Stay within rate limits [Section titled “Stay within rate limits”](#stay-within-rate-limits) The API doesn’t limit requests yet. Even so, start one session per page load rather than one per action, and call `users.upsert` only when starting sessions or when a profile changes. When limits come, a request over them will get `429` with a `Retry-After` header, which the Node SDK already waits for and retries. In the editor, people’s edits always go first, and each agent request gets a short slice of work at a time per document, so one busy agent never slows anyone’s typing. ### Handle errors [Section titled “Handle errors”](#handle-errors) Every error has a `type` and a `code`, so your code can branch on them: ```ts try { await proof.sessions.create({ user: "usr_9Tb3Wc1X", document: "doc_8Hq2Lm4R" }); } catch (error) { if (error.type === "permission" && error.code === "no_access") showNoAccess(); else throw error; } ``` The [API overview](/reference/api/overview/) lists every type. ## Next [Section titled “Next”](#next) * [Platforms and keys](/concepts/platforms-and-keys/): test and live modes in detail. * [Trust model](/concepts/trust-model/): what your key vouches for. # Add your own agent tools > Give agents your app's own features, such as folders and pins, as tools Proof's MCP server lists beside its own and forwards to your server. Proof’s MCP server gives agents everything about documents: reading, writing, suggesting and commenting. It doesn’t know about the things your app keeps itself, such as folders, pins, tags, projects or due dates. To let agents use those too, register tools of your own with Proof. Its server lists them beside its own, at the same address, and forwards each call to your server, signed, saying which agent is calling and for whom. By the end of this guide, an agent can file a document it just wrote into one of your folders, with nothing more for your users to connect. ## How it fits [Section titled “How it fits”](#how-it-fits) | | Proof’s tools | Yours | | ------- | ---------------------------------------------------- | ---------------------------------- | | Address | Your MCP address, `https://mcp.proof.exalto.ai/acme` | The same | | Tools | Documents: read, write, suggest, comment | Your features: folders, pins, tags | | Runs on | Proof | Your server, called by Proof | | Sign-in | Proof’s, via your consent page | The same: one approval covers both | Document ids are the same in both: `doc_8Hq2Lm4R` in Proof’s `list_documents` is the id your folders table already stores. So an agent can find a document with Proof’s tools and file it with yours, without any translation. Keep document text in Proof’s tools. Your tools never write text into a document: words that don’t come through Proof’s own tools or editor aren’t credited to whoever wrote them. ## Register your tools [Section titled “Register your tools”](#register-your-tools) 1. **Describe them.** Each tool is a name, a description and a JSON Schema for its arguments, as MCP describes tools. Names can’t be one of Proof’s own. Say in `instructions` how your tools relate to Proof’s, so agents use the two together. tools.ts ```ts export const TOOLS = [ { name: "list_folders", title: "List folders", description: "The person's folders, in their order, with the documents in each.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "move_document", title: "Move document", description: "File a document in one of the person's folders, or in none with folder_id null. Only when they ask.", inputSchema: { type: "object", properties: { document_id: { type: "string" }, folder_id: { type: ["string", "null"] } }, required: ["document_id", "folder_id"], additionalProperties: false, }, }, ]; ``` 2. **Register them when your server starts**, with the address Proof sends calls to and a secret of at least 32 characters, which you choose and keep: server.ts ```ts await proof.platform.setTools({ url: "https://acme.com/proof/tools", secret: process.env.ACME_TOOLS_SECRET!, instructions: "Folders are the person's own: list_folders shows them, move_document files a document in one.", tools: TOOLS, }); ``` Each call replaces what you registered before, for the key’s mode. Agents see the change at their next `tools/list`. 3. **Answer the calls.** Proof posts each call as JSON, signed in a `Proof-Signature` header as webhooks are: ```json { "tool": "move_document", "arguments": { "document_id": "doc_8Hq2Lm4R", "folder_id": "fld_2" }, "agent": { "id": "agt_3Vn8Qx2K", "name": "Claude Code", "access": "all" }, "user": "usr_5Kq2Lm8R" } ``` `user` is the Proof id of the person the agent acts for. Check the signature, run the tool for that person, and answer within 10 seconds with `{ "text": … }`, what to tell the agent, and optionally `data`, given to it as structured content; or with `{ "error": … }` to refuse. tools.ts ```ts import { verifySignature } from "@exalto/proof"; app.post("/proof/tools", express.raw({ type: "application/json" }), async (req, res) => { if (!verifySignature(req.body, req.get("Proof-Signature"), process.env.ACME_TOOLS_SECRET!)) return res.sendStatus(401); const call = JSON.parse(String(req.body)); const user = await db.users.findByProofId(call.user); if (!user) return res.json({ error: "That person has no account here." }); switch (call.tool) { case "list_folders": { const folders = await db.folders.list(user.id); return res.json({ text: folders.map((f) => `- ${f.name} (folder_id: ${f.id})`).join("\n"), data: { folders } }); } case "move_document": { const { document_id, folder_id } = call.arguments; const mine = await proof.users.listDocuments(call.user, { ids: [document_id] }); if (!mine.data.length) return res.json({ error: `No document ${document_id} for this person.` }); await db.folders.place(user.id, document_id, folder_id); return res.json({ text: "Moved." }); } } res.json({ error: `Unknown tool ${call.tool}.` }); }); ``` Check access against Proof, as `move_document` does, so your tools never reach a document the person can’t open. `agent.access` says whether the agent may reach all its person’s documents or only those chosen for it; refuse what an agent with chosen documents shouldn’t see, such as a list of every folder. An answer that isn’t JSON, a status other than `2xx`, or no answer in time reaches the agent as a failed call. ## Next [Section titled “Next”](#next) * [Platform](/reference/api/platform/#set-your-agent-tools): `PUT /v1/platform/tools` in the API reference. * [MCP tools](/reference/mcp/): the tools Proof’s server already has, so yours don’t repeat them. # Proof of Thought, inside your product > Embed the editor, let people bring their AI agents, and seal what they write. Each seal shows which words were typed, shaped, written by AI or pasted, and whether a person was there. ## Three parts you build with [Section titled “Three parts you build with”](#three-parts-you-build-with) ### The editor One call puts the Proof of Thought editor in your page: writing, suggestions, comments and the live seal, recorded as it happens. [Embed the editor](/guides/embed-the-editor/) ### Agents Your users connect Claude, Codex or any MCP client to their documents in your product. Every word an agent writes is credited to it. [Connect agents](/guides/connect-agents/) ### Seals A snapshot of a document and where its words came from, at a short link anyone can open and check. [Seal and verify](/guides/seal-and-verify/) ## What stays yours [Section titled “What stays yours”](#what-stays-yours) Your accounts, sign-in, document lists, folders, sharing screens and notifications. Proof has no accounts of its own: your server tells Proof who your users are and what they may open, and Proof records what happens in the editor. Proof of Thought itself is built this way, on the same API you use. ## A sealed document in four calls [Section titled “A sealed document in four calls”](#a-sealed-document-in-four-calls) Your server holds a secret key; the browser only ever gets a short-lived session token for one document. server.ts ```ts import Proof from "@exalto/proof"; const proof = new Proof(process.env.PROOF_SECRET_KEY); const ada = await proof.users.upsert("u_42", { name: "Ada Lovelace" }); // your id for Ada const doc = await proof.documents.create({ owner: ada.id, name: "On the Analytical Engine" }); const session = await proof.sessions.create({ user: ada.id, document: doc.id }); // Send session.token to the page. Later, when Ada is done: const seal = await proof.seals.create(doc.id, { user: ada.id }); console.log(seal.url); // https://proof.exalto.ai/AH271NSHS ``` page.ts ```ts import { mountEditor } from "@exalto/proof-embed"; mountEditor(document.querySelector("#editor"), { document: "doc_8Hq2Lm4R", getToken: () => fetch("/proof/session", { method: "POST" }).then((r) => r.json()).then((r) => r.token), }); ``` The [quickstart](/quickstart/) walks through it with test keys, from an empty project to a seal you can open. ## Written for agents as well [Section titled “Written for agents as well”](#written-for-agents-as-well) Every page here is also plain Markdown: add `.md` to its address, or fetch [`llms.txt`](/llms.txt) for the whole set. If you build with Claude Code, Codex or another agent, [start it here](/build-with-an-agent/). # Agent requests > Approve or deny an agent from your consent page, so Proof can issue it tokens. An agent request is one agent asking to act for one of your users. When someone adds your MCP address to their agent, the agent registers with Proof and opens the person’s browser at Proof’s authorize page. Proof has no sign-in, so it sends the browser on to the consent URL you set in the [console](https://developer.proof.exalto.ai/console) or with [`PATCH /v1/platform`](/reference/api/platform/), with the request’s id: ```plaintext https://acme.com/proof/connect?request=areq_7Tc1Pz9M ``` Your page signs the person in if they aren’t already, shows them which agent is asking, and on Allow or Deny calls one of the endpoints below from your server. Each returns a `redirect_url`; send the browser there, and Proof finishes the OAuth flow with the agent directly. You approve; Proof issues the agent’s tokens; you never see them. See [Connect agents](/guides/connect-agents/) for the whole flow. A request lasts 10 minutes and can be answered once. ## The agent request object [Section titled “The agent request object”](#the-agent-request-object) | Attribute | Type | Description | | ------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The request’s id, starting `areq_`. | | `status` | string | `pending`, `approved`, `denied` or `expired`. | | `client` | object | The agent asking. `name`: the name it registered with. `host`: the domain its registration is published from, checked by Proof, so it can’t claim someone else’s. `logo_url`: its logo, or `null`. | | `expires_at` | timestamp | When the request lapses if it isn’t answered. | ```json { "id": "areq_7Tc1Pz9M", "status": "pending", "client": { "name": "Claude Code", "host": "claude.ai", "logo_url": "https://claude.ai/images/claude-code-icon.png" }, "expires_at": "2026-10-30T18:30:00Z" } ``` Show `client.name` and `client.host` together on your consent page. The name is whatever the client chose; the host is the part people can trust. ## Retrieve an agent request [Section titled “Retrieve an agent request”](#retrieve-an-agent-request) `GET /v1/agent-requests/{request_id}` | Parameter | Type | Description | | ------------ | ---------------------- | ------------------------------------------ | | `request_id` | string, path, required | The `request` value from your consent URL. | * curl ```bash curl https://api.proof.exalto.ai/v1/agent-requests/areq_7Tc1Pz9M \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const request = await proof.agentRequests.retrieve("areq_7Tc1Pz9M"); ``` Returns the [agent request](#the-agent-request-object), or `404 request_not_found` for an id that isn’t one of yours. ## Approve an agent request [Section titled “Approve an agent request”](#approve-an-agent-request) `POST /v1/agent-requests/{request_id}/approve` Approves the agent for one of your users and creates the [agent](/reference/api/agents/). Call it only after your own sign-in has confirmed who the person is, and only from a deliberate action such as a form post, never from a page load. If the user has already connected this same client, Proof keeps the existing agent and replaces its access with what you pass here. | Parameter | Type | Description | | ------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | `request_id` | string, path, required | The request’s id. | | `user` | string, required | The Proof id of the person approving: the one signed in to your product, never a value from the form. | | `access` | string, required | `all` for every document they can open, editing at most, or `chosen` for only the documents in `documents`. | | `documents` | object | With `chosen`: a map from document id to role, `editor`, `suggester` or `viewer`. | * curl ```bash curl https://api.proof.exalto.ai/v1/agent-requests/areq_7Tc1Pz9M/approve \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "user": "usr_5Kq2Lm8R", "access": "chosen", "documents": { "doc_8Hq2Lm4R": "suggester" } }' ``` * Node ```ts const { redirect_url } = await proof.agentRequests.approve("areq_7Tc1Pz9M", { user: "usr_5Kq2Lm8R", access: "chosen", documents: { doc_8Hq2Lm4R: "suggester" }, }); // then redirect the browser to redirect_url ``` ```json { "redirect_url": "https://mcp.proof.exalto.ai/acme/authorize/continue?state=pz9Kq2Vn8Lr4", "agent": { "id": "agt_6Bw4Ys7J", "user": "usr_5Kq2Lm8R", "name": "Codex", "host": "openai.com", "access": "chosen", "connected_at": "2026-10-30T18:21:40Z", "last_used_at": null } } ``` For an agent that’s new, Proof also sends `agent.connected` to your [webhook endpoints](/reference/webhooks/). | Error | When | | -------------------------------------- | --------------------------------------------------------------------------- | | `400 missing_parameter` on `documents` | `access` is `chosen` and `documents` is empty or missing. | | `400 invalid_parameter` on `documents` | A role is `owner` or not a role. | | `404 user_not_found` | The user doesn’t exist. | | `404 document_not_found` | A document in `documents` doesn’t exist. | | `409 request_expired` | More than 10 minutes have passed. The person starts again from their agent. | | `409 request_already_answered` | The request was approved or denied already. | ## Deny an agent request [Section titled “Deny an agent request”](#deny-an-agent-request) `POST /v1/agent-requests/{request_id}/deny` Turns the agent away. Send the browser to `redirect_url` so the agent hears the answer instead of waiting. | Parameter | Type | Description | | ------------ | ---------------------- | ----------------- | | `request_id` | string, path, required | The request’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/agent-requests/areq_7Tc1Pz9M/deny \ -X POST \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const { redirect_url } = await proof.agentRequests.deny("areq_7Tc1Pz9M"); ``` ```json { "redirect_url": "https://mcp.proof.exalto.ai/acme/authorize/continue?state=pz9Kq2Vn8Lr4" } ``` | Error | When | | ------------------------------ | ------------------------------------------- | | `409 request_expired` | More than 10 minutes have passed. | | `409 request_already_answered` | The request was approved or denied already. | # Agents > List, limit and revoke the AI agents your users connected to their documents. An agent is an MCP client, such as Claude Code or Codex, that one of your users approved to work in their documents. It connects to your platform’s MCP address at `https://mcp.proof.exalto.ai/acme` (`/acme/test` in test mode), and Proof issues its tokens; your server never holds them. These endpoints are for your settings screens: show people their agents, choose which documents each reaches, and revoke them. Agents are created by approving an [agent request](/reference/api/agent-requests/). See [Agents](/concepts/agents/). An agent’s access is either: * **`all`**: every document its person can open, editing at most. * **`chosen`**: only the documents you give it a role in. Either way, what it may do in a document is the lower of that and its person’s current role there, checked on every call. It never owns a document. ## The agent object [Section titled “The agent object”](#the-agent-object) | Attribute | Type | Description | | -------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Proof’s id for the agent, starting `agt_`. | | `user` | string | The Proof id of the person it acts for. | | `name` | string | The name the client registered with, which its person saw when approving it. Its edits are credited as this name and its person’s: “Claude Code (Ada’s agent)”. | | `host` | string | The domain the client is published from, checked by Proof. Two agents can share a name; the host tells them apart. | | `access` | string | `all` or `chosen`. | | `connected_at` | timestamp | When its person approved it. | | `last_used_at` | timestamp or null | When it last called a tool, or `null` if it hasn’t yet. | | `expires_at` | timestamp or null | Until when it can renew its access unused: its newest refresh token lapses then, 90 days after it last renewed. Past it, or at `null`, its person approves it again. | ```json { "id": "agt_3Vn8Qx2K", "user": "usr_5Kq2Lm8R", "name": "Claude Code", "host": "claude.ai", "access": "all", "connected_at": "2026-10-13T21:30:02Z", "last_used_at": "2026-10-13T22:58:41Z", "expires_at": "2027-01-11T22:58:41Z" } ``` ## List a user’s agents [Section titled “List a user’s agents”](#list-a-users-agents) `GET /v1/users/{user_id}/agents` The agents a user has connected, newest first. | Parameter | Type | Description | | ---------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `user_id` | string, path, required | The user’s Proof id. | | `document` | string | A document id: each agent then has one more field, `document_role`, the role you gave it there, or `null`. For a document’s sharing screen. | | `limit` | integer | 1 to 100. Default 50. | | `after` | string | The `next` cursor from the previous page. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/agents \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const page = await proof.users.listAgents("usr_5Kq2Lm8R"); ``` ```json { "data": [ { "id": "agt_6Bw4Ys7J", "user": "usr_5Kq2Lm8R", "name": "Codex", "host": "openai.com", "access": "chosen", "connected_at": "2026-10-13T22:31:15Z", "last_used_at": "2026-10-13T22:38:09Z" }, { "id": "agt_3Vn8Qx2K", "user": "usr_5Kq2Lm8R", "name": "Claude Code", "host": "claude.ai", "access": "all", "connected_at": "2026-10-13T21:30:02Z", "last_used_at": "2026-10-13T22:58:41Z" } ], "next": null } ``` ## Retrieve an agent [Section titled “Retrieve an agent”](#retrieve-an-agent) `GET /v1/agents/{agent_id}` | Parameter | Type | Description | | ---------- | ---------------------- | --------------- | | `agent_id` | string, path, required | The agent’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/agents/agt_3Vn8Qx2K \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const agent = await proof.agents.retrieve("agt_3Vn8Qx2K"); ``` Returns the [agent](#the-agent-object), or `404 agent_not_found` once it has been revoked. ## Update an agent [Section titled “Update an agent”](#update-an-agent) `PATCH /v1/agents/{agent_id}` Switches an agent between reaching all its person’s documents and only the ones you choose. The change applies to its next tool call. Switching to `chosen` keeps any roles you set before. An agent switched to `chosen` with no roles reaches nothing until you [give it a document](#set-an-agents-role-in-a-document). | Parameter | Type | Description | | ---------- | ---------------------- | ------------------ | | `agent_id` | string, path, required | The agent’s id. | | `access` | string, required | `all` or `chosen`. | * curl ```bash curl https://api.proof.exalto.ai/v1/agents/agt_3Vn8Qx2K \ -X PATCH \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"access": "chosen"}' ``` * Node ```ts const agent = await proof.agents.update("agt_3Vn8Qx2K", { access: "chosen" }); ``` Returns the updated [agent](#the-agent-object). ## List an agent’s documents [Section titled “List an agent’s documents”](#list-an-agents-documents) `GET /v1/agents/{agent_id}/documents` The documents you’ve given an agent a role in, most recently edited first. These roles apply while its access is `chosen`; with `all`, it reaches every document its person can open and this list is kept for when you switch. | Parameter | Type | Description | | ---------- | ---------------------- | ----------------------------------------- | | `agent_id` | string, path, required | The agent’s id. | | `limit` | integer | 1 to 100. Default 50. | | `after` | string | The `next` cursor from the previous page. | * curl ```bash curl https://api.proof.exalto.ai/v1/agents/agt_6Bw4Ys7J/documents \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const page = await proof.agents.listDocuments("agt_6Bw4Ys7J"); ``` ```json { "data": [ { "agent": "agt_6Bw4Ys7J", "document": "doc_8Hq2Lm4R", "title": "On the Analytical Engine", "role": "suggester", "effective_role": "suggester" }, { "agent": "agt_6Bw4Ys7J", "document": "doc_3Wp7Zc1V", "title": "Notes for Thursday", "role": "editor", "effective_role": "suggester" } ], "next": null } ``` | Attribute | Type | Description | | ---------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `role` | string | The role you gave the agent: `editor`, `suggester` or `viewer`. | | `effective_role` | string or null | What it can do now: the lower of `role` and its person’s current role. `null` when its person can no longer open the document. | ## Set an agent’s role in a document [Section titled “Set an agent’s role in a document”](#set-an-agents-role-in-a-document) `PUT /v1/agents/{agent_id}/documents/{document_id}` Gives an agent a role in one document, or changes it. | Parameter | Type | Description | | ------------- | ---------------------- | -------------------------------------------------------------- | | `agent_id` | string, path, required | The agent’s id. | | `document_id` | string, path, required | The document’s id. | | `role` | string, required | `editor`, `suggester` or `viewer`. An agent is never an owner. | * curl ```bash curl https://api.proof.exalto.ai/v1/agents/agt_6Bw4Ys7J/documents/doc_8Hq2Lm4R \ -X PUT \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"role": "suggester"}' ``` * Node ```ts await proof.agents.putDocument("agt_6Bw4Ys7J", "doc_8Hq2Lm4R", { role: "suggester" }); ``` ```json { "agent": "agt_6Bw4Ys7J", "document": "doc_8Hq2Lm4R", "title": "On the Analytical Engine", "role": "suggester", "effective_role": "suggester" } ``` | Error | When | | --------------------------------- | --------------------------------- | | `400 invalid_parameter` on `role` | `role` is `owner`, or not a role. | | `404 document_not_found` | The document doesn’t exist. | ## Remove an agent’s role in a document [Section titled “Remove an agent’s role in a document”](#remove-an-agents-role-in-a-document) `DELETE /v1/agents/{agent_id}/documents/{document_id}` Takes a document off an agent’s list. With `chosen` access, it can no longer reach the document. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------ | | `agent_id` | string, path, required | The agent’s id. | | `document_id` | string, path, required | The document’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/agents/agt_6Bw4Ys7J/documents/doc_8Hq2Lm4R \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.agents.deleteDocument("agt_6Bw4Ys7J", "doc_8Hq2Lm4R"); ``` ```json { "agent": "agt_6Bw4Ys7J", "document": "doc_8Hq2Lm4R", "removed": true } ``` ## Revoke an agent [Section titled “Revoke an agent”](#revoke-an-agent) `DELETE /v1/agents/{agent_id}` Disconnects an agent for good. Its tokens stop working at once, mid-call included, and Proof sends `agent.revoked`. What it already wrote stays in documents, credited to it. To connect it again, its person approves it again. | Parameter | Type | Description | | ---------- | ---------------------- | --------------- | | `agent_id` | string, path, required | The agent’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/agents/agt_3Vn8Qx2K \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.agents.revoke("agt_3Vn8Qx2K"); ``` ```json { "id": "agt_3Vn8Qx2K", "revoked": true } ``` # Documents > Create documents, name them, set link access, move them to the trash, export them and read their provenance. A document is what people write in the embedded editor: its text, its suggestions and comments, and the record of how it was written. Every document has one or more owners, and other people reach it as [members](/reference/api/members/) or through its link access. See [Documents and access](/concepts/documents-and-access/). ## The document object [Section titled “The document object”](#the-document-object) | Attribute | Type | Description | | ------------- | ----------------- | ----------------------------------------------------------------------------------------- | | `id` | string | Proof’s id for the document, starting `doc_`. | | `name` | string or null | The name an editor gave it, or `null` when it has none. | | `title` | string | What to call it in your lists: its `name`, or its first line when it has no name. | | `owners` | array | The Proof ids of its owners, in the order they became owners. | | `link_access` | string | What people get by link without being members: `restricted`, `view`, `suggest` or `edit`. | | `created_at` | timestamp | When it was created. | | `edited_at` | timestamp | When its text last changed. | | `trashed_at` | timestamp or null | When it was moved to the trash, or `null`. | ```json { "id": "doc_8Hq2Lm4R", "name": "On the Analytical Engine", "title": "On the Analytical Engine", "owners": ["usr_5Kq2Lm8R"], "link_access": "restricted", "created_at": "2026-10-13T21:12:40Z", "edited_at": "2026-10-13T23:30:52Z", "trashed_at": null } ``` ## Create a document [Section titled “Create a document”](#create-a-document) `POST /v1/documents` Creates an empty document owned by `owner`, who becomes its first member, and sends [`document.created`](/reference/webhooks/#documentcreated). | Parameter | Type | Description | | ----------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `owner` | string, required | The Proof id of the user who owns it. The user must exist. | | `name` | string | Up to 200 characters. Without one, the document is called by its first line. | | `reuse_untouched` | boolean | `true` to answer with one of the owner’s documents that nobody has touched yet, if there is one, instead of making another: none written in, named, shared or sealed. It comes back to the top of their list, with `200`. For a “New document” button people may press again and again. | | `except` | array | With `reuse_untouched`, document ids not to reuse, such as the one already open. | Starting text isn’t supported yet: `markdown` is refused with `400 invalid_parameter`. People and agents write the text in the editor or through the [MCP tools](/reference/mcp/). * curl ```bash curl https://api.proof.exalto.ai/v1/documents \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"owner": "usr_5Kq2Lm8R", "name": "On the Analytical Engine"}' ``` * Node ```ts const doc = await proof.documents.create({ owner: "usr_5Kq2Lm8R", name: "On the Analytical Engine", }); ``` Returns the [document](#the-document-object) with `201`, or a reused one with `200`. ```json { "id": "doc_8Hq2Lm4R", "name": "On the Analytical Engine", "title": "On the Analytical Engine", "owners": ["usr_5Kq2Lm8R"], "link_access": "restricted", "created_at": "2026-10-13T21:12:40Z", "edited_at": "2026-10-13T21:12:40Z", "trashed_at": null } ``` | Error | When | | ------------------------------------- | --------------------------------------------- | | `400 invalid_parameter` on `markdown` | Starting text isn’t supported yet. | | `404 user_not_found` | `owner` doesn’t exist. Create the user first. | ## Retrieve a document [Section titled “Retrieve a document”](#retrieve-a-document) `GET /v1/documents/{document_id}` | Parameter | Type | Description | | ------------- | ---------------------- | ------------------ | | `document_id` | string, path, required | The document’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const doc = await proof.documents.retrieve("doc_8Hq2Lm4R"); ``` Returns the [document](#the-document-object), or `404 document_not_found`. Documents in the trash are returned with `trashed_at` set. ## Update a document [Section titled “Update a document”](#update-a-document) `PATCH /v1/documents/{document_id}` Renames a document or changes its link access. Only the fields you send change. People in the editor see a new name at once. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------------------------------------------------------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `name` | string or null | Up to 200 characters. `null` removes the name, so the document is called by its first line again. | | `link_access` | string | `restricted`, `view`, `suggest` or `edit`. See [Share documents](/guides/share-documents/). | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R \ -X PATCH \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"link_access": "suggest"}' ``` * Node ```ts const doc = await proof.documents.update("doc_8Hq2Lm4R", { link_access: "suggest", }); ``` Returns the updated [document](#the-document-object). Lowering link access takes effect at once: people in the editor who came by link get the new role, or lose the document if it’s now `restricted`. ## Move a document to the trash [Section titled “Move a document to the trash”](#move-a-document-to-the-trash) `POST /v1/documents/{document_id}/trash` A document goes to the trash for everyone when an owner trashes it. While it’s there, its owners can still open it in the editor, only to read; nobody else can, agents can’t reach it, and its seals are down. It’s listed with `trashed=true` on [its owners’ documents](/reference/api/users/#list-a-users-documents). Proof deletes it 30 days after it was trashed, as if you had called [Delete a document](#delete-a-document). Any other member who trashes it puts it in their own trash only: it leaves their list and nobody else’s, and they can still open it to read. After 30 days there, they stop being a member. | Parameter | Type | Description | | ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `user` | string | The Proof id of the member trashing it. Leave it out to trash it as its first owner, for everyone. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/trash \ -X POST \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const doc = await proof.documents.trash("doc_8Hq2Lm4R", { user: "usr_5Kq2Lm8R" }); ``` Returns the [document](#the-document-object). With `user`, it has one more field, `trash`: `"everyone"` when it went to the trash for everyone, `"own"` when it went to the member’s own, or `null` when it was there already. ```json { "id": "doc_8Hq2Lm4R", "name": "On the Analytical Engine", "title": "On the Analytical Engine", "owners": ["usr_5Kq2Lm8R"], "link_access": "restricted", "created_at": "2026-10-13T21:12:40Z", "edited_at": "2026-10-13T23:30:52Z", "trashed_at": "2026-10-30T18:20:00Z", "trash": "everyone" } ``` Trashing a document that’s already in the trash returns it unchanged. | Error | When | | ---------------- | -------------------------------------- | | `403 not_member` | `user` isn’t a member of the document. | ## Restore a document [Section titled “Restore a document”](#restore-a-document) `POST /v1/documents/{document_id}/restore` Takes a document out of the trash, with its members, link access and agents’ roles as they were. With `user`, it takes it out of that member’s trash: an owner’s restores it for everyone; anyone else’s, for them. | Parameter | Type | Description | | ------------- | ---------------------- | --------------------------------------------------------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `user` | string | The Proof id of the member restoring it. Leave it out to restore it as its first owner. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/restore \ -X POST \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const doc = await proof.documents.restore("doc_8Hq2Lm4R"); ``` Returns the [document](#the-document-object), with `trash` when you passed `user`, as for [trashing](#move-a-document-to-the-trash). ## Delete a document [Section titled “Delete a document”](#delete-a-document) `DELETE /v1/documents/{document_id}` Deletes a document now, for everyone. Open editors close, agents lose it, its images are deleted, and every seal made from it is withdrawn: its pages at `proof.exalto.ai` say it was withdrawn. This can’t be undone; to give people a way back, [move it to the trash](#move-a-document-to-the-trash) instead. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------ | | `document_id` | string, path, required | The document’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.documents.delete("doc_8Hq2Lm4R"); ``` ```json { "id": "doc_8Hq2Lm4R", "deleted": true } ``` ## Export a document [Section titled “Export a document”](#export-a-document) `GET /v1/documents/{document_id}/export` The document’s text as it stands, as Markdown (`text/markdown; charset=utf-8`). Pending suggestions and comments are left out. The response body is the file itself, not JSON. HTML and Word come from the editor’s [`export` method](/reference/embed/#methods) for now. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------------------------------------------------------ | | `document_id` | string, path, required | The document’s id. | | `format` | string, required | `markdown`. Anything else is refused with `400 invalid_parameter`. | * curl ```bash curl "https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/export?format=markdown" \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const markdown = await proof.documents.export("doc_8Hq2Lm4R", { format: "markdown" }); ``` ```markdown # On the Analytical Engine The Analytical Engine weaves algebraical patterns just as the Jacquard loom weaves flowers and leaves. ``` ## Read a document’s provenance [Section titled “Read a document’s provenance”](#read-a-documents-provenance) `GET /v1/documents/{document_id}/provenance` Where a document’s words came from and how it was written, read live under the current method. These are the numbers its seal would show if it were sealed now; a [seal](/reference/api/seals/) keeps the numbers it was sealed with. See [Provenance](/concepts/provenance/) for what each part means. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------ | | `document_id` | string, path, required | The document’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/provenance \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const reading = await proof.documents.provenance("doc_8Hq2Lm4R"); ``` ```json { "document": "doc_8Hq2Lm4R", "method": "v0.2", "composition": { "typed": 71.4, "shaped": 15.9, "ai": 7.8, "external": 4.9 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min" } ``` | Attribute | Type | Description | | ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `method` | string | The version of the method that produced these numbers. | | `composition` | object | Percentages of the document by where its words came from, summing to 100: `typed` by people, `shaped` (agent text people revised), `ai` (agent text kept as written) and `external` (pasted or imported). Unrounded; a [seal](/reference/api/seals/) rounds them to whole numbers. | | `presence` | object | `share`: percent of session time with a person present, or `null` before anyone has worked on it. `verified_share`: the part of it vouched for by check-ins. `verified`: `true` when every sitting people wrote in was verified. | | `prompts` | integer | Rounds of back-and-forth with agents. | | `sittings` | integer | Separate stretches of work on the document. | | `duration` | string | Session time across those sittings, as the seal line writes it, such as `1hr48min`; empty when nothing was measured. | Who wrote what, person by person and agent by agent, is in the editor’s breakdown (the [`openBreakdown` method](/reference/embed/#methods)) and on each seal’s page. # Members > Decide who can open a document and with which role, and transfer ownership. Members are the users who can open a document, each with a role. Your sharing screens decide who gets in; these endpoints tell Proof, which enforces the role in the editor and for each person’s agents. People who aren’t members can still open a document through its [link access](/reference/api/documents/#update-a-document). See [Documents and access](/concepts/documents-and-access/). | Role | Can | | ----------- | ------------------------------------------------------------------------------------------------------------ | | `owner` | Everything an editor can, and seal the document. A document has one or more owners, and always at least one. | | `editor` | Write, accept and reject suggestions, comment. Can turn on suggesting for themselves. | | `suggester` | Propose changes as suggestions and comment. Every edit they make is a suggestion. | | `viewer` | Read, and see comments. | A person’s agents never get more than the person’s own role, and never ownership. When you lower someone’s role, their agents’ access drops with it. ## The member object [Section titled “The member object”](#the-member-object) | Attribute | Type | Description | | ---------- | --------- | ------------------------------------------- | | `document` | string | The document’s id. | | `user` | string | The member’s Proof id. | | `name` | string | The user’s current name. | | `role` | string | `owner`, `editor`, `suggester` or `viewer`. | | `added_at` | timestamp | When they became a member. | ```json { "document": "doc_8Hq2Lm4R", "user": "usr_9Tb3Wc1X", "name": "Grace Hopper", "role": "suggester", "added_at": "2026-10-13T22:05:17Z" } ``` ## List members [Section titled “List members”](#list-members) `GET /v1/documents/{document_id}/members` The document’s members, owners first, then in the order they were added. | Parameter | Type | Description | | ------------- | ---------------------- | ----------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `limit` | integer | 1 to 100. Default 50. | | `after` | string | The `next` cursor from the previous page. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/members \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const page = await proof.members.list("doc_8Hq2Lm4R"); ``` ```json { "data": [ { "document": "doc_8Hq2Lm4R", "user": "usr_5Kq2Lm8R", "name": "Ada Lovelace", "role": "owner", "added_at": "2026-10-13T21:12:40Z" }, { "document": "doc_8Hq2Lm4R", "user": "usr_9Tb3Wc1X", "name": "Grace Hopper", "role": "suggester", "added_at": "2026-10-13T22:05:17Z" } ], "next": null } ``` ## Add or change a member [Section titled “Add or change a member”](#add-or-change-a-member) `PUT /v1/documents/{document_id}/members/{user_id}` Adds the user as a member with `role`, or changes their role if they’re one already. The change reaches open editors at once: someone made a suggester mid-sentence carries on writing as suggestions. Setting `role` to `owner` makes them an owner too; the others stay owners. To hand a document over, make the new person an owner, then lower the old owner’s role or remove them. A member added again comes out of their own trash. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `user_id` | string, path, required | The user’s Proof id. The user must exist. | | `role` | string, required | `owner`, `editor`, `suggester` or `viewer`. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/members/usr_9Tb3Wc1X \ -X PUT \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"role": "suggester"}' ``` * Node ```ts const member = await proof.members.put("doc_8Hq2Lm4R", "usr_9Tb3Wc1X", { role: "suggester" }); ``` Returns the [member](#the-member-object): `201` when they were added, `200` when their role changed. ```json { "document": "doc_8Hq2Lm4R", "user": "usr_9Tb3Wc1X", "name": "Grace Hopper", "role": "suggester", "added_at": "2026-10-13T22:05:17Z" } ``` | Error | When | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `404 user_not_found` | The user doesn’t exist. Create them with [`PUT /v1/users/external/{external_id}`](/reference/api/users/#create-or-update-a-user) first. | | `409 last_owner` | You tried to give the only owner a lower role. Make someone else an owner first. | | `409 document_trashed` | The document is in the trash. | ## Remove a member [Section titled “Remove a member”](#remove-a-member) `DELETE /v1/documents/{document_id}/members/{user_id}` Takes away a member’s access. Their open editors close, and their agents lose the document. What they wrote stays credited to them. People can still reach the document through link access afterwards if its `link_access` lets them in and you start a session for them with `via: "link"`. | Parameter | Type | Description | | ------------- | ---------------------- | -------------------- | | `document_id` | string, path, required | The document’s id. | | `user_id` | string, path, required | The user’s Proof id. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/members/usr_9Tb3Wc1X \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.members.delete("doc_8Hq2Lm4R", "usr_9Tb3Wc1X"); ``` ```json { "document": "doc_8Hq2Lm4R", "user": "usr_9Tb3Wc1X", "removed": true } ``` | Error | When | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `404 member_not_found` | The user isn’t a member. | | `409 last_owner` | The user is the only owner. Make someone else an owner first, or [delete the document](/reference/api/documents/#delete-a-document). | # Overview > How the Proof REST API works, from authentication and errors to pagination, versions and rate limits. The Proof REST API is how your server tells Proof who your users are, which documents exist and who may open them, and how it reads back what happened: provenance, seals and agents. Browsers never call it. They get a short-lived [session token](/reference/api/sessions/) from your server and talk to Proof through the [embedded editor](/reference/embed/). ## Base URL [Section titled “Base URL”](#base-url) ```plaintext https://api.proof.exalto.ai/v1 ``` Every request goes over HTTPS. Plain HTTP requests are refused, not redirected. ## Authentication [Section titled “Authentication”](#authentication) Send your secret key as a bearer token on every request: ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` In Node, the SDK sends it for you: ```ts import Proof from "@exalto/proof"; const proof = new Proof(process.env.PROOF_SECRET_KEY); ``` Secret keys start with `sk_test_` or `sk_live_`. Find them, and roll them, in the [console](https://developer.proof.exalto.ai/console). Each key also has an id (`key_…`) that is safe to log; seals record the id of the key that started each writing session, so a leaked key can be traced to the sessions it started. See [Platforms and keys](/concepts/platforms-and-keys/). Caution A secret key can start a session as any of your users. Keep it on your server, out of your client bundle and out of source control. A request without a key, or with an unknown or revoked one, gets `401` with an error of type `authentication`. ### Test and live data [Section titled “Test and live data”](#test-and-live-data) Test keys and live keys see separate data. A document created with a test key doesn’t exist for a live key, and the other way round. Test seals have codes that start with `T`, and their pages at `proof.exalto.ai` say “Test seal”. Use test keys in development and CI; nothing you do with them is billed. ## Requests and responses [Section titled “Requests and responses”](#requests-and-responses) * Request and response bodies are JSON (`Content-Type: application/json`), except [exports](/reference/api/documents/#export-a-document). * Field names are `snake_case`. * Timestamps are ISO 8601 in UTC, such as `2026-10-30T18:20:00Z`. * Fields that have no value are present and `null`, not left out. * Every response carries a `Proof-Request-Id` header. Include it when you contact us about a request. New fields can appear in any response at any time (see [Versioning](#versioning)), so read the fields you need and ignore the rest. ## IDs [Section titled “IDs”](#ids) Everything Proof creates has an id with a prefix that says what it is, users included. Your own id for a user is kept on the user as `external_id`, and appears only there: everywhere else, in requests, responses, events and seals, users are named by Proof id. Store the Proof id beside your user, or look it up with [`GET /v1/users/external/{external_id}`](/reference/api/users/#retrieve-a-user-by-your-id). | Prefix | Object | | -------- | ------------------------------------------------------- | | `usr_` | [User](/reference/api/users/) | | `doc_` | [Document](/reference/api/documents/) | | `est_` | [Session](/reference/api/sessions/) token | | `agt_` | [Agent](/reference/api/agents/) | | `areq_` | [Agent request](/reference/api/agent-requests/) | | `srq_` | [Reader request](/reference/api/seals/#reader-requests) | | `we_` | [Webhook endpoint](/reference/api/webhook-endpoints/) | | `whsec_` | Webhook signing secret | | `evt_` | [Webhook event](/reference/webhooks/) | | `key_` | Secret key id | Seals are identified by their code: nine capital letters and digits, such as `AH271NSHS`. Ids are opaque: don’t parse them or rely on their length. ## Errors [Section titled “Errors”](#errors) Proof uses HTTP status codes, and every error response has the same body: ```json { "error": { "type": "invalid_request", "code": "invalid_parameter", "param": "role", "message": "role must be one of owner, editor, suggester, viewer." } } ``` `type` tells you what kind of problem it is, `code` names the specific one, `param` names the field at fault when there is one, and `message` is for people reading logs, not for your code to match on. | Type | Status | Meaning | | ----------------- | ------ | ------------------------------------------------------------------------------------------------ | | `invalid_request` | 400 | The request is malformed or a parameter is missing or wrong. | | `authentication` | 401 | No key, or the key is unknown or revoked. | | `permission` | 403 | The key is fine, but the action isn’t allowed, such as a session for someone without access. | | `not_found` | 404 | The object doesn’t exist in this mode (test or live). | | `conflict` | 409 | The object’s state doesn’t allow it, such as sealing a document in the trash. | | `rate_limited` | 429 | Too many requests. Wait for `Retry-After` seconds. Reserved: the API doesn’t limit requests yet. | | `server` | 500 | Something went wrong on our side. Retry. | Codes you’ll see most often: | Code | Type | When | | -------------------- | ----------------- | ---------------------------------------------------------------------------- | | `missing_parameter` | `invalid_request` | A required field is absent. | | `invalid_parameter` | `invalid_request` | A field has the wrong type or value. | | `invalid_body` | `invalid_request` | The body isn’t a JSON object. | | `missing_key` | `authentication` | No `Authorization` header. | | `invalid_key` | `authentication` | The key isn’t one of yours, or has been revoked. | | `no_access` | `permission` | The user can’t open the document. | | `not_owner` | `permission` | Only one of the document’s owners can do this. | | `user_not_found` | `not_found` | No user with that id. Create it with `PUT /v1/users/external/{external_id}`. | | `document_not_found` | `not_found` | No document with that id, or it was deleted. | | `document_trashed` | `conflict` | The document is in the trash. Restore it first. | Each resource page lists the codes specific to its endpoints. ## Pagination [Section titled “Pagination”](#pagination) Endpoints that list things return a page: ```json { "data": [ ... ], "next": "c_2Fh8Kq" } ``` Pass `limit` (1 to 100, default 50) to choose the page size and `after` with the previous page’s `next` to get the following page. `next` is `null` on the last page. Each endpoint says what order its list is in. ```bash curl "https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/documents?limit=20&after=c_2Fh8Kq" \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` In Node, `await` a list method for one page, or loop over it with `for await` and the SDK follows `next` for you: ```ts for await (const doc of proof.users.listDocuments("usr_5Kq2Lm8R")) { console.log(doc.title); } ``` ## Idempotency [Section titled “Idempotency”](#idempotency) Proof doesn’t take idempotency keys yet: a `POST` sent twice is carried out twice, and the Node SDK doesn’t retry one that failed. Retry a `POST` that failed without an answer only where doing it twice is harmless, such as starting a session, or check first, for example by listing a document’s seals before sealing again. `PUT` and `DELETE` are idempotent already. ## Versioning [Section titled “Versioning”](#versioning) The version is in the path: `/v1`. Within `/v1`, changes are only additive: * new endpoints, and new optional parameters on existing ones * new fields in responses * new error codes and new webhook event types * new values for fields that are documented as open-ended, such as a seal’s `method` Anything that would break working code ships as `/v2`, and `/v1` keeps working for at least 12 months after `/v2` is released. The embed script follows the same rule: `v1.js` changes only in compatible ways. The [changelog](/changelog/) lists every change. ## Rate limits [Section titled “Rate limits”](#rate-limits) The API doesn’t limit requests yet. When it does, a request over the limit will get `429 rate_limited` with a `Retry-After` header saying how many seconds to wait, which the [Node SDK](/reference/node/) already honours, retrying twice by default. Writing in the editor isn’t rate limited per request: people’s edits always go first, and each agent request gets a short slice of work at a time per document, so a burst of agent edits never holds up someone’s typing. ## Resources [Section titled “Resources”](#resources) | Resource | What it’s for | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | [Users](/reference/api/users/) | Tell Proof who your users are, merge and delete them, and set their check-ins. | | [Documents](/reference/api/documents/) | Create documents, set link access, move them to the trash, export them and read their provenance. | | [Members](/reference/api/members/) | Decide who can open a document and with which role. | | [Sessions](/reference/api/sessions/) | Start the short-lived tokens the embedded editor runs on. | | [Agents](/reference/api/agents/) | List, limit and revoke the agents your users connected. | | [Agent requests](/reference/api/agent-requests/) | Approve or deny an agent from your consent page. | | [Seals](/reference/api/seals/) | Seal documents, look seals up, and let in the readers of private ones. | | [Webhook endpoints](/reference/api/webhook-endpoints/) | Choose where Proof sends events. | | [Platform](/reference/api/platform/) | Your consent, reader and document URLs, embed origins, and your own agent tools. | # Platform > Your platform's settings in each mode, the agent tools of your own that Proof's MCP server offers, and its counts. Your platform’s settings say where Proof sends people and calls: the page where they approve agents, the page where private seals’ readers sign in, where your documents are, which origins may frame the editor, and the tools of your own that agents get beside Proof’s. Each mode has its own, except the name, which both share and only a live key changes. You can set them here or in the [console](https://developer.proof.exalto.ai/console); a server that sets them on starting keeps them in step with where it runs. ## The platform object [Section titled “The platform object”](#the-platform-object) | Attribute | Type | Description | | --------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Your platform’s id. | | `slug` | string | Its short name, in your MCP address. | | `name` | string | What Proof calls your platform: on consent and seal pages, and to agents. | | `icon_url` | string or null | Your icon, which MCP clients may show beside your server: Proof’s MCP server names it in `serverInfo.icons` when an agent connects. | | `mode` | string | `test` or `live`, by the key that asked. | | `embed_origins` | array | The origins that may frame the editor and presence settings. See [Embed origins](/reference/embed/#embed-origins). | | `consent_url` | string or null | Where Proof sends people to approve an agent, with `?request=areq_…`. See [Agent requests](/reference/api/agent-requests/). Without one, agents can’t connect. | | `reader_url` | string or null | Where Proof sends a private seal’s reader to sign in, with `?request=srq_…`. See [Reader requests](/reference/api/seals/#reader-requests). | | `document_url` | string or null | Where your product shows a document, with `{document}` for its id. Links between documents in the editor, and agents’ answers, use it. | | `mcp_url` | string | Your MCP address in this mode, for your users’ agents. | ```json { "id": "plt_6Hq2Lm4R", "slug": "acme", "name": "Acme Notes", "icon_url": "https://acme.com/icon.png", "mode": "live", "embed_origins": ["https://acme.com"], "consent_url": "https://acme.com/proof/connect", "reader_url": "https://acme.com/proof/read", "document_url": "https://acme.com/docs/{document}", "mcp_url": "https://mcp.proof.exalto.ai/acme" } ``` ## Retrieve the platform [Section titled “Retrieve the platform”](#retrieve-the-platform) `GET /v1/platform` * curl ```bash curl https://api.proof.exalto.ai/v1/platform \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const platform = await proof.platform.retrieve(); ``` ## Update the platform [Section titled “Update the platform”](#update-the-platform) `PATCH /v1/platform` Only the fields you send change. `null` removes a URL. | Parameter | Type | Description | | --------------- | -------------- | ------------------------------------------------------------------------------------- | | `name` | string | Your platform’s name, in both modes: with a live key only. | | `icon_url` | string or null | For this mode: a URL of your icon, for MCP clients to show. | | `embed_origins` | array | For this mode: origins, such as `https://acme.com`; each is kept as its origin alone. | | `consent_url` | string or null | For this mode. | | `reader_url` | string or null | For this mode. | | `document_url` | string or null | For this mode, with `{document}` where the id goes. | * curl ```bash curl https://api.proof.exalto.ai/v1/platform \ -X PATCH \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"consent_url": "https://acme.com/proof/connect", "document_url": "https://acme.com/docs/{document}"}' ``` * Node ```ts const platform = await proof.platform.update({ consent_url: "https://acme.com/proof/connect", document_url: "https://acme.com/docs/{document}", }); ``` Returns the [platform](#the-platform-object). | Error | When | | ----------------------- | ------------------------------------------------------------- | | `403 live_key_required` | `name` sent with a test key. | | `400 invalid_parameter` | A URL that isn’t one, or `embed_origins` that aren’t origins. | ## Set your agent tools [Section titled “Set your agent tools”](#set-your-agent-tools) `PUT /v1/platform/tools` Gives your users’ agents tools of your own, for what your product keeps itself, such as folders and pins. Proof’s MCP server lists them beside its own, and forwards each call to `url`, signed with `secret`. Each call replaces the tools you set before, for this mode. See [Add your own agent tools](/guides/your-own-agent-tools/). | Parameter | Type | Description | | -------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | string, required | Where Proof posts tool calls. | | `secret` | string, required | At least 32 characters, which Proof signs each call with. Choose it yourself and keep it. | | `tools` | array, required | Each `{ name, title?, description, inputSchema }`, as MCP describes a tool. Names are lowercase letters, digits and underscores, and can’t be one of Proof’s own. | | `instructions` | string | What agents are told of your tools, after Proof’s own instructions. | * curl ```bash curl https://api.proof.exalto.ai/v1/platform/tools \ -X PUT \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.com/proof/tools", "secret": "'"$ACME_TOOLS_SECRET"'", "instructions": "Folders are the person'\''s own: list_folders shows them, move_document files a document in one.", "tools": [ { "name": "list_folders", "description": "The person'\''s folders, in their order.", "inputSchema": { "type": "object", "properties": {} } } ] }' ``` * Node ```ts await proof.platform.setTools({ url: "https://acme.com/proof/tools", secret: process.env.ACME_TOOLS_SECRET!, instructions: "Folders are the person's own: list_folders shows them, move_document files a document in one.", tools: [{ name: "list_folders", description: "The person's folders, in their order.", inputSchema: { type: "object", properties: {} } }], }); ``` ```json { "url": "https://acme.com/proof/tools", "tools": ["list_folders"] } ``` | Error | When | | ----------------------------------- | -------------------------------------------------------------------------------- | | `400 invalid_parameter` on `url` | Not a URL. | | `400 invalid_parameter` on `secret` | Shorter than 32 characters. | | `400 invalid_parameter` on `tools` | A tool lacks a name, description or input schema, or its name is one of Proof’s. | ## Retrieve counts [Section titled “Retrieve counts”](#retrieve-counts) `GET /v1/platform/stats` Counts for your own operations page, in the key’s mode: all time, and the last 7 days. ```json { "data": [ { "label": "Documents", "total": 1204, "week": 87 }, { "label": "Sealed documents", "total": 312, "week": 19 }, { "label": "Agents", "total": 96, "week": 8 } ] } ``` # Seals > Seal a document, list and look up its seals, and withdraw one. A seal is a snapshot of a document as it stood, with the record of how it was written and the numbers that come from it: where its words came from, whether a person was present, how many prompts and sittings it took. Each seal has a short page at `proof.exalto.ai` that anyone with the link can open, unless its owners [made its seals private](#who-reads-seals), and a seal line people paste wherever the text goes. A seal never changes once it’s made; sealing again adds a new version. See [Seals](/concepts/seals/). People can also seal from the Seal button in the editor. Either way, Proof sends `seal.created` to your [webhook endpoints](/reference/webhooks/). ## The seal object [Section titled “The seal object”](#the-seal-object) | Attribute | Type | Description | | -------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `code` | string | Nine capital letters and digits. Test seals start with `T`. | | `url` | string | The seal’s page: `https://proof.exalto.ai/` and the code. | | `version` | integer | 1 for the document’s first seal, then 2, 3 and so on. | | `sealed_at` | timestamp | When it was sealed. | | `sealed_by` | string | The Proof id of the owner who sealed it. | | `document` | string or null | The document’s id, or `null` once the document was deleted. | | `method` | string or null | The version of the method its numbers were worked out with. They never change; under a newer method, the seal page shows a labeled reassessment beside them. This and the numbers below are `null` once the seal is withdrawn. | | `line` | string or null | The seal line, ready to paste. | | `composition` | object | Percentages of the document by where its words came from, summing to 100: `typed`, `shaped`, `ai` and `external`. | | `presence` | object | `share`: percent of session time with a person present. `verified_share`: the part vouched for by check-ins. `verified`: `true` when every sitting people wrote in was verified. | | `prompts` | integer | Rounds of back-and-forth with agents. | | `sittings` | integer | Separate stretches of work. The seal line calls them sessions. | | `duration` | string | Session time across those sittings, as the seal line writes it, such as `1hr48min`. | | `attested_by` | object | Who vouches for the people named in it. `platform`: your platform’s slug. `key_ids`: the ids of the secret keys that started its writing sessions. | | `withdrawn_at` | timestamp or null | When it was withdrawn, or `null`. | ```json { "code": "AH271NSHS", "url": "https://proof.exalto.ai/AH271NSHS", "document": "doc_8Hq2Lm4R", "version": 1, "sealed_at": "2026-10-13T23:31:04Z", "sealed_by": "usr_5Kq2Lm8R", "method": "v0.2", "line": "proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min", "composition": { "typed": 71, "shaped": 16, "ai": 8, "external": 5 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min", "attested_by": { "platform": "acme", "key_ids": ["key_2Lq9Wd4T"] }, "withdrawn_at": null } ``` When every sitting people wrote in was verified by check-ins, the line ends `(verified)`; when some were, it ends with the share, such as `(73% verified)`. ## Seal a document [Section titled “Seal a document”](#seal-a-document) `POST /v1/documents/{document_id}/seals` Seals the document as it stands now, as a new version. A document with pending suggestions can’t be sealed: a seal says what the document says, so they’re accepted or rejected first. | Parameter | Type | Description | | ------------- | ---------------------- | --------------------------------------------------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `user` | string, required | The Proof id of the person sealing it. They must be one of the document’s owners. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/seals \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"user": "usr_5Kq2Lm8R"}' ``` * Node ```ts const seal = await proof.seals.create("doc_8Hq2Lm4R", { user: "usr_5Kq2Lm8R" }); console.log(seal.line); ``` Returns the [seal](#the-seal-object) with `201`. | Error | When | | ---------------------- | --------------------------------------------- | | `403 not_owner` | `user` isn’t one of the document’s owners. | | `409 document_empty` | Nothing has been written in the document. | | `409 document_trashed` | The document is in the trash. | | `409 open_suggestions` | The document has pending suggestions. | | `409 unchanged` | It reads exactly as its newest seal still up. | ## List a document’s seals [Section titled “List a document’s seals”](#list-a-documents-seals) `GET /v1/documents/{document_id}/seals` Every seal made from a document, newest first, withdrawn ones included. | Parameter | Type | Description | | ------------- | ---------------------- | ----------------------------------------- | | `document_id` | string, path, required | The document’s id. | | `limit` | integer | 1 to 100. Default 50. | | `after` | string | The `next` cursor from the previous page. | * curl ```bash curl https://api.proof.exalto.ai/v1/documents/doc_8Hq2Lm4R/seals \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const page = await proof.seals.list("doc_8Hq2Lm4R"); ``` ```json { "data": [ { "code": "AH271NSHS", "url": "https://proof.exalto.ai/AH271NSHS", "document": "doc_8Hq2Lm4R", "version": 1, "sealed_at": "2026-10-13T23:31:04Z", "sealed_by": "usr_5Kq2Lm8R", "method": "v0.2", "line": "proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min", "composition": { "typed": 71, "shaped": 16, "ai": 8, "external": 5 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min", "attested_by": { "platform": "acme", "key_ids": ["key_2Lq9Wd4T"] }, "withdrawn_at": null } ], "next": null } ``` ## Retrieve a seal [Section titled “Retrieve a seal”](#retrieve-a-seal) `GET /v1/seals/{code}` Looks a seal up by its code, such as one a reader pasted into your product. You can only retrieve seals made on your platform; anyone can open a public seal’s page at `proof.exalto.ai`. | Parameter | Type | Description | | --------- | ---------------------- | ------------------------------------- | | `code` | string, path, required | The seal’s code. Case doesn’t matter. | * curl ```bash curl https://api.proof.exalto.ai/v1/seals/AH271NSHS \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const seal = await proof.seals.retrieve("AH271NSHS"); ``` Returns the [seal](#the-seal-object), or `404 seal_not_found`. ## Withdraw a seal [Section titled “Withdraw a seal”](#withdraw-a-seal) `DELETE /v1/seals/{code}` Takes a seal down. Its page then says it was withdrawn, and when, instead of showing the text. A withdrawn seal can’t be brought back, and other versions of the same document stay up. Seals can be withdrawn but never changed. | Parameter | Type | Description | | --------- | ---------------------- | ---------------- | | `code` | string, path, required | The seal’s code. | * curl ```bash curl https://api.proof.exalto.ai/v1/seals/AH271NSHS \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const seal = await proof.seals.withdraw("AH271NSHS"); ``` Returns the [seal](#the-seal-object) with `withdrawn_at` set, and Proof sends `seal.withdrawn`. Withdrawing a seal that’s already withdrawn returns it unchanged. While a document is in its owners’ trash, its seals are down: their pages show nothing until it’s restored. ## Who reads seals [Section titled “Who reads seals”](#who-reads-seals) A document’s owners choose who reads its seals, every version alike, in the editor’s seal dialog (the [`openSeal` method](/reference/embed/#methods) opens it): * **Anyone with the link**, the default. Only these seals have a link preview. * **Private**: the document’s members; readers the owners list by email, or `*@domain` for everyone at a domain; and, if they set one, whoever has its password. A private seal’s page asks the reader for the password, or to sign in with your platform, which says who they are with a [reader request](#reader-requests). Signing in works only when you’ve set a reader URL (`reader_url` on the [platform](/reference/api/platform/)). ## Reader requests [Section titled “Reader requests”](#reader-requests) A reader request is someone asking to read a private seal, sent to your reader URL with its id, as an agent request is sent to your consent page: ```plaintext https://acme.com/proof/read?request=srq_4Wn8Kd2P ``` Your page signs the person in, then approves the request from your server with who they are and their verified email, and sends the browser to the `redirect_url` it returns. Proof lets them in if they’re a member or their email is one of the readers, and remembers them in that browser for 30 days. A request lasts 10 minutes. ### Retrieve a reader request [Section titled “Retrieve a reader request”](#retrieve-a-reader-request) `GET /v1/reader-requests/{request_id}` ```json { "id": "srq_4Wn8Kd2P", "seal": "AH271NSHS", "title": "On the Analytical Engine", "status": "pending", "expires_at": "2026-10-30T18:30:00Z" } ``` `status` is `pending`, `answered` once you’ve approved it, or `finished` once the reader is back at the seal. Returns `404 request_not_found` for an id that isn’t one of yours. ### Approve a reader request [Section titled “Approve a reader request”](#approve-a-reader-request) `POST /v1/reader-requests/{request_id}/approve` | Parameter | Type | Description | | ------------ | ---------------------- | --------------------------------------------------------------------- | | `request_id` | string, path, required | The request’s id. | | `user` | string, required | The Proof id of the person signed in to your product. | | `email` | string or null | Their email, only if you’ve verified it: Proof lets readers in by it. | * curl ```bash curl https://api.proof.exalto.ai/v1/reader-requests/srq_4Wn8Kd2P/approve \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"user": "usr_9Tb3Wc1X", "email": "grace@acme.com"}' ``` * Node ```ts const { redirect_url } = await proof.readerRequests.approve("srq_4Wn8Kd2P", { user: "usr_9Tb3Wc1X", email: "grace@acme.com", }); // then redirect the browser to redirect_url ``` ```json { "redirect_url": "https://proof.exalto.ai/AH271NSHS/continue?state=Vq2Lk7Tz1Mx9Pw3H" } ``` | Error | When | | --------------------- | ------------------------------------------------------------------------------------------------------------- | | `404 user_not_found` | The user doesn’t exist. | | `409 request_expired` | The request was answered already, or more than 10 minutes have passed. The reader starts again from the seal. | # Sessions > Start the short-lived tokens the embedded editor and presence settings run on. A session is a short-lived token that lets one of your users into one document in the [embedded editor](/reference/embed/), or into presence settings. Your server starts it with your secret key and hands the token to your page; the page passes it to Proof and never sees your key. Tokens last 15 minutes, and the embed asks your `getToken` function for a new one before the old one runs out, so an open editor keeps working for as long as the person is allowed in. See [Embed the editor](/guides/embed-the-editor/). A session token only opens the editor or presence settings. It can’t call the REST API. ## The session object [Section titled “The session object”](#the-session-object) | Attribute | Type | Description | | ------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `token` | string | The token to pass to the page, starting `est_`. | | `user` | string | The user’s Proof id. | | `document` | string or null | The document it opens, or `null` for a presence session. | | `purpose` | string | `editor` or `presence`. | | `role` | string or null | The role the user has in the document for this session: `owner`, `editor`, `suggester` or `viewer`. `null` for a presence session. | | `via` | string or null | `member` when the role comes from membership, `link` when it comes from the document’s link access. `null` for a presence session. | | `expires_at` | timestamp | When the token stops being accepted, 15 minutes after it was started. | ```json { "token": "est_q7Lr2VkX9pN4sT8wY3mB6cZ1", "user": "usr_5Kq2Lm8R", "document": "doc_8Hq2Lm4R", "purpose": "editor", "role": "owner", "via": "member", "expires_at": "2026-10-30T18:35:00Z" } ``` ## Create a session [Section titled “Create a session”](#create-a-session) `POST /v1/sessions` Starts a session for the editor, or for presence settings when you pass `purpose: "presence"`. | Parameter | Type | Description | | ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `user` | string, required | The user’s Proof id. The user must exist. | | `document` | string | The document to open. Required unless `purpose` is `presence`. | | `via` | string | `link` to let in someone who isn’t a member, with the document’s link access as their role. Leave it out for members. | | `purpose` | string | `editor` (the default) or `presence`. | | `group` | string | Up to 200 characters of yours that the session belongs to, such as the id of the person’s own sign-in session with you, so you can [end them together](#end-a-users-sessions). | ### For the editor [Section titled “For the editor”](#for-the-editor) Proof works out the user’s role when it starts the session: their member role, or with `via: "link"`, the document’s `link_access`. A member who comes by link keeps whichever role is higher. The role is checked again on every edit, so lowering it or removing the member takes effect in an open editor at once, whatever the token says. * curl ```bash curl https://api.proof.exalto.ai/v1/sessions \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"user": "usr_5Kq2Lm8R", "document": "doc_8Hq2Lm4R"}' ``` * Node ```ts const session = await proof.sessions.create({ user: "usr_5Kq2Lm8R", document: "doc_8Hq2Lm4R", }); ``` ```json { "token": "est_q7Lr2VkX9pN4sT8wY3mB6cZ1", "user": "usr_5Kq2Lm8R", "document": "doc_8Hq2Lm4R", "purpose": "editor", "role": "owner", "via": "member", "expires_at": "2026-10-30T18:35:00Z" } ``` For someone who followed a shared link: * curl ```bash curl https://api.proof.exalto.ai/v1/sessions \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"user": "usr_9Tb3Wc1X", "document": "doc_8Hq2Lm4R", "via": "link"}' ``` * Node ```ts const session = await proof.sessions.create({ user: "usr_9Tb3Wc1X", document: "doc_8Hq2Lm4R", via: "link", }); ``` ```json { "token": "est_d2Hs9QmW4kR7xV1nJ6pL8tF3", "user": "usr_9Tb3Wc1X", "document": "doc_8Hq2Lm4R", "purpose": "editor", "role": "suggester", "via": "link", "expires_at": "2026-10-30T18:35:00Z" } ``` A document in the trash still opens, only to read: for its owners when it’s in the trash for everyone, and for a member who put it in their own. The session’s `role` is then `viewer`. People who come by link aren’t added as members. Their writing is credited to them like anyone else’s, and they appear in the document’s mention list while they have it open. ### For presence settings [Section titled “For presence settings”](#for-presence-settings) A presence session opens the window where a user adds security keys and chooses how often to check in. It isn’t tied to a document. Pass its token to `mountPresenceSettings` or `openPresenceSettings` in the [embed API](/reference/embed/#presence-settings). See [Set up check-ins](/guides/set-up-check-ins/). * curl ```bash curl https://api.proof.exalto.ai/v1/sessions \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"user": "usr_5Kq2Lm8R", "purpose": "presence"}' ``` * Node ```ts const session = await proof.sessions.create({ user: "usr_5Kq2Lm8R", purpose: "presence", }); ``` ```json { "token": "est_m5Tc8Wq1Zr6Kv3Np9Ys2Bx7H", "user": "usr_5Kq2Lm8R", "document": null, "purpose": "presence", "role": null, "via": null, "expires_at": "2026-10-30T18:35:00Z" } ``` ### Errors [Section titled “Errors”](#errors) | Error | When | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `400 missing_parameter` on `document` | `purpose` is `editor` and there’s no `document`. | | `400 invalid_parameter` on `group` | Empty, or longer than 200 characters. | | `403 no_access` | The user isn’t a member, and either you didn’t pass `via: "link"` or the document’s link access is `restricted`. | | `404 user_not_found` | The user doesn’t exist. | | `404 document_not_found` | The document doesn’t exist. | | `409 document_trashed` | The document is in its owners’ trash, and the user isn’t one of its owners. | ## End a user’s sessions [Section titled “End a user’s sessions”](#end-a-users-sessions) `DELETE /v1/users/{user_id}/sessions` Ends a user’s sessions at once: all of them, or with `group`, only those started in that group. Call it when someone signs out of your product, or is signed out everywhere. Their open editors lose their connection within moments and ask your page for a new token, which your server refuses once they’re signed out. | Parameter | Type | Description | | --------- | ---------------------- | -------------------------------------------- | | `user_id` | string, path, required | The user’s Proof id. | | `group` | string, query | Only the sessions started with this `group`. | * curl ```bash curl "https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/sessions?group=sess_42" \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.users.endSessions("usr_5Kq2Lm8R", { group: "sess_42" }); ``` ```json { "deleted": 2 } ``` Caution A session token lets whoever holds it write as that user until it expires. Send it only to the person it’s for, over HTTPS, and don’t log it. Start sessions only after your own sign-in has checked who is asking. # Users > Create users under your own ids, look them up, merge and delete them, list their documents and set their check-ins. A user is one of your people. Proof gives each user its own id, `usr_…`, and keeps your id for them beside it as `external_id`. Every other endpoint, event and seal names users by Proof id. Proof has no accounts and no sign-in: you create a user with a name, and from then on Proof credits what that user writes in the editor to that name. See [Users](/concepts/users/) for how the two ids, names, pictures and deletion work. ## The user object [Section titled “The user object”](#the-user-object) | Attribute | Type | Description | | ------------- | -------------- | -------------------------------------------------------------------------------------------------------------- | | `id` | string | Proof’s id for this user, `usr_` and 8 characters. It never changes. | | `external_id` | string | Your id for this user, 1 to 128 characters, unique on your platform in each mode. | | `name` | string | The name Proof shows on their writing, comments, cursors and seals. Up to 48 characters. | | `avatar_url` | string or null | Proof’s copy of their picture, or `null` for initials. | | `detail` | string or null | A second line shown under their name where people pick someone to mention, such as an email address or a team. | | `created_at` | timestamp | When you first created the user. | ```json { "id": "usr_5Kq2Lm8R", "external_id": "u_42", "name": "Ada Lovelace", "avatar_url": "https://api.proof.exalto.ai/v1/avatars/av_9Rk3Tq", "detail": "ada@acme.com", "created_at": "2026-10-12T09:14:03Z" } ``` ## Create or update a user [Section titled “Create or update a user”](#create-or-update-a-user) `PUT /v1/users/external/{external_id}` Creates a user for your id if there isn’t one, and replaces their name, picture and detail if there is. Call it when someone signs up, and again whenever their profile changes in your product. Keep the `id` it returns beside your user. A new name shows everywhere at once, in every document and open editor, because documents name people by Proof id. Seals already made keep the name they were sealed with. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `external_id` | string, path, required | Your id for this user. URL-encode it. | | `name` | string, required | Up to 48 characters, without control or invisible characters. | | `avatar_url` | string | An `https` URL of a PNG, JPEG or WebP picture of at most 1 MB, on a public address. Proof fetches it once and keeps its own copy; send the same URL again and it isn’t fetched again. `null` removes the picture. Leave it out to keep the current one. | | `detail` | string | Up to 128 characters. `null` removes it. Leave it out to keep the current one. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/external/u_42 \ -X PUT \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Ada Lovelace", "avatar_url": "https://acme.com/avatars/42.png", "detail": "ada@acme.com" }' ``` * Node ```ts const user = await proof.users.upsert("u_42", { name: "Ada Lovelace", avatar_url: "https://acme.com/avatars/42.png", detail: "ada@acme.com", }); await db.users.update("u_42", { proof_user_id: user.id }); ``` Returns the user: `201` when it was created, `200` when it was updated. ```json { "id": "usr_5Kq2Lm8R", "external_id": "u_42", "name": "Ada Lovelace", "avatar_url": "https://api.proof.exalto.ai/v1/avatars/av_9Rk3Tq", "detail": "ada@acme.com", "created_at": "2026-10-12T09:14:03Z" } ``` | Error | When | | ---------------------------------------- | ------------------------------------------------------------------------------------- | | `400 invalid_parameter` on `name` | The name is empty, longer than 48 characters, or has control or invisible characters. | | `400 invalid_parameter` on `external_id` | Empty, or longer than 128 characters. | | `400 avatar_unreadable` | Proof couldn’t fetch the picture, or it isn’t a PNG, JPEG or WebP of at most 1 MB. | ## Retrieve a user [Section titled “Retrieve a user”](#retrieve-a-user) `GET /v1/users/{id}` | Parameter | Type | Description | | --------- | ---------------------- | -------------------- | | `id` | string, path, required | The user’s Proof id. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const user = await proof.users.retrieve("usr_5Kq2Lm8R"); ``` Returns the [user](#the-user-object), or `404 user_not_found`. ## Retrieve a user by your id [Section titled “Retrieve a user by your id”](#retrieve-a-user-by-your-id) `GET /v1/users/external/{external_id}` For when you have only your own id, for example before you’ve stored the Proof id. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------------------------- | | `external_id` | string, path, required | Your id for this user. URL-encode it. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/external/u_42 \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const user = await proof.users.retrieveByExternalId("u_42"); ``` Returns the [user](#the-user-object), or `404 user_not_found`. ## Update a user [Section titled “Update a user”](#update-a-user) `PATCH /v1/users/{id}` Changes any of a user’s fields by Proof id, including `external_id`: use it when your ids change, in a migration or when someone switches login method. Documents, seals and agents follow the Proof id, so nothing else moves. | Parameter | Type | Description | | ------------- | ---------------------- | -------------------------------------------------------------------- | | `id` | string, path, required | The user’s Proof id. | | `external_id` | string | A new id of yours for this user. It must not belong to another user. | | `name` | string | As in [Create or update a user](#create-or-update-a-user). | | `avatar_url` | string or null | As above. | | `detail` | string or null | As above. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R \ -X PATCH \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"external_id": "2f6c1e0a-7b1d-4c55-9e0f-3a8d2b6c9e41"}' ``` * Node ```ts await proof.users.update("usr_5Kq2Lm8R", { external_id: "2f6c1e0a-7b1d-4c55-9e0f-3a8d2b6c9e41" }); ``` Returns the [user](#the-user-object). | Error | When | | ----------------------- | -------------------------------------------------------------------------------------------- | | `409 external_id_taken` | Another user already has that `external_id`. Merge the two first if they’re the same person. | | `404 user_not_found` | No user with that id. | ## Delete a user [Section titled “Delete a user”](#delete-a-user) `DELETE /v1/users/{id}` Removes the user from Proof. Call it when someone deletes their account in your product. Their `external_id` is free again afterwards; using it again creates a new person. * Documents they wrote in keep the last name they had, without their picture, so the record of who wrote what stays readable. * Documents they owned pass to their other members: those with the highest role become owners. A document with no other member passes to someone else who wrote in it, if anyone did, and is otherwise deleted, with its seals. One in the trash with no owner left is deleted. * Their agents are revoked, and their open editors close. * Their security keys and check-in history are deleted. | Parameter | Type | Description | | --------- | ---------------------- | -------------------- | | `id` | string, path, required | The user’s Proof id. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.users.delete("usr_5Kq2Lm8R"); ``` ```json { "id": "usr_5Kq2Lm8R", "deleted": true } ``` Caution A document that still has other members isn’t deleted when its owner is. Make someone an owner first with [`PUT /v1/documents/{document_id}/members/{user_id}`](/reference/api/members/#add-or-change-a-member) if you want a particular person to own it; otherwise every member with the highest role becomes an owner. ## Merge users [Section titled “Merge users”](#merge-users) `POST /v1/users/{id}/merge` Moves everything from one user to another, then deletes the first. Use it when someone who started without an account signs up or signs in, so their work comes with them. See [Anonymous users](/guides/anonymous-users/). What moves: * their documents and memberships (where both users were members, the higher role is kept), and documents in their own trash * the credit for everything they and their agents wrote, typed, shaped or accepted, in every document * their security keys (after `into`’s own), their check-ins, their presence signals, and their check-in interval unless `into` already has one * their agents. An agent for a client `into` already has an agent for joins that one, keeping the higher role in each document and its tokens, so it needn’t be approved again. Open editors of the user merged from lose their sessions: start new ones as `into`. | Parameter | Type | Description | | --------- | ---------------------- | ------------------------------------------------------ | | `id` | string, path, required | The user to merge from. It’s deleted afterwards. | | `into` | string, required | The Proof id of the user to merge into. It must exist. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_2Hd7Np4Q/merge \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"into": "usr_5Kq2Lm8R"}' ``` * Node ```ts const user = await proof.users.merge("usr_2Hd7Np4Q", { into: "usr_5Kq2Lm8R" }); ``` Returns the user merged into. ```json { "id": "usr_5Kq2Lm8R", "external_id": "u_42", "name": "Ada Lovelace", "avatar_url": null, "detail": "ada@acme.com", "created_at": "2026-10-30T18:20:00Z" } ``` | Error | When | | --------------------------------- | -------------------------- | | `400 invalid_parameter` on `into` | `into` is the same user. | | `404 user_not_found` | Either user doesn’t exist. | ## List a user’s documents [Section titled “List a user’s documents”](#list-a-users-documents) `GET /v1/users/{id}/documents` The documents a user is a member of, most recently edited or shared with them first, leaving out those in the trash. Use it to build your document list; keep your own folders and pins beside it, keyed by document id. | Parameter | Type | Description | | --------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string, path, required | The user’s Proof id. | | `ids` | string | Comma-separated document ids: only those of them the user may open now, as a member or by link access. For titles and access of documents you keep elsewhere, such as in a notification list. | | `trashed` | boolean | `true` lists what’s in their trash instead, most recently trashed first: documents an owner trashed for everyone, where they’re an owner, and documents they put in their own. Default `false`. | | `limit` | integer | 1 to 100. Default 50. | | `after` | string | The `next` cursor from the previous page. | * curl ```bash curl "https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/documents?limit=2" \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const page = await proof.users.listDocuments("usr_5Kq2Lm8R", { limit: 2 }); ``` Each item is a [document](/reference/api/documents/#the-document-object) with two more fields: `role`, this user’s role in it (the higher of their member role and the link’s), and `added_at`, when they became a member, or `null` for one they reach by link. With `trashed=true`, each has instead `everyone`, `true` when it’s in the trash for everyone and `false` when only in theirs, and `in_trash_since`. ```json { "data": [ { "id": "doc_8Hq2Lm4R", "name": "On the Analytical Engine", "title": "On the Analytical Engine", "owners": ["usr_5Kq2Lm8R"], "link_access": "restricted", "created_at": "2026-10-13T21:12:40Z", "edited_at": "2026-10-13T23:30:52Z", "trashed_at": null, "role": "owner", "added_at": "2026-10-13T21:12:40Z" }, { "id": "doc_3Wp7Zc1V", "name": null, "title": "Notes for Thursday", "owners": ["usr_9Tb3Wc1X"], "link_access": "suggest", "created_at": "2026-10-11T15:02:11Z", "edited_at": "2026-10-12T08:47:30Z", "trashed_at": null, "role": "suggester", "added_at": "2026-10-11T15:40:02Z" } ], "next": "c_2Fh8Kq" } ``` ## Empty a user’s trash [Section titled “Empty a user’s trash”](#empty-a-users-trash) `DELETE /v1/users/{id}/trash` `DELETE /v1/users/{id}/trash/{document_id}` Deletes for good everything in a user’s trash, or one document there. A document in the trash for everyone, where they’re an owner, is [deleted](/reference/api/documents/#delete-a-document) for everyone; one in their own trash they leave, as a member, and it stays for the others. | Parameter | Type | Description | | ------------- | ---------------------- | ---------------------------- | | `id` | string, path, required | The user’s Proof id. | | `document_id` | string, path | One document in their trash. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/trash \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.users.emptyTrash("usr_5Kq2Lm8R"); await proof.users.deleteFromTrash("usr_5Kq2Lm8R", "doc_8Hq2Lm4R"); ``` ```json { "user": "usr_5Kq2Lm8R", "deleted": 3 } ``` For one document, `{ "user": …, "document": …, "deleted": true }`, or `404 document_not_found` when it isn’t in their trash. ## End a user’s sessions [Section titled “End a user’s sessions”](#end-a-users-sessions) `DELETE /v1/users/{id}/sessions` Ends a user’s editor and presence sessions, all of them or one `group`’s, as when they sign out. Described with the other session endpoints: see [End a user’s sessions](/reference/api/sessions/#end-a-users-sessions). ## List a user’s agents [Section titled “List a user’s agents”](#list-a-users-agents) `GET /v1/users/{id}/agents` The agents a user has connected, newest first. This endpoint is described with the other agent endpoints: see [List a user’s agents](/reference/api/agents/#list-a-users-agents). ## Retrieve check-in settings [Section titled “Retrieve check-in settings”](#retrieve-check-in-settings) `GET /v1/users/{id}/checkins` How often a user is asked to check in with a security key while they write, and how many keys they have. See [Presence and check-ins](/concepts/presence-and-check-ins/). | Parameter | Type | Description | | --------- | ---------------------- | -------------------- | | `id` | string, path, required | The user’s Proof id. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/checkins \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const checkins = await proof.users.retrieveCheckins("usr_5Kq2Lm8R"); ``` ```json { "user": "usr_5Kq2Lm8R", "every_minutes": 10, "security_keys": 2 } ``` | Attribute | Type | Description | | --------------- | --------------- | ------------------------------------------------------------------------------------------------------ | | `user` | string | The user’s Proof id. | | `every_minutes` | integer or null | `1`, `5`, `10` or `30`: about how often they’re asked while writing. `null` when check-ins are off. | | `security_keys` | integer | How many security keys they’ve added. Keys are added only in presence settings, on Proof’s own domain. | ## Update check-in settings [Section titled “Update check-in settings”](#update-check-in-settings) `PUT /v1/users/{id}/checkins` Turns check-ins on or off, or changes how often they’re asked. People can also change this themselves in the [presence settings window](/guides/set-up-check-ins/), where they add their keys. | Parameter | Type | Description | | --------------- | ------------------------- | -------------------------------------------------------- | | `id` | string, path, required | The user’s Proof id. | | `every_minutes` | integer or null, required | `1`, `5`, `10` or `30`, or `null` to turn check-ins off. | * curl ```bash curl https://api.proof.exalto.ai/v1/users/usr_5Kq2Lm8R/checkins \ -X PUT \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"every_minutes": 5}' ``` * Node ```ts await proof.users.updateCheckins("usr_5Kq2Lm8R", { every_minutes: 5 }); ``` ```json { "user": "usr_5Kq2Lm8R", "every_minutes": 5, "security_keys": 2 } ``` | Error | When | | ------------------------------------------ | ------------------------------------------------------------------------------ | | `400 invalid_parameter` on `every_minutes` | Not one of `1`, `5`, `10`, `30` or `null`. | | `409 no_security_keys` | The user has no security keys yet. Open presence settings for them to add one. | # Webhook endpoints > Choose where Proof sends events, and get the secret to check they came from Proof. A webhook endpoint is a URL on your server where Proof sends events: a comment, a mention, a suggestion accepted, a document an agent created, a seal. You turn them into notifications, emails and updates in your own lists. You can manage endpoints here or in the [console](https://developer.proof.exalto.ai/console). See [Send notifications](/guides/notifications/) for a worked example and [Webhook events](/reference/webhooks/) for every event and its payload. Test and live mode have separate endpoints: events from test documents go only to endpoints created with a test key. ## The webhook endpoint object [Section titled “The webhook endpoint object”](#the-webhook-endpoint-object) | Attribute | Type | Description | | ------------ | --------- | ----------------------------------------------------------------------------------------------------- | | `id` | string | The endpoint’s id, starting `we_`. | | `url` | string | Where Proof posts events. | | `events` | array | The event types it receives, or `["*"]` for all of them, including types added later. | | `created_at` | timestamp | When it was created. | | `secret` | string | The signing secret, starting `whsec_`. Returned only when you create the endpoint or roll its secret. | ```json { "id": "we_5Kd2Hs8N", "url": "https://acme.com/proof/events", "events": ["thread.created", "thread.replied", "mention.created", "seal.created"], "created_at": "2026-10-30T18:20:00Z" } ``` ## Create a webhook endpoint [Section titled “Create a webhook endpoint”](#create-a-webhook-endpoint) `POST /v1/webhook-endpoints` | Parameter | Type | Description | | --------- | ---------------- | --------------------------------------------------------------------------------------------------- | | `url` | string, required | An `https` URL. With a test key, any URL, such as `http://localhost:3000/proof/events` or a tunnel. | | `events` | array, required | Event types, such as `["mention.created", "seal.created"]`, or `["*"]` for all. | * curl ```bash curl https://api.proof.exalto.ai/v1/webhook-endpoints \ -H "Authorization: Bearer $PROOF_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.com/proof/events", "events": ["thread.created", "thread.replied", "mention.created", "seal.created"] }' ``` * Node ```ts const endpoint = await proof.webhookEndpoints.create({ url: "https://acme.com/proof/events", events: ["thread.created", "thread.replied", "mention.created", "seal.created"], }); // Store endpoint.secret now: it isn't shown again. ``` Returns the [endpoint](#the-webhook-endpoint-object) with its `secret`, with `201`. ```json { "id": "we_5Kd2Hs8N", "url": "https://acme.com/proof/events", "events": ["thread.created", "thread.replied", "mention.created", "seal.created"], "created_at": "2026-10-30T18:20:00Z", "secret": "whsec_R4n8Vq2Lk7Tz1Mx9Pw3Hc6Bs" } ``` | Error | When | | ----------------------------------- | -------------------------------------------- | | `400 invalid_parameter` on `url` | Not a URL, or, with a live key, not `https`. | | `400 invalid_parameter` on `events` | An event type doesn’t exist. | ## List webhook endpoints [Section titled “List webhook endpoints”](#list-webhook-endpoints) `GET /v1/webhook-endpoints` Your endpoints in the current mode, newest first. Secrets aren’t included. | Parameter | Type | Description | | --------- | ------- | ----------------------------------------- | | `limit` | integer | 1 to 100. Default 50. | | `after` | string | The `next` cursor from the previous page. | * curl ```bash curl https://api.proof.exalto.ai/v1/webhook-endpoints \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const page = await proof.webhookEndpoints.list(); ``` ```json { "data": [ { "id": "we_5Kd2Hs8N", "url": "https://acme.com/proof/events", "events": ["thread.created", "thread.replied", "mention.created", "seal.created"], "created_at": "2026-10-30T18:20:00Z" } ], "next": null } ``` ## Roll a signing secret [Section titled “Roll a signing secret”](#roll-a-signing-secret) `POST /v1/webhook-endpoints/{endpoint_id}/roll-secret` Issues a new signing secret. For the next 24 hours Proof signs each event with both the old and the new secret, so you can deploy the new one without dropping events. See [Verifying signatures](/reference/webhooks/). | Parameter | Type | Description | | ------------- | ---------------------- | ------------------ | | `endpoint_id` | string, path, required | The endpoint’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/webhook-endpoints/we_5Kd2Hs8N/roll-secret \ -X POST \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts const { secret } = await proof.webhookEndpoints.rollSecret("we_5Kd2Hs8N"); ``` Returns the [endpoint](#the-webhook-endpoint-object) with its new `secret`. ## Delete a webhook endpoint [Section titled “Delete a webhook endpoint”](#delete-a-webhook-endpoint) `DELETE /v1/webhook-endpoints/{endpoint_id}` Stops sending events to the endpoint. Events already on their way, including retries, are dropped. | Parameter | Type | Description | | ------------- | ---------------------- | ------------------ | | `endpoint_id` | string, path, required | The endpoint’s id. | * curl ```bash curl https://api.proof.exalto.ai/v1/webhook-endpoints/we_5Kd2Hs8N \ -X DELETE \ -H "Authorization: Bearer $PROOF_SECRET_KEY" ``` * Node ```ts await proof.webhookEndpoints.delete("we_5Kd2Hs8N"); ``` ```json { "id": "we_5Kd2Hs8N", "deleted": true } ``` ## Delivery [Section titled “Delivery”](#delivery) Each event is a `POST` with a JSON body and a `Proof-Signature` header. Check the signature before you trust the body: `proof.webhooks.verify(rawBody, header, secret)` does it in Node. Answer with any `2xx` within 10 seconds; anything else, or no answer, is retried with backoff for 3 days. Events can arrive more than once or out of order, so skip ids you’ve already handled. The [webhook events reference](/reference/webhooks/) has the signature format and every event’s payload. # Embed API > Every option, event and method of the editor you embed with @exalto/proof-embed, and what your page needs to allow it. The embed puts the Proof of Thought editor in your page as an iframe served from `embed.proof.exalto.ai`. Your page passes it a document id and a way to get session tokens; everything the person does inside it goes straight to Proof. For a walkthrough, see [Embed the editor](/guides/embed-the-editor/). ## Install [Section titled “Install”](#install) From npm, for pages you bundle: ```bash npm install @exalto/proof-embed ``` ```ts import { mountEditor, mountPresenceSettings, openPresenceSettings } from "@exalto/proof-embed"; ``` Or with a script tag, which defines `window.Proof` with the same three functions: ```html ``` The package is a few kilobytes: it creates the iframe and passes messages to it. The editor itself always loads from `embed.proof.exalto.ai`, so it stays current without a new release of your page. `v1.js` and `@exalto/proof-embed` 1.x only change in ways that keep your code working. ## mountEditor [Section titled “mountEditor”](#mounteditor) ```ts const editor = mountEditor(element, options); ``` `element` is the container the iframe goes in; it replaces anything inside it. `mountEditor` returns the editor at once and loads it in the background: wait for the [`ready` event](#events) before calling methods that need the document. page.ts ```ts const editor = mountEditor(document.querySelector("#editor")!, { document: "doc_8Hq2Lm4R", getToken: async () => { const response = await fetch("/proof/session?doc=doc_8Hq2Lm4R", { method: "POST" }); return (await response.json()).token; }, comments: "right", colorScheme: "auto", }); ``` ### Options [Section titled “Options”](#options) | Option | Type | Default | What it does | | ------------- | ------------------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `document` | `string` | required | The document to open. | | `getToken` | `() => Promise` | required | Returns a fresh [session token](/reference/api/sessions/) for this document and the person viewing it. | | `mode` | `"edit"` or `"suggest"` | `"edit"` where the role allows | Whether the person’s changes are made or proposed. A suggester always suggests and a viewer only reads, whatever you pass. | | `colorScheme` | `"light"`, `"dark"` or `"auto"` | `"light"` | `"auto"` follows the person’s system setting. | | `toolbar` | `"header"`, `"floating"` or `"none"` | `"header"` | Where the format bar goes: along the top, floating at the bottom, or nowhere (Markdown shortcuts and the ⌘K palette still work). | | `comments` | `"right"`, `"left"` or `"hidden"` | `"right"` | Where comment and suggestion cards go. Where the frame is too narrow, they go in a panel. `"hidden"` hides comments but not suggestions. | | `rails` | `"left"`, `"right"` or `"hidden"` | `"left"` | Where the attribution rail goes, beside each line. | | `highlight` | `boolean` | `false` | Tint text by who wrote it. | | `height` | `"auto"` or `"fill"` | `"auto"` | See [Sizing](#sizing). | | `target` | `{ kind, id }` | none | A comment (`kind: "comment"`, a thread id) or suggestion to open once the document has loaded, as from a notification. | | `autofocus` | `boolean` | `true` | Put the caret in the text once it loads. Pass `false` when your page shows something first, and call `focus()` when it’s ready. | | `origin` | `string` | where `v1.js` came from, or `https://embed.proof.exalto.ai` | Where Proof serves the editor. | These shape the editor around your own page’s controls: | Option | Type | What it does | | -------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `headerStart`, `headerEnd` | `number` | Pixels kept free at the start and end of the editor’s header, for controls of your own laid over the frame, such as a menu button or a notification bell. | | `pageLeft` | `number` | How far, in pixels, the frame’s left edge is from your page’s, as beside a sidebar. The editor’s dialogs then centre on your page rather than the frame, as far as the frame allows. Update it with `setOptions` when the sidebar resizes. | | `commands` | `{ title, items }[]` | Groups of your own items for the editor’s ⌘K palette. Each item is `{ id, label, hint?, checked?, disabled? }`; choosing one sends a [`command` event](#events) with its `id`. | | `shortcuts` | `string[]` | Keys your page listens for, such as `"mod+k"`, that the editor passes on as a `shortcut` event while it has focus. | | `sealNote` | `{ text, action? }` or `null` | A line for the seal dialog, such as how long a guest’s documents last, with an optional `action: { label, command }` that sends a `command` event. | | `trashActions` | `boolean` | For a document in the person’s trash, the card at the foot offers Restore and Delete forever, sending `command` events `restore` and `delete`. | Change any of these after mounting with `editor.setOptions({ ... })`. What you set is kept if the frame reloads, as it does when the person’s access changes. ### Tokens [Section titled “Tokens”](#tokens) The editor calls `getToken` when it mounts, and again two minutes before each token lapses (tokens last 15 minutes), so a page left open keeps working; an open connection carries on with the new token. If a call fails, the editor keeps the person’s changes and the embed tries again after 1, 2, 5, 10 and 30 seconds. If it still fails, as when your server refuses because the person lost access, the editor emits an `error` event with code `token_rejected` and stops asking. Your server makes the token with [`POST /v1/sessions`](/reference/api/sessions/). Check that the signed-in person may open the document before you do: the token is how Proof knows who is writing. ## Events [Section titled “Events”](#events) ```ts const stop = editor.on("title", ({ title }) => (document.title = `${title} · Acme Notes`)); stop(); // stops listening ``` `on` returns a function that removes the handler. | Event | Payload | When | | ------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ready` | `{ document, role, mode, user }` | The document has loaded. `role` is `"owner"`, `"editor"`, `"suggester"` or `"viewer"`; `user` is the person’s Proof id. | | `title` | `{ title, name }` | The document’s title changed: `name` is the name an editor gave it, or `null`; `title` is that, or its first line. Also sent once after `ready`. | | `save-state` | `{ state, unsaved }` | `"saving"` while changes are on their way, `"saved"` once Proof has them, `"offline"` while the connection is down. `unsaved` is `true` while some change hasn’t reached Proof, for warning before the page closes. Changes made offline are kept and sent on reconnecting. | | `status` | `{ mode, comments, openComments, sealed, role, trashed }` | What your own controls show of the editor, whenever it changes: its mode, whether comments are shown and how many are open, whether it has a seal, the person’s role, and whether it’s in their trash. | | `seal` | `{ code, url, line }` | The person sealed the document with the editor’s Seal button. The [`seal.created` webhook](/reference/webhooks/#sealcreated) follows. | | `open-document` | `{ document, href }` | The person followed a link to another document of yours. Navigate to it in your own way; the editor doesn’t. | | `thread-read` | `{ thread }` | The person read a thread, by opening it or writing in it: clear your notifications of it. | | `command` | `{ id }` | The person chose one of your [`commands`](#options), or a `sealNote` or trash action. | | `shortcut` | `{ key }` | The person pressed one of your `shortcuts` in the editor. | | `presence-settings` | `{}` | The person asked for presence settings, from a check-in or the breakdown. Open them, framed or in their window. | | `overlay` | `{ open }` | A dialog opened in the frame, or closed: dim your own controls around it if you like. | | `settings` | options | The person changed a display setting from inside the editor, such as the theme or where comments go: keep it, and pass it back next time. | | `height` | `{ height }` | The document’s height changed. With `height: "auto"`, the embed resizes the frame itself. | | `error` | `{ code, message }` | Something stopped the editor. See below. | ### Error codes [Section titled “Error codes”](#error-codes) | Code | What happened | | ------------------ | --------------------------------------------------------------------- | | `token_rejected` | `getToken` failed for good, or returned a token Proof doesn’t accept. | | `no_access` | The token’s person can no longer open the document. | | `document_deleted` | The document was deleted while it was open. | Handle the event to show your own message, or to send the person elsewhere. On a page whose origin isn’t one of your [embed origins](#embed-origins), the browser refuses to show the frame at all, and no event comes. ## Methods [Section titled “Methods”](#methods) | Method | What it does | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `editor.focus()` | Puts the caret back where it was in the document. | | `editor.setMode(mode)` | Switches between `"edit"` and `"suggest"`, within what the role allows. | | `editor.print()` | Prints the document, typeset, without the editor around it. | | `editor.export(format)` | Resolves to the document as `"markdown"` or `"html"` text, or as a `Blob` for `"docx"`. Pending suggestions are left out. | | `editor.openBreakdown()` | Opens the breakdown behind the live seal: where the words came from, presence, prompts and sittings. | | `editor.openSeal()` | Opens the owner’s seal dialog: seal, choose who reads its seals, withdraw them. | | `editor.openPalette()` | Opens the ⌘K palette. | | `editor.showTarget({ kind, id })` | Scrolls to a comment or suggestion and opens its card. | | `editor.comment()` | Starts a comment on the selection. | | `editor.toggleComments()` | Shows comments beside the text or hides them; in the narrow panel, opens or closes it. | | `editor.setOptions(options)` | Changes [options](#options) after mounting. | | `editor.destroy()` | Removes the iframe and its listeners. Changes already made are kept. | Methods return promises, which resolve once the editor has done it. `editor.role` and `editor.mode` hold the current role and mode once `ready` has fired. ## Presence settings [Section titled “Presence settings”](#presence-settings) Security keys must be added on Proof’s own domain, so people add them, and choose how often to check in, in Proof’s presence settings: framed in your own settings page, or in a small window of their own. Both take a token from [`POST /v1/sessions`](/reference/api/sessions/) with `purpose: "presence"`. ### mountPresenceSettings [Section titled “mountPresenceSettings”](#mountpresencesettings) ```ts const presence = mountPresenceSettings(element, { getToken: () => fetch("/proof/presence-session", { method: "POST" }).then((r) => r.json()).then((r) => r.token), platform: "acme", mode: "live", }, (choice) => console.log(choice)); // { keys: 1, everyMinutes: 10 } ``` `platform` is your platform’s slug and `mode` the key’s mode (`"live"` by default): framed, presence settings load only on your embed origins. The callback hears each change. `presence.destroy()` removes the frame. Browsers that won’t create a security key in a frame need the window instead. ### openPresenceSettings [Section titled “openPresenceSettings”](#openpresencesettings) ```ts button.addEventListener("click", async () => { const result = await openPresenceSettings({ getToken: () => fetch("/proof/presence-session", { method: "POST" }).then((r) => r.json()).then((r) => r.token), }); console.log(result); // { keys: 1, everyMinutes: 10 } }); ``` Call it from a click or key press, or the browser blocks the window. The promise resolves when the person closes the window, with how many keys they have and their check-in interval in minutes (`null` when check-ins are off). See [Set up check-ins](/guides/set-up-check-ins/). ## Embed origins [Section titled “Embed origins”](#embed-origins) The editor loads only on the origins you list under **Embed origins** in the [console](https://developer.proof.exalto.ai/console). Proof serves it with a `Content-Security-Policy: frame-ancestors` header naming exactly those origins, so another site can’t frame your users’ documents. List each origin in full, for example `https://acme.com` and `https://www.acme.com`. In test mode, `http://localhost` and `http://127.0.0.1` on any port are allowed as well. Set them with [`PATCH /v1/platform`](/reference/api/platform/) too. If your own page sends a Content Security Policy, it needs: | Directive | Value | For | | ------------ | ------------------------------- | ----------------------------------- | | `frame-src` | `https://embed.proof.exalto.ai` | The editor and the presence window. | | `script-src` | `https://embed.proof.exalto.ai` | Only with the `v1.js` script tag. | The iframe asks for two permissions: `publickey-credentials-get`, for check-ins with a security key, and `clipboard-write`, for copying a seal line; framed presence settings ask for `publickey-credentials-create` too, to add a key. If your page sends a `Permissions-Policy` header, it must not deny them to `https://embed.proof.exalto.ai`, or check-ins can’t run. ## Sizing [Section titled “Sizing”](#sizing) With `height: "auto"`, the iframe grows and shrinks with the document, and your page scrolls as it would with any long content. Use it when the document sits in the flow of a page. With `height: "fill"`, the iframe fills its container and the document scrolls inside it. Give the container a height, for example with flex or grid. Use it for a full-window editor beside your own sidebar. The editor is at least 320 pixels tall either way, and needs 720 pixels of width to put comments beside the text. ## Keyboard and focus [Section titled “Keyboard and focus”](#keyboard-and-focus) While the editor has focus, keys go to it: ⌘K (Ctrl+K) opens its palette for this document, ⌘P prints the document, and Tab indents list items. Press Esc, then Tab, to move focus out of the editor to the next control on your page. Shortcuts your page listens for on `window` don’t fire while the editor has focus, unless you list them in `shortcuts`. ## Appearance [Section titled “Appearance”](#appearance) You choose the colour scheme, where the toolbar, comments and attribution rail go, and room in the header for your own controls. The rest is fixed, so every Proof editor reads the same way to the people who verify what was written in it: * The editor’s typefaces and spacing. * The seal’s colours, which stand for where words came from. * The blocks people can write: headings, lists, checklists, quotes, code, links, images and tables. You can’t add your own. There is no custom CSS. If your page needs something the options don’t give, [tell us](mailto:hello@exalto.ai). ## Storage and cookies [Section titled “Storage and cookies”](#storage-and-cookies) The editor sets no cookies and needs none: everything rides on the session token. Browsers keep a framed site’s storage apart from the same site opened directly, so the embed doesn’t rely on it. Display settings a person changes in the editor come to your page as a `settings` event, for you to keep with your other preferences and pass back as options. ## Browsers [Section titled “Browsers”](#browsers) The editor supports the two latest versions of Chrome, Edge, Firefox and Safari, on desktop and on iOS and Android. Check-ins with a security key need a browser that allows WebAuthn in a frame, which those versions do. Older browsers aren’t supported. # MCP tools > The MCP server your users' agents connect to, how agents are authorised, and every tool they can call. Your users’ agents (Claude, Codex, or any client that speaks the Model Context Protocol) reach their documents through one MCP server per platform, run by Proof. Agents talk to Proof directly: your server approves each agent once, and never relays its edits or holds its tokens. For the approval step, see [Connect agents](/guides/connect-agents/). ## Your platform’s server [Section titled “Your platform’s server”](#your-platforms-server) ```text https://mcp.proof.exalto.ai/acme ``` The last part is your platform’s slug, from the [console](https://developer.proof.exalto.ai/console). In test mode the address is `https://mcp.proof.exalto.ai/acme/test`, and connects agents to your test data. Show the address wherever your users connect agents; [`GET /v1/platform`](/reference/api/platform/) gives it for the key’s mode as `mcp_url`. An agent’s tokens work only on the platform that issued them, so someone who uses two Proof platforms connects their agent to each, and the agent keeps the two apart by server name (`acme`’s `read_document`, say, beside another platform’s). The server names itself to agents by your slug and platform name, with your icon if you set `icon_url` on the [platform](/reference/api/platform/). It speaks Streamable HTTP, statelessly, with JSON responses. It supports MCP protocol versions `2025-11-25`, `2025-06-18`, `2025-03-26` and `2024-11-05`, and answers a client asking for another with the newest. A request may batch up to 20 messages. ## How an agent is authorised [Section titled “How an agent is authorised”](#how-an-agent-is-authorised) An agent needs nothing but the address. The rest follows the MCP authorization spec, with OAuth 2.1: 1. The agent calls the server without a token and gets `401` with a `WWW-Authenticate` header pointing at the protected resource metadata, `https://mcp.proof.exalto.ai/.well-known/oauth-protected-resource/acme`. 2. That names the authorization server, whose issuer is the same address as the MCP server. Its metadata is at `https://mcp.proof.exalto.ai/.well-known/oauth-authorization-server/acme`. 3. The agent identifies itself in one of two ways. Most use a client ID metadata document: its `client_id` is the https address of a JSON document describing it, which Proof fetches (from public addresses only). Others register with dynamic client registration and get a `client_id` back. Either way, the name the agent gives is the name people see, and the domain it comes from is shown beside it. 4. The agent opens the person’s browser at the authorize endpoint, with PKCE (`S256`; plain is refused). Its redirect URI must be one it registered, or for one on a loopback address, the same on any port. 5. Proof has no sign-in of its own, so it sends the browser to your consent page with an [agent request](/reference/api/agent-requests/). You sign the person in, ask them, and approve or deny from your server. 6. Proof sends the browser back to the agent with an authorization code, and the agent exchanges it for tokens. Access tokens last an hour, and work only at the address that issued them. Refresh tokens change each time they are used, and lapse after 90 days without use; one used twice ends all the agent’s tokens, as someone else may hold a copy. Revoking the agent, through [`DELETE /v1/agents/:agent_id`](/reference/api/agents/) or by deleting its person, ends both at once. ## What an agent may do [Section titled “What an agent may do”](#what-an-agent-may-do) An agent acts for one person, and can never do more than they can: * With `access: "all"`, it reaches every document its person can open, editing at most. * With `access: "chosen"`, it reaches only the documents its person, or your server, gave it a role in: `editor`, `suggester` or `viewer`. What it may do in a document is the lower of that and its person’s role there now, checked on every call. If Ada becomes a viewer of a document, Ada’s agent can only read it from that moment. An agent never owns a document, though it can start one for its person with `create_document`. A document the agent may not open answers as if it didn’t exist: “You have no access to document doc_8Hq2Lm4R.” ### Credit [Section titled “Credit”](#credit) Everything an agent writes, suggests or comments is credited to it, under the name its client registered and its person’s current name: **Claude Code (Ada Lovelace’s agent)**. Its words count as AI on the seal until a person shapes them. See [Provenance](/concepts/provenance/). Writing tools take an optional `instruction`: what the person asked for, in their words where the agent has them. It is shown beside the text the agent wrote, labelled as reported by the agent. ### Suggest by default [Section titled “Suggest by default”](#suggest-by-default) The server’s instructions tell agents to propose their changes, with `suggest: true`, unless their person asked them to edit the document directly. Each suggestion shows in the editor for people to accept or reject, and its optional `comment` starts the suggestion’s discussion. Where the agent may only suggest, every edit is a suggestion whatever it passes. ## Tools [Section titled “Tools”](#tools) Every tool that works on one document takes its `document_id`. Every tool that writes to a document also takes these optional arguments: | Argument | Type | What it does | | ------------- | ------- | ---------------------------------------------------------- | | `instruction` | string | What the person asked for, shown beside the text written. | | `suggest` | boolean | Propose the change instead of making it. | | `comment` | string | With `suggest`, a note for whoever reviews the suggestion. | Blocks are addressed by the ids `read_document` gives them, such as `1234:5`. Content goes in and comes out as GitHub-flavoured Markdown. ### Finding documents [Section titled “Finding documents”](#finding-documents) | Tool | Arguments | What it does | | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_documents` | `cursor` | The documents the agent may open, newest first, 100 at a time, with what it may do in each: its person’s documents with `access: "all"`, or those it was given. Pass the `next_cursor` it returns for more. | | `search_documents` | `query` | Blocks matching words across those documents, best first, at most 20, each with its `document_id`, block id and the text around the match. `"quotes"` match a phrase, `or` goes between alternatives, and `-word` excludes. | ### Managing documents [Section titled “Managing documents”](#managing-documents) | Tool | Arguments | What it does | | ------------------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `create_document` | `markdown`, `instruction` | Starts a document owned by the agent’s person, with the agent as an editor. Its first line is its title. Returns its `document_id` and link. | | `rename_document` | `document_id`, `name` | Names the document for everyone; an empty name calls it by its first line again. Needs edit access. | | `list_trash` | none | The documents in its person’s trash that the agent may restore. | | `trash_document` | `document_id` | Moves a document to its person’s trash: for everyone if they own it, from their own list if it was shared with them. Deleted for good after 30 days. | | `restore_document` | `document_id` | Brings a document back from its person’s trash, as it was. | The three trash tools work only on documents the agent was given edit access to, or with `access: "all"`, and agents are told to use them only when their person asks. So are `rename_document` and `create_document`. ### Reading [Section titled “Reading”](#reading) | Tool | Arguments | What it does | | --------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read_document` | `document_id`, `from_block_id` | The document as Markdown, each block with its id, and its pending suggestions with theirs. About 40,000 characters at a time: each part says the `from_block_id` to continue from, and the first part has the outline. | ### Editing [Section titled “Editing”](#editing) | Tool | Arguments | What it does | | ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `replace_text` | `document_id`, `find`, `replace`, `block_id`, `all` | Replaces plain text inside blocks, keeping its formatting. Matches within one run of text. `block_id` limits it to one block; `all` replaces every match instead of the first. | | `insert_markdown` | `document_id`, `after_block_id`, `markdown` | Inserts Markdown after a block, or at the top with `"start"`. | | `replace_block` | `document_id`, `block_id`, `markdown` | Replaces one block with Markdown, which may be several blocks. | | `delete_block` | `document_id`, `block_id` | Deletes one block, images included. | | `append_markdown` | `document_id`, `markdown` | Adds Markdown at the end. | All five also take `instruction`, `suggest` and `comment`, and answer with the blocks they changed. ### Suggestions [Section titled “Suggestions”](#suggestions) | Tool | Arguments | What it does | | ------------------- | ------------------------------ | ------------------------------------------------------------------------------------ | | `accept_suggestion` | `document_id`, `suggestion_id` | Makes a pending suggestion part of the document. Needs edit access. | | `reject_suggestion` | `document_id`, `suggestion_id` | Rejects a pending suggestion. Needs edit access, except to withdraw the agent’s own. | ### Comments [Section titled “Comments”](#comments) | Tool | Arguments | What it does | | ------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_comments` | `document_id`, `include_resolved`, `from_thread_id` | The document’s threads, oldest first: the block each is on, the text or suggestion it’s about, and every message with its author. About 40,000 characters at a time. | | `add_comment` | `document_id`, `block_id`, `quote`, `suggestion_id`, `comment` | Starts a thread on the first `quote` in a block, or on a suggestion with `suggestion_id`. A suggestion has one thread, so commenting on one that has it adds a reply. | | `reply_to_comment` | `document_id`, `thread_id`, `comment` | Replies in a thread. Replying to a resolved thread reopens it. | | `resolve_comment` | `document_id`, `thread_id`, `resolved` | Resolves a thread, or reopens it with `resolved: false`. | | `edit_comment` | `document_id`, `thread_id`, `message_id`, `comment` | Rewrites one of the agent’s own messages. | | `delete_comment` | `document_id`, `thread_id`, `message_id` | Deletes one of the agent’s own messages. Deleting a thread’s first message deletes the thread, which it may do only while nobody else has replied. | Agents may comment wherever they may suggest. To mention someone, which [notifies them through your webhook](/reference/webhooks/#mentioncreated), an agent writes `@[Grace Hopper](user:usr_9Tb3Wc1X)` with an id from `list_people`. ### People [Section titled “People”](#people) | Tool | Arguments | What it does | | ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `list_people` | `document_id` | The people the agent may mention here: the document’s members, and others who wrote in it or its comments and can still open it. Each comes with their Proof user id, their name, the `detail` you gave them (such as an email address), and how to write their mention. | ### Images [Section titled “Images”](#images) | Tool | Arguments | What it does | | ------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `read_image` | `document_id`, `src` | Returns an image stored with the document, as `read_document` shows its address, for the agent to see. | | `add_image` | `document_id`, `after_block_id`, `url` or `data`, `alt` | Adds a PNG, JPEG, GIF or WebP image after a block, from an https address or base64 data, stored with the document. `alt` describes it for people who can’t see it. | ## Links in answers [Section titled “Links in answers”](#links-in-answers) Several tools answer with a link to the document, so the agent can hand it to its person. The link follows the **Document URL** pattern you set in the console or as `document_url` with [`PATCH /v1/platform`](/reference/api/platform/), such as `https://acme.com/docs/{document}`. Without one, answers give the document’s id alone. ## Limits [Section titled “Limits”](#limits) | What | Limit | | ---------------------------------------------- | ------------------------------------ | | One part of `read_document` or `list_comments` | About 40,000 characters | | `list_documents` | 100 documents a call | | `search_documents` | 20 results | | One image | 1,900,000 bytes | | Images in one document | 100 MB, shared with people’s uploads | | Messages in one request | 20 | Agents share the editor with people, and people come first. Their edits go ahead of agents’ in every document, and each agent request gets a short slice of work at a time, so a burst from an agent never holds typing back by more than a moment. ## Your own tools [Section titled “Your own tools”](#your-own-tools) Proof’s server has the tools for documents: their text, suggestions, comments, people and images. Folders, pins, workspaces and anything else your product keeps for itself aren’t Proof’s. To give agents those, [register tools of your own](/reference/api/platform/#set-your-agent-tools): the server lists them after its own, adds your instructions to its own, and forwards each call to your server. See [Add your own agent tools](/guides/your-own-agent-tools/). # Node SDK > The @exalto/proof package for calling the Proof API from your server, with typed methods for every endpoint. `@exalto/proof` wraps the [REST API](/reference/api/overview/) for Node.js servers. Every method maps to one endpoint, takes the same fields and returns the same objects, so the REST reference describes the SDK too. Use it only on your server: it holds your secret key. ## Install [Section titled “Install”](#install) ```bash npm install @exalto/proof ``` It needs Node.js 20 or later and has no dependencies. Version 1.x of the package talks to `/v1` of the API. ## Create a client [Section titled “Create a client”](#create-a-client) proof.ts ```ts import Proof from "@exalto/proof"; export const proof = new Proof(process.env.PROOF_SECRET_KEY); ``` The key decides the mode: an `sk_test_` key works on test data, an `sk_live_` key on live data. [`proof.platform.retrieve()`](#platform) says which, as `mode`. The second argument takes options: | Option | Default | What it does | | ------------ | -------------------------------- | --------------------------------------------------------------------------- | | `maxRetries` | `2` | How many times a request answered with `429` or `5xx` is tried again. | | `baseUrl` | `https://api.proof.exalto.ai/v1` | Where requests go. Change it only for a proxy, or a Proof you run yourself. | ```ts const proof = new Proof(process.env.PROOF_SECRET_KEY, { maxRetries: 4 }); ``` ## Field names [Section titled “Field names”](#field-names) The SDK uses the API’s own field names, in snake_case, in both directions. What you send to `proof.documents.update` is the body of `PATCH /v1/documents/:id`, and what comes back is the document object as the REST reference shows it. ```ts await proof.documents.update("doc_8Hq2Lm4R", { link_access: "suggest" }); ``` ## Methods [Section titled “Methods”](#methods) Each resource is a property of the client. Path parameters come first, then the body or query as one object. ### Users [Section titled “Users”](#users) See [Users](/reference/api/users/). | Method | Endpoint | | ------------------------------------------------------- | ----------------------------------------------- | | `proof.users.upsert(externalId, params)` | `PUT /v1/users/external/:external_id` | | `proof.users.retrieve(userId)` | `GET /v1/users/:user_id` | | `proof.users.retrieveByExternalId(externalId)` | `GET /v1/users/external/:external_id` | | `proof.users.update(userId, params)` | `PATCH /v1/users/:user_id` | | `proof.users.delete(userId)` | `DELETE /v1/users/:user_id` | | `proof.users.merge(userId, { into })` | `POST /v1/users/:user_id/merge` | | `proof.users.listDocuments(userId, { limit?, ids? })` | `GET /v1/users/:user_id/documents` | | `proof.users.listTrash(userId)` | `GET /v1/users/:user_id/documents?trashed=true` | | `proof.users.emptyTrash(userId)` | `DELETE /v1/users/:user_id/trash` | | `proof.users.deleteFromTrash(userId, documentId)` | `DELETE /v1/users/:user_id/trash/:document_id` | | `proof.users.listAgents(userId, { document? })` | `GET /v1/users/:user_id/agents` | | `proof.users.endSessions(userId, { group? })` | `DELETE /v1/users/:user_id/sessions` | | `proof.users.retrieveCheckins(userId)` | `GET /v1/users/:user_id/checkins` | | `proof.users.updateCheckins(userId, { every_minutes })` | `PUT /v1/users/:user_id/checkins` | ### Documents [Section titled “Documents”](#documents) See [Documents](/reference/api/documents/). | Method | Endpoint | | ------------------------------------------------ | ---------------------------------- | | `proof.documents.create(params)` | `POST /v1/documents` | | `proof.documents.retrieve(documentId)` | `GET /v1/documents/:id` | | `proof.documents.update(documentId, params)` | `PATCH /v1/documents/:id` | | `proof.documents.trash(documentId, { user? })` | `POST /v1/documents/:id/trash` | | `proof.documents.restore(documentId, { user? })` | `POST /v1/documents/:id/restore` | | `proof.documents.delete(documentId)` | `DELETE /v1/documents/:id` | | `proof.documents.export(documentId, { format })` | `GET /v1/documents/:id/export` | | `proof.documents.provenance(documentId)` | `GET /v1/documents/:id/provenance` | `export` resolves to the Markdown as a string. ### Members [Section titled “Members”](#members) See [Members](/reference/api/members/). | Method | Endpoint | | ------------------------------------------------- | ------------------------------------------- | | `proof.members.list(documentId)` | `GET /v1/documents/:id/members` | | `proof.members.put(documentId, userId, { role })` | `PUT /v1/documents/:id/members/:user_id` | | `proof.members.delete(documentId, userId)` | `DELETE /v1/documents/:id/members/:user_id` | ### Sessions [Section titled “Sessions”](#sessions) See [Sessions](/reference/api/sessions/). | Method | Endpoint | | -------------------------------------------------------------- | ------------------- | | `proof.sessions.create({ user, document, via?, group? })` | `POST /v1/sessions` | | `proof.sessions.create({ user, purpose: "presence", group? })` | `POST /v1/sessions` | ### Agents [Section titled “Agents”](#agents) See [Agents](/reference/api/agents/). | Method | Endpoint | | --------------------------------------------------------- | ---------------------------------------------------- | | `proof.agents.retrieve(agentId)` | `GET /v1/agents/:agent_id` | | `proof.agents.update(agentId, { access })` | `PATCH /v1/agents/:agent_id` | | `proof.agents.listDocuments(agentId)` | `GET /v1/agents/:agent_id/documents` | | `proof.agents.putDocument(agentId, documentId, { role })` | `PUT /v1/agents/:agent_id/documents/:document_id` | | `proof.agents.deleteDocument(agentId, documentId)` | `DELETE /v1/agents/:agent_id/documents/:document_id` | | `proof.agents.revoke(agentId)` | `DELETE /v1/agents/:agent_id` | ### Agent requests [Section titled “Agent requests”](#agent-requests) See [Agent requests](/reference/api/agent-requests/). | Method | Endpoint | | ------------------------------------------------ | ------------------------------------- | | `proof.agentRequests.retrieve(requestId)` | `GET /v1/agent-requests/:id` | | `proof.agentRequests.approve(requestId, params)` | `POST /v1/agent-requests/:id/approve` | | `proof.agentRequests.deny(requestId)` | `POST /v1/agent-requests/:id/deny` | ### Reader requests [Section titled “Reader requests”](#reader-requests) See [Reader requests](/reference/api/seals/#reader-requests). | Method | Endpoint | | ---------------------------------------------------------- | -------------------------------------- | | `proof.readerRequests.retrieve(requestId)` | `GET /v1/reader-requests/:id` | | `proof.readerRequests.approve(requestId, { user, email })` | `POST /v1/reader-requests/:id/approve` | ### Seals [Section titled “Seals”](#seals) See [Seals](/reference/api/seals/). | Method | Endpoint | | ------------------------------------------ | ------------------------------ | | `proof.seals.create(documentId, { user })` | `POST /v1/documents/:id/seals` | | `proof.seals.list(documentId)` | `GET /v1/documents/:id/seals` | | `proof.seals.retrieve(code)` | `GET /v1/seals/:code` | | `proof.seals.withdraw(code)` | `DELETE /v1/seals/:code` | ### Webhook endpoints [Section titled “Webhook endpoints”](#webhook-endpoints) See [Webhook endpoints](/reference/api/webhook-endpoints/). | Method | Endpoint | | ------------------------------------------------ | -------------------------------------------- | | `proof.webhookEndpoints.create({ url, events })` | `POST /v1/webhook-endpoints` | | `proof.webhookEndpoints.list()` | `GET /v1/webhook-endpoints` | | `proof.webhookEndpoints.rollSecret(endpointId)` | `POST /v1/webhook-endpoints/:id/roll-secret` | | `proof.webhookEndpoints.delete(endpointId)` | `DELETE /v1/webhook-endpoints/:id` | ### Platform [Section titled “Platform”](#platform) See [Platform](/reference/api/platform/). | Method | Endpoint | | ---------------------------------------------------------------- | ------------------------ | | `proof.platform.retrieve()` | `GET /v1/platform` | | `proof.platform.update(params)` | `PATCH /v1/platform` | | `proof.platform.setTools({ url, secret, tools, instructions? })` | `PUT /v1/platform/tools` | | `proof.platform.stats()` | `GET /v1/platform/stats` | ## Lists and paging [Section titled “Lists and paging”](#lists-and-paging) Methods that list things return a page you can either await or iterate. Awaiting it gives you one page, as the API returns it: ```ts const page = await proof.users.listDocuments("usr_5Kq2Lm8R", { limit: 20 }); page.data; // up to 20 documents, newest edited first page.next; // a cursor, or null on the last page ``` Iterating it with `for await` fetches the following pages as you go, so you see every item once: ```ts for await (const doc of proof.users.listDocuments("usr_5Kq2Lm8R")) { console.log(doc.title); } ``` Stop the loop with `break` whenever you have what you need; no further pages are fetched. ## Errors [Section titled “Errors”](#errors) A request the API refuses throws a `ProofError`. It carries the API’s error fields and the HTTP status: | Property | Example | What it is | | --------- | ----------------------------------------- | ------------------------------------------------------------------- | | `type` | `"permission"` | The kind of error, as listed in [Errors](/reference/api/overview/). | | `code` | `"no_access"` | A specific reason you can branch on. | | `message` | `"usr_9Tb3Wc1X can't open doc_8Hq2Lm4R."` | A sentence for your logs, not for your users. | | `param` | `"role"` | The field at fault, for `invalid_request` errors. | | `status` | `403` | The HTTP status. | ```ts import Proof, { ProofError } from "@exalto/proof"; try { const session = await proof.sessions.create({ user: "usr_9Tb3Wc1X", document: "doc_8Hq2Lm4R" }); } catch (error) { if (error instanceof ProofError && error.code === "no_access") { // Grace isn't a member, and the document's link access doesn't let Grace in. } else { throw error; } } ``` Network failures throw the error `fetch` threw. ## Retries [Section titled “Retries”](#retries) The client retries a request that was rate limited (429), and one other than a `POST` that failed on our side (500 and above), `maxRetries` times. It waits longer before each try, and as long as `Retry-After` asks when the API sends it. A `POST` that failed may have been carried out, so it isn’t sent again: see [Idempotency](/reference/api/overview/#idempotency). Every id the client puts in a path is encoded as one segment, and an id of `.` or `..` throws a `TypeError`: an id that came from your users can’t name another endpoint. ## Webhooks [Section titled “Webhooks”](#webhooks) `proof.webhooks.verify` checks a delivery’s signature and returns the event, or throws an `Error`. Give it the body exactly as it arrived, before any JSON parsing: webhooks.ts ```ts import express from "express"; import { proof } from "./proof"; const app = express(); app.post("/proof/webhooks", express.raw({ type: "application/json" }), (req, res) => { const event = proof.webhooks.verify(req.body, req.get("Proof-Signature") ?? "", process.env.PROOF_WEBHOOK_SECRET!); if (event.type === "mention.created") notifyMention(event.data); res.sendStatus(204); }); ``` It also refuses a delivery whose timestamp is more than five minutes from your clock. [Webhook events](/reference/webhooks/) lists every event and its payload. Calls to [your own agent tools](/guides/your-own-agent-tools/) are signed the same way. `verifySignature(rawBody, header, secret)` checks one and says whether it matched: ```ts import { verifySignature } from "@exalto/proof"; if (!verifySignature(rawBody, req.get("Proof-Signature"), process.env.ACME_TOOLS_SECRET!)) return res.sendStatus(401); ``` ## TypeScript [Section titled “TypeScript”](#typescript) The package ships its own types. Each object the API returns has a type of the same name: ```ts import type { Agent, Document, Event, Member, Platform, Role, Seal, Session, User } from "@exalto/proof"; ``` `Event` is the webhook envelope, with `data` typed as `T`; pass the payload type of the events you handle: ```ts const event = proof.webhooks.verify<{ seal: Seal }>(rawBody, header, secret); if (event.type === "seal.created") await saveSeal(event.data.seal.code, event.data.seal.url); ``` # Webhook events > Every event Proof sends to your webhook endpoints, how deliveries are signed, and how they are retried. Proof tells your server what happened in the editor by posting events to the endpoints you register in the [console](https://developer.proof.exalto.ai/console) or with [`POST /v1/webhook-endpoints`](/reference/api/webhook-endpoints/). Use them for what Proof leaves to you: notifications, emails, activity feeds and your own document lists. For a walkthrough, see [Send notifications](/guides/notifications/). ## The event [Section titled “The event”](#the-event) Every delivery is one event, posted as JSON: ```json { "id": "evt_4Rk9Tn2Wq8Lm", "type": "mention.created", "created_at": "2026-10-30T14:02:11Z", "mode": "live", "actor": { "type": "person", "user": "usr_5Kq2Lm8R" }, "data": { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message_id": "msg_9Xc1", "mentioned": "usr_9Tb3Wc1X", "excerpt": "@Grace Hopper could you check the second table?" } } ``` | Field | What it is | | ------------ | ---------------------------------------------------------- | | `id` | The event’s id. A retried delivery has the same id. | | `type` | One of the [event types](#event-types) below. | | `created_at` | When it happened, in UTC. | | `mode` | `"test"` or `"live"`, matching the key that made the data. | | `actor` | Who did it. See below. | | `data` | What happened. Its shape depends on `type`. | ### The actor [Section titled “The actor”](#the-actor) | `actor.type` | Fields | Who | | ------------ | --------------- | --------------------------------------------- | | `person` | `user` | One of your users, writing in the editor. | | `agent` | `agent`, `user` | An agent, with `user` the person it acts for. | | `platform` | none | Your own server, through the API. | ## Verify the signature [Section titled “Verify the signature”](#verify-the-signature) Each delivery carries a `Proof-Signature` header: ```http Proof-Signature: t=1793455331,v1=5f1c0b8a9e3d4f27c6a1e0b9d8c7f6a5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9 ``` `t` is when it was sent, in seconds since 1970. `v1` is an HMAC-SHA256, in hex, of the timestamp, a full stop and the raw request body, keyed with the endpoint’s signing secret (`whsec_…`). To check a delivery: 1. Read the raw body as bytes, before any JSON parsing; parsing and re-encoding changes it. 2. Compute the HMAC of `` `${t}.${body}` `` with your secret. 3. Compare it with each `v1` in the header, in constant time. One must match. 4. Refuse the delivery if `t` is more than five minutes from your clock. The [Node SDK](/reference/node/#webhooks) does this for you with `proof.webhooks.verify`. By hand, in Node: verify.ts ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyProofSignature(rawBody: Buffer, header: string, secret: string): boolean { const pairs = header.split(",").map((part) => part.split("=") as [string, string]); const t = Number(pairs.find(([key]) => key === "t")?.[1]); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); return pairs .filter(([key]) => key === "v1") .some(([, hex]) => { const given = Buffer.from(hex, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); }); } ``` When you roll an endpoint’s secret in the console, deliveries carry two `v1` signatures, one for each secret, for 24 hours. Code that accepts any matching `v1` keeps working through the change. ## Delivery [Section titled “Delivery”](#delivery) * Proof posts each event with `Content-Type: application/json` and `User-Agent: Proof-Webhooks/1`. * Answer with any 2xx status within 10 seconds. Do slow work after you answer, from a queue. * Anything else, or no answer, counts as a failure. Proof tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 12 hours, for 3 days in all. * An event can arrive more than once. Keep the ids you’ve handled and skip repeats. * Events can arrive out of order, especially after a retry. Where order matters, compare `created_at`, or read the current state from the API: [`GET /v1/documents/:id`](/reference/api/documents/) rather than the last `document.renamed` you saw. An endpoint receives the types it was registered for, or all of them with `["*"]`. New event types may be added to `/v1` at any time, so ignore types you don’t know. ## Event types [Section titled “Event types”](#event-types) | Type | When | | -------------------------------------------------- | ------------------------------------------------------ | | [`thread.created`](#threadcreated) | Someone started a comment thread. | | [`thread.replied`](#threadreplied) | Someone replied in a thread. | | [`thread.resolved`](#threadresolved) | A thread was resolved. | | [`thread.reopened`](#threadreopened) | A resolved thread was reopened. | | [`thread.message_edited`](#threadmessage_edited) | Someone changed a message they wrote. | | [`thread.message_deleted`](#threadmessage_deleted) | Someone deleted a message, or a whole thread. | | [`mention.created`](#mentioncreated) | Someone was mentioned in a comment. | | [`document.created`](#documentcreated) | A document was created by an agent or through the API. | | [`document.renamed`](#documentrenamed) | A document’s name changed. | | [`document.trashed`](#documenttrashed) | A document went to the trash. | | [`document.restored`](#documentrestored) | A document came back from the trash. | | [`document.deleted`](#documentdeleted) | A document was deleted for good. | | [`seal.created`](#sealcreated) | A document was sealed. | | [`seal.withdrawn`](#sealwithdrawn) | A seal was withdrawn. | | [`agent.connected`](#agentconnected) | A person approved an agent. | | [`agent.revoked`](#agentrevoked) | An agent was removed. | `suggestion.created`, `suggestion.accepted`, `suggestion.rejected` and `checkin.missed` are reserved: an endpoint may name them, but Proof doesn’t send them yet. The examples below show `data` only; every event has the envelope above around it. ### Who to tell [Section titled “Who to tell”](#who-to-tell) The thread events carry what you need to decide whom to notify without asking the API: | Field | What it is | | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `owners` | The document’s owners, by Proof id. | | `participants` | Everyone who wrote in the thread before this message, by Proof id; an agent’s messages count for its person. | | `suggestion_author` | For a thread on a suggestion, the person who proposed it (an agent’s person, even once the agent was removed), or `null`. | | `mentions` | The people this message mentions, but its author. | Each message carries its `author` as an actor, with the `name` it was written under (an agent’s own), its plain-text `body`, and an `excerpt` of it cut to about 200 characters. ### thread.created [Section titled “thread.created”](#threadcreated) A comment thread started, on a stretch of text (`quote`) or on a suggestion (`suggestion_id`). Mentions in its first message also send [`mention.created`](#mentioncreated). ```json { "document": "doc_8Hq2Lm4R", "thread": { "id": "thr_6Pz3", "quote": "the engine might compose elaborate pieces of music", "suggestion_id": null, "messages": [ { "id": "msg_9Xc1", "author": { "type": "person", "user": "usr_5Kq2Lm8R", "name": "Ada Lovelace" }, "body": "Is there a source for this? @Grace Hopper", "excerpt": "Is there a source for this? @Grace Hopper", "created_at": "2026-10-30T14:02:11Z" } ] }, "participants": [], "suggestion_author": null, "owners": ["usr_5Kq2Lm8R"], "mentions": ["usr_9Tb3Wc1X"] } ``` ### thread.replied [Section titled “thread.replied”](#threadreplied) `participants` is everyone who wrote in the thread before this reply. See [Who to tell](#who-to-tell). ```json { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message": { "id": "msg_2Vb7", "author": { "type": "agent", "agent": "agt_3Hs8", "user": "usr_5Kq2Lm8R", "name": "Claude Code" }, "body": "Menabrea's notes, 1842. I added the citation.", "excerpt": "Menabrea's notes, 1842. I added the citation.", "created_at": "2026-10-30T14:05:40Z" }, "participants": ["usr_5Kq2Lm8R", "usr_9Tb3Wc1X"], "suggestion_author": null, "owners": ["usr_5Kq2Lm8R"], "mentions": [] } ``` ### thread.resolved [Section titled “thread.resolved”](#threadresolved) ```json { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3" } ``` ### thread.reopened [Section titled “thread.reopened”](#threadreopened) Sent when someone reopens a resolved thread, including by replying in it. ```json { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3" } ``` ### thread.message_edited [Section titled “thread.message_edited”](#threadmessage_edited) A message says something new. It carries the message as it is now, with the [fields above](#who-to-tell), `kind` (`comment` for a thread’s first message, `reply` otherwise), and `newly_mentioned`: the people it mentions now and didn’t before, who also get [`mention.created`](#mentioncreated). ```json { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message": { "id": "msg_9Xc1", "author": { "type": "person", "user": "usr_5Kq2Lm8R", "name": "Ada Lovelace" }, "body": "Is there a source for this? @Grace Hopper @Charles Babbage", "excerpt": "Is there a source for this? @Grace Hopper @Charles Babbage", "created_at": "2026-10-30T14:02:11Z" }, "kind": "comment", "participants": [], "suggestion_author": null, "owners": ["usr_5Kq2Lm8R"], "mentions": ["usr_9Tb3Wc1X", "usr_2Hd7Np4Q"], "newly_mentioned": ["usr_2Hd7Np4Q"] } ``` ### thread.message_deleted [Section titled “thread.message_deleted”](#threadmessage_deleted) `thread_deleted` is `true` when the thread went with it, as when its first message is deleted. Remove any notification you made of it. ```json { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message_id": "msg_2Vb7", "thread_deleted": false } ``` ### mention.created [Section titled “mention.created”](#mentioncreated) One event per person mentioned, whether in a new thread, a reply, or an edited message that newly mentions them. Notify `mentioned`; the actor is who wrote the mention. ```json { "document": "doc_8Hq2Lm4R", "thread_id": "thr_6Pz3", "message_id": "msg_9Xc1", "mentioned": "usr_9Tb3Wc1X", "excerpt": "@Grace Hopper could you check the second table?" } ``` ### document.created [Section titled “document.created”](#documentcreated) Sent for documents an agent starts with `create_document`, and for those you create through the API, so your document lists stay complete either way. `data.document` is the [document object](/reference/api/documents/). ```json { "document": { "id": "doc_2Mw5Rt9K", "name": null, "title": "Reading notes: Menabrea", "owners": ["usr_5Kq2Lm8R"], "link_access": "restricted", "created_at": "2026-10-30T15:11:02Z", "edited_at": "2026-10-30T15:11:02Z", "trashed_at": null } } ``` ### document.renamed [Section titled “document.renamed”](#documentrenamed) `name` is `null` when someone cleared the name and the document is called by its first line again. `title` is what to show either way. ```json { "document": "doc_8Hq2Lm4R", "name": "On the Analytical Engine", "title": "On the Analytical Engine" } ``` ### document.trashed [Section titled “document.trashed”](#documenttrashed) Sent when an owner moves it to the trash for everyone. A member putting it in their own trash sends nothing. ```json { "document": "doc_8Hq2Lm4R", "delete_at": "2026-11-29T15:20:00Z" } ``` ### document.restored [Section titled “document.restored”](#documentrestored) ```json { "document": "doc_8Hq2Lm4R" } ``` ### document.deleted [Section titled “document.deleted”](#documentdeleted) The document is gone for everyone, its seals are withdrawn, and no further events will name it. ```json { "document": "doc_8Hq2Lm4R" } ``` ### seal.created [Section titled “seal.created”](#sealcreated) Sent whether the document was sealed through the API or with the editor’s Seal button. `data.seal` is the [seal object](/reference/api/seals/). ```json { "seal": { "code": "AH271NSHS", "url": "https://proof.exalto.ai/AH271NSHS", "document": "doc_8Hq2Lm4R", "version": 1, "sealed_at": "2026-10-30T16:31:00Z", "sealed_by": "usr_5Kq2Lm8R", "method": "v0.2", "line": "proof.exalto.ai/AH271NSHS · 87% typed/shaped · 8% AI · 5% external · 4 prompts · 2 sessions, 1hr48min", "composition": { "typed": 71, "shaped": 16, "ai": 8, "external": 5 }, "presence": { "share": 97, "verified_share": 0, "verified": false }, "prompts": 4, "sittings": 2, "duration": "1hr48min", "attested_by": { "platform": "acme", "key_ids": ["key_5Jd8Wq"] }, "withdrawn_at": null } } ``` ### seal.withdrawn [Section titled “seal.withdrawn”](#sealwithdrawn) The seal’s page now says it was withdrawn. ```json { "code": "AH271NSHS", "document": "doc_8Hq2Lm4R" } ``` ### agent.connected [Section titled “agent.connected”](#agentconnected) Sent when a person approves a client they hadn’t connected before; approving the same one again changes its access and sends nothing. `data.agent` is the [agent object](/reference/api/agents/). ```json { "agent": { "id": "agt_3Hs8", "user": "usr_5Kq2Lm8R", "name": "Claude Code", "host": "claude.ai", "connected_at": "2026-10-30T13:40:12Z", "last_used_at": null, "access": "all" } } ``` ### agent.revoked [Section titled “agent.revoked”](#agentrevoked) Sent when the agent’s person or your server removes it, and when its person is deleted. Its tokens no longer work. ```json { "agent_id": "agt_3Hs8", "user": "usr_5Kq2Lm8R" } ```