This guide continues the editor quick start. The free editor from that guide stays as it is; @runstamp/editor-pro adds a review room on a second page of the same app. The room opens the latest revision saved in data/contract and saves its versions there, so the page at / and the room at /pro share one document history. It needs the contract and the two files from the quick start, and Node.js 20 or later.
1. Install editor-pro
npm install https://runstamp.com/dl/runstamp-editor-0.1.0.tgz https://runstamp.com/dl/runstamp-editor-pro-0.1.0.tgz yjs ws2. Paste your key
Copy the key from your Runstamp console (Settings, Billing, "editor-pro licence key"). It starts with pjsx_offline_v1_. Put it in .env.local next to package.json, and keep that file out of source control:
NEXT_PUBLIC_RUNSTAMP_LICENSE=pjsx_offline_v1_...The key is checked in the browser with no network call. On localhost editor-pro also runs without a key, with a "Development licence" badge; a key you do paste is always verified, so a wrong key shows here and not in production.
3. Add the review room page
Create app/pro/page.tsx:
'use client';
import '@runstamp/editor/styles.css';
import '@runstamp/editor-pro/styles.css';
import { useEffect, useMemo, useState } from 'react';
import { DocxEditor } from '@runstamp/editor/docx/react';
import { activateEditorPro, type ProSession } from '@runstamp/editor-pro';
import { PresenceBar, ProProvider, ReviewPanel, ReviewToolbar } from '@runstamp/editor-pro/react';
export default function Page() {
const [pro, setPro] = useState<ProSession | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<'changes' | 'comments' | 'history'>('changes');
useEffect(() => {
let live: ProSession | null = null;
let cancelled = false;
// Open /pro?name=Bo in a second window to be the second person.
const name = new URLSearchParams(location.search).get('name') ?? 'Ada';
// activateEditorPro is async: it verifies the key offline, then joins the room.
activateEditorPro({
license: process.env.NEXT_PUBLIC_RUNSTAMP_LICENSE,
user: { id: `user-${name}`, name },
collab: { url: 'ws://127.0.0.1:3002/rooms', httpUrl: 'http://127.0.0.1:3002/rooms', room: 'docx-contract' },
}).then(
(session) => { if (cancelled) session.destroy(); else { live = session; setPro(session); } },
(e: unknown) => setError(e instanceof Error ? e.message : String(e)),
);
return () => { cancelled = true; live?.destroy(); };
}, []);
// The host must keep its identity between renders, or the editor is created again.
const host = useMemo(() => (pro ? pro.collabHost() : null), [pro]);
if (error) return <p role="alert">{error}</p>;
if (!pro || !host) return <p>Connecting to the room…</p>;
return (
<ProProvider session={pro}>
<main style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<header style={{ padding: '12px 16px', font: '600 16px system-ui' }}>
YourApp {pro.license.development ? '(Development licence)' : '(Pro)'}
</header>
<PresenceBar />
<ReviewToolbar />
{/* One click each. The line beside a button says what to try. */}
<nav aria-label="Try editor-pro" style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 16px', padding: '8px 16px', font: '14px system-ui' }}>
<span><button type="button" onClick={() => setTab('comments')}>Comments</button> Select a word, then choose New comment.</span>
<span><a href={`?name=${pro.user.name === 'Bo' ? 'Ada' : 'Bo'}`} target="_blank" rel="noreferrer">Co-editing</a> Opens a second person; type in one window.</span>
<span><button type="button" onClick={() => setTab('history')}>Version history</button> Choose Save version, then Restore.</span>
<span><a href="/api/pdf" target="_blank" rel="noreferrer">Export PDF</a> The room's current document as a PDF.</span>
<span><a href={`/api/pdf?action=sign&name=${encodeURIComponent(pro.user.name)}`} target="_blank" rel="noreferrer">Fill and sign</a> Signs that PDF as you.</span>
</nav>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<DocxEditor
host={host}
extensions={[pro.extension]}
style={{ flex: 1, overflow: 'auto' }}
engine={() => new Worker(new URL('@runstamp/editor/docx/layout-worker', import.meta.url), { type: 'module' })}
/>
<ReviewPanel key={tab} initialTab={tab} />
</div>
</main>
</ProProvider>
);
}4. Add the room server
The room keeps everyone's edits, suggestions and comments in step. It reads and writes the same data folder as the quick start's route, through the @runstamp/sdk you already installed. Save this as room-server.mjs next to package.json:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createCollabServer } from '@runstamp/editor-pro/server/node';
import { MemoryRoomStore } from '@runstamp/editor-pro/server';
import { fileSystemStorage } from '@runstamp/sdk/editor-host/node';
// The same ./data store the quick-start page saves into. The room opens its latest revision (your contract.docx before any save).
const storage = fileSystemStorage(join(process.cwd(), 'data'));
const latest = async () => storage.ensure({ documentId: 'contract', format: 'docx', name: 'contract.docx', bytes: new Uint8Array(await readFile('contract.docx')) });
// Every room version (Save version, the idle save after 30 s, a restore) is written to ./data as the next revision.
let opened = 0; // the revision the room started from, then the one it last wrote
class RevisionStore extends MemoryRoomStore {
async putVersion(roomId, v) {
await super.putVersion(roomId, v);
if (v.receipt && v.transaction) { // the room's opening version has none: it is the revision already stored
const head = await latest();
if (head.revision !== opened) console.warn(`data/contract moved to r${head.revision} after the room opened: restart this server to open it from there.`);
const saved = await storage.commit('contract', head.revision, { bytes: v.bytes, artifact: v.artifact, receipt: v.receipt, transaction: v.transaction });
if (saved.ok) opened = saved.revision;
}
}
}
const store = new RevisionStore();
// Loopback only, for development: anyone who can reach it is an anonymous editor. In production see "Room server in production" in the editor-pro README.
const server = await createCollabServer({
port: 3002,
host: '127.0.0.1',
corsOrigin: '*',
// The key from step 1 (read from .env.local by `node --env-file`); with no key the room runs on the Development licence.
license: process.env.NEXT_PUBLIC_RUNSTAMP_LICENSE,
store: () => store,
// The room "docx-contract" opens the latest revision in ./data, the one the page at / shows.
loadDocument: async (roomId) => {
if (roomId !== 'docx-contract') return null;
const head = await latest();
opened = head.revision;
return { bytes: head.bytes, documentId: roomId, format: 'docx' };
},
});
console.log(`Room server on ${server.url}`);The room opens the latest revision in data/contract: your contract.docx until something is saved, then the revision you saved on /. Every version the room records (Save version, the automatic save after 30 seconds without edits, a restore) is written to data/contract as the next revision, the same r<n>.bin files the page at / writes. Until a revision exists, the room stores contract.docx there as revision 0, as the page at / does.
5. Add the PDF route
The "Export PDF" and "Fill and sign" links on the page call this route, which uses the free @runstamp/sdk on your server. It asks the room server from step 4 for the document as it is edited right now, so a suggestion made a second ago is in the PDF, saved or not. Create app/api/pdf/route.ts:
import { createRunstamp } from '@runstamp/sdk';
export const runtime = 'nodejs';
// The review room from step 4 holds the document as it is edited right now, suggestions included.
const ROOM = 'http://127.0.0.1:3002/rooms/docx-contract';
// /api/pdf exports the room's current document to a PDF; /api/pdf?action=sign signs that PDF as the name in ?name=.
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const action = url.searchParams.get('action') === 'sign' ? 'sign' : 'export';
const live = await fetch(`${ROOM}/export`, { cache: 'no-store' }).catch(() => null);
if (!live?.ok) return new Response('The room server is not running: start `node --env-file=.env.local room-server.mjs` first.', { status: 502 });
const rs = createRunstamp();
const exported = await rs.exportPdf(new Uint8Array(await live.arrayBuffer()));
if (!exported.ok) return new Response(exported.error.message, { status: 500 });
let pdf: Uint8Array = (exported.value as { bytes: Uint8Array }).bytes;
if (action === 'sign') {
const signed = await rs.sign(pdf, { name: url.searchParams.get('name') ?? 'Ada', reason: 'Approval' });
if (!signed.ok) return new Response(signed.error.message, { status: 500 });
pdf = (signed.value as { bytes: Uint8Array }).bytes;
}
return new Response(pdf as BodyInit, { headers: { 'content-type': 'application/pdf', 'content-disposition': `inline; filename="contract-${action}.pdf"` } });
}Filling a form you already have is the same call with a PDF form; see Export PDF, fill and sign.
6. Build and run
node --env-file=.env.local room-server.mjs # first terminal
npm run build && npm start # second terminalOpen http://localhost:3000/pro and, in a second window, http://localhost:3000/pro?name=Bo. Turn on suggest mode in the first window and type: the edit appears as a tracked change in both windows. Select text in the second window, choose "New comment on the selection" on the Comments tab and post: the thread appears in the first. The header says "(Pro)" when your key verified, and "(Development licence)" when no key was given.
The room opens the latest revision, so an edit you saved at http://localhost:3000 is already in it. Choose Version history, then Save version: ls data/contract shows the new revision, and reloading http://localhost:3000 shows the room's changes on the free page. The room reads data/contract once, when its first person joins. If you save on / while the room is running, restart room-server.mjs before you go on in the room: the server prints a warning when a room save would sit on top of a newer revision, and the older revision stays in data/contract.
Everything the plan adds is one click from that page, with a line beside it saying what to try: Comments and Version history switch the side panel to that tab, Co-editing opens the second person in a new window, and Export PDF and Fill and sign open the PDF from step 5.
7. Let an agent join the document
An agent can work in the open document like another person: its change shows up live as a tracked change, and the review panel's Agents tab lists it while it is connected. Save this as room-agent.mjs next to room-server.mjs. It is an MCP server that your agent client starts (it needs no extra install):
// An agent that joins the open document. It is an MCP server (stdio) that connects to your room server as a participant:
// its edits arrive live as tracked changes by the agent, and the editor's Agents tab lists it while it is connected.
// node room-agent.mjs (an MCP client such as Claude Desktop starts it; see the MCP server docs)
// ROOM_URL (default http://127.0.0.1:3002), ROOM_ID (default docx-contract), AGENT_NAME (default "Claude (agent)"),
// ROOM_TOKEN and RUNSTAMP_LICENSE only when the room server is not on loopback.
import { createInterface } from 'node:readline';
import { activatePro } from '@runstamp/editor-pro/license';
import { AgentParticipant } from '@runstamp/editor-pro/agent';
import { CollabProvider, bodyOfYDoc } from '@runstamp/editor-pro/collab';
// stdout carries the MCP protocol: send every library notice to stderr.
console.log = console.info = console.warn = (...a) => console.error(...a);
const http = (process.env.ROOM_URL ?? 'http://127.0.0.1:3002').replace(/\/$/, '');
const roomId = process.env.ROOM_ID ?? 'docx-contract';
const name = process.env.AGENT_NAME ?? 'Claude (agent)';
const WS = globalThis.WebSocket ?? (await import('ws')).default;
const provider = new CollabProvider({
url: http.replace(/^http/, 'ws') + '/rooms', room: roomId, token: process.env.ROOM_TOKEN,
user: { name, kind: 'agent' }, WebSocket: WS,
});
provider.connect();
await provider.whenSynced();
const license = await activatePro(process.env.RUNSTAMP_LICENSE, { hostname: 'localhost' });
const agent = new AgentParticipant({
license, provider, agent: { id: 'room-agent', display: name },
base: async () => new Uint8Array(await (await fetch(`${http}/rooms/${roomId}/bytes`)).arrayBuffer()),
});
const operations = { type: 'array', items: { type: 'object', properties: {
op: { enum: ['replace_text', 'insert_text', 'delete_text', 'insert_paragraph'] },
target: { type: 'object', properties: { find: { type: 'string' }, occurrence: { type: 'number' } } },
text: { type: 'string' }, position: { enum: ['before', 'after'] }, comment: { type: 'string' },
}, required: ['op', 'target'] } };
const tools = [
{ name: 'room_read', description: 'Read the open document as it is now, paragraph by paragraph.', inputSchema: { type: 'object', properties: {} } },
{ name: 'docx_edit', description: 'Edit the open document. The change appears live in the editor as a tracked change by this agent.',
inputSchema: { type: 'object', properties: { operations, label: { type: 'string' } }, required: ['operations'] } },
{ name: 'docx_suggest', description: 'Suggest changes in the open document as tracked changes, with optional comments.',
inputSchema: { type: 'object', properties: { operations, label: { type: 'string' } }, required: ['operations'] } },
{ name: 'docx_comment', description: 'Comment on text in the open document without changing it.',
inputSchema: { type: 'object', properties: { comments: { type: 'array', items: { type: 'object', properties: { target: { type: 'object', properties: { find: { type: 'string' } } }, text: { type: 'string' } }, required: ['target', 'text'] } }, label: { type: 'string' } }, required: ['comments'] } },
];
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], isError });
async function call(tool, a = {}) {
if (tool === 'room_read') {
const paragraphs = [];
bodyOfYDoc(provider.doc).forEach((n) => paragraphs.push(n.textContent));
return text(paragraphs.map((p, i) => `[${i + 1}] ${p}`).join('\n'));
}
if (tool === 'docx_comment') {
const r = await agent.comment(a.comments, a.label);
return text(`Commented in the open document: ${r.summary.join('; ')}`);
}
if (tool === 'docx_edit' || tool === 'docx_suggest') {
const r = await agent.suggest(a.operations, a.label);
return text(`Tracked change by ${name} in the open document: ${r.summary.join('; ')}`);
}
return text(`Unknown tool ${tool}`, true);
}
const send = (m) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...m }) + '\n');
createInterface({ input: process.stdin }).on('line', async (line) => {
let m; try { m = JSON.parse(line); } catch { return; }
if (m.id === undefined) return; // notifications
try {
if (m.method === 'initialize') send({ id: m.id, result: { protocolVersion: m.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'runstamp-room-agent', version: '1.0.0' } } });
else if (m.method === 'tools/list') send({ id: m.id, result: { tools } });
else if (m.method === 'tools/call') send({ id: m.id, result: await call(m.params.name, m.params.arguments).catch((e) => text(String(e.message ?? e), true)) });
else if (m.method === 'ping') send({ id: m.id, result: {} });
else send({ id: m.id, error: { code: -32601, message: `Method not found: ${m.method}` } });
} catch (e) { send({ id: m.id, error: { code: -32603, message: String(e.message ?? e) } }); }
});
process.stdin.on('end', () => { agent.disconnect(); process.exit(0); });Then tell your agent client about it. Start the room server (step 4) first, and keep the page open at /pro. Pick your client.
Claude Code. Run this once from your app folder (it registers room-agent.mjs for that folder only; add --scope user to use it everywhere; ROOM_ID is the room id the page opens):
claude mcp add runstamp-room --env ROOM_URL=http://127.0.0.1:3002 --env ROOM_ID=docx-contract -- node "$(pwd)/room-agent.mjs"Check it with claude mcp list, then start claude in the same folder.
Claude Desktop (claude_desktop_config.json) and Cursor (.cursor/mcp.json, or Settings, MCP). Add this entry, with the full path to your app folder, then restart the client:
{
"mcpServers": {
"runstamp-room": {
"command": "node",
"args": ["/path/to/your-app/room-agent.mjs"],
"env": { "ROOM_URL": "http://127.0.0.1:3002", "ROOM_ID": "docx-contract" }
}
}
}The MCP server page has the same entries for the other clients. With /pro open, ask the agent to change a phrase: the change appears in the editor as a tracked change by the agent, and the Agents tab shows its name and a pending suggestion to accept or reject.
Version history
Every room keeps versions. The review panel from step 3 has a History tab with "Save version" and "Restore"; from code, the session's history does the same:
const saved = await pro.history?.save('Before review');
console.log(pro.history?.versions());
if (saved) await pro.history?.restore(saved.id);A restore is an ordinary edit that everyone in the room receives, and it becomes a new version, so no version is ever lost. Each version is also written to data/contract as the next revision (the room server of step 4 does this), so the free editor opens it. pro.history is null when the session has no room.
When a key expires or the plan ends
A key is valid until the date shown beside it on the billing page. Each renewal sends a new key to the account owner by email and shows it on the billing page: put it in NEXT_PUBLIC_RUNSTAMP_LICENSE and redeploy. When a key has expired, or the plan was cancelled and its period ended, only the page that calls activateEditorPro changes: it shows the error ("Offline license expired") instead of the room, and the free editor on every other page keeps opening and saving the same files with no key.