This document describes the Open Integration system used by paperlesspaper and how to build a compatible plugin provider (manifest + settings UI + render page).
A working reference implementation ships in this repo under:
src/app/open-integration-example/config.json/route.ts(manifest)src/app/open-integration-example/settings/page.tsx(settings iframe)src/app/open-integration-example/render/page.tsx(render target)src/app/open-integration-example/auth/page.tsx(optional mock OAuth helper)
- Host app: the paperlesspaper where users install/configure integrations.
- Plugin provider: your web app/server that hosts the integration.
- Manifest: JSON document describing the provider (install URL).
- Settings page: a web page embedded as an iframe inside the host app.
- Render page: a web page opened by a renderer (Puppeteer) and screenshot for ePaper.
An Open Integration plugin provider exposes three things:
-
Manifest (install URL)
- A public URL returning a JSON manifest.
- Must be fetchable from the host app browser.
-
Render page (can be any website, Puppeteer target)
- Opened by the renderer.
- Receives its runtime payload via
window.postMessage. - Produces a stable layout for screenshotting.
-
Settings page (iframe, optional)
- Hosted in an iframe in the host app.
- Uses
window.postMessageto exchange settings and optional redirect/auth payloads.
The host expects at least:
name: stringversion: string
description?: stringicon?: string(absolute URL)nativeSettings?: Record<string, any>- Treated as defaults by the host app.
- Applied only if the host-side value is currently
undefined.
formSchema?: JsonSchema(a small subset of JSON Schema)settingsPage?: string(absolute URL)renderPage?: string(absolute URL)
{
"name": "My Integration",
"version": "1.0.0",
"description": "Shows a thing",
"icon": "https://example.com/icon.png",
"nativeSettings": {
"orientation": "portrait"
},
"formSchema": {
"type": "object",
"required": ["apiKey"],
"properties": {
"apiKey": {
"type": "string",
"description": "API key for Example"
},
"refreshSeconds": {
"type": "integer",
"description": "Refresh interval",
"minimum": 60,
"maximum": 86400,
"default": 900
},
"tags": {
"type": "array",
"description": "Optional tags",
"items": { "type": "string" },
"default": []
}
}
},
"settingsPage": "https://example.com/my-plugin/settings",
"renderPage": "https://example.com/my-plugin/render"
}The host uses formSchema to render a simple schema-driven form. Supported property types:
string(enumsupported)booleannumber/integerarrayofstring
“Required” can be expressed in two ways:
- Standard:
schema.required: string[] - Non-standard convenience:
property.required: true
Defaults:
- If
defaultis provided, the host applies it once (when the field isundefined). - Otherwise the host initializes:
array→[]boolean→false- everything else →
""
The host fetches the manifest from the browser. Your config URL should:
- respond to
GET - ideally respond to
OPTIONS - include CORS headers (at minimum
Access-Control-Allow-Origin)
The example in this repo uses:
Access-Control-Allow-Origin: <echo Origin> | *Access-Control-Allow-Methods: GET,OPTIONSVary: Originwhen echoing
If you need credentials/cookies, you must echo the caller origin and send Access-Control-Allow-Credentials: true.
The settings UI runs inside an iframe in the paperlesspaper app and allows more advanced UI and customizations.
It communicates with the app via a message protocol.
The preferred protocol is a structured envelope.
type OpenIntegrationAppToPluginMessage =
| {
source: "wirewire-app";
type: "INIT";
payload: {
settings: Record<string, any>;
nativeSettings: Record<string, any>;
device: { deviceId?: string; kind?: string };
app: { language?: string };
};
}
| {
source: "wirewire-app";
type: "REDIRECT";
payload: {
redirectUrl: string;
tempToken: string;
};
};INITis sent after the iframe loads.REDIRECTis optional and may arrive later. It is used to support OAuth-like flows.
type OpenIntegrationPluginToAppMessage =
| {
source: "wirewire-plugin";
type: "UPDATE_SETTINGS";
payload: Record<string, any>; // patch (merge)
}
| {
source: "wirewire-plugin";
type: "SET_HEIGHT";
payload: { height: number };
}
| {
source: "wirewire-plugin";
type: "INFO";
payload: Record<string, any>;
};The host merges UPDATE_SETTINGS payloads into its stored settings object, that are saved in the "paper".
The host also supports the legacy messages below (and the example provider sends both):
- Plugin → Host settings patch:
{ cmd: "message", data: { ...patch } }
- Plugin → Host height:
{ height: 520 }
And the plugin may receive from older hosts/renderers:
- Host → Plugin init:
{ cmd: "message", data: { ...payload } }
- Host → Plugin redirect:
{ cmd: "redirect", data: { redirectUrl, tempToken } }
The host will size the iframe based on messages from the plugin.
Recommended approach:
- Compute
document.documentElement.scrollHeight - Clamp to a sane range (e.g. 240–1400)
- Post
SET_HEIGHTwhenever the layout changes
- Validate
event.originagainst your expected host origin once you’ve learned it. - Reply to the specific
event.sourcewindow with a stricttargetOrigin. - Avoid putting secrets directly into settings messages (treat them as user-controlled config).
If the host provides a REDIRECT payload, you can start an external auth flow.
-
Host sends
REDIRECTwith:redirectUrl: where to send the user back totempToken: a short-lived token generated by the host
-
Plugin opens an auth URL (popup or new tab).
-
After success, the auth flow redirects back to
redirectUrl.
In memo-mono, the host looks for query params such as:
tempToken(orintegrationTempToken)settings(orintegrationSettings) — a JSON string representing settings to merge
After redeeming the token, the host merges the provided settings and cleans the URL.
Practical notes:
- Keep the
settingspayload small (URL length limits). - Consider encoding/escaping carefully; JSON should be valid and URL-encoded.
The render page is opened by the renderer to take a screenshot.
At runtime, the page receives data via window.postMessage.
Legacy renderer format:
{ cmd: "message", data: payload }Newer envelope (optional):
{ source: "wirewire-app", type: "INIT", payload }A typical payload shape:
type RenderPayload = {
settings?: Record<string, any>;
nativeSettings?: Record<string, any>;
device?: { deviceId?: string; kind?: string };
app?: { language?: string };
paper?: Record<string, any>;
};To support reliable screenshot timing, follow the “Puppeteer rendering contract” used by paperlesspaper:
- Always include
#website-has-loading-element(can be hidden) - When fully ready, render
#website-has-loaded
Example:
<div id="website-has-loading-element" style="display:none"></div>
<div id="website-has-loaded">ready</div>If you have real async loading, you can also temporarily show a loading marker (see the repo README for details).
npm install
npm run devThe example manifest is available at:
http://localhost:3001/open-integration-example/config.json
- Start this repo locally (default
http://localhost:3001). - In memo-mono, open the Integration Plugin editor.
- Install using the config URL:
http://localhost:3001/open-integration-example/config.json
- Host a manifest JSON at a stable public URL
- Add correct CORS headers for browser fetches
- Provide an iframe-friendly settings page that:
- consumes
INIT - emits
UPDATE_SETTINGS - emits
SET_HEIGHT - (optional) handles
REDIRECT
- consumes
- Provide a render page that:
- listens for the payload via
postMessage - renders a deterministic 100vw/100vh layout
- exposes
#website-has-loaded
- listens for the payload via
No. You can provide either:
- a schema-only integration (
formSchemaonly), or - an iframe settings integration (
settingsPage), or - both (common for simple + advanced settings).
In paperlesspaper, plugin settings are stored in the integration metadata and merged from schema defaults and iframe updates.
Yes. The host iframe bridge validates origin when available, and you communicate via postMessage.