@markupai/sidebar-adapter
v1.3.3
Published
Adapter package for MarkupAI Sidebar integration
Readme
@markupai/sidebar-adapter
TypeScript library for structured postMessage communication between your host application (the page that embeds the sidebar) and the Markup AI sidebar (a web app loaded in an <iframe>).
You do not need prior knowledge of how the sidebar is built internally. As an integrator, you provide a sidebar URL (from Markup AI), an iframe, and a small set of callbacks that read and update the user’s document in your editor or viewer.
Contents
- Host integration checklist
- How the pieces fit together
- Installation
- Architecture options
- PluginAdapter (inside the sidebar app → calls the host)
- SidebarAdapter (host page → serves the sidebar)
- Host shell & HTML helpers
- createSidebarHost (iframe + overlay + adapter in one step)
- Authentication (declaring an Auth0 login strategy)
- Sidebar load overlay (standalone)
- Agentic issue lifecycle
- API reference · Development
Features
- Bidirectional IPC — Promise-based calls with timeouts and typed method names
- PluginAdapter — Used inside the sidebar app to call into the host (
getContent,replaceContent, …) - SidebarAdapter — Used in the host page to expose
PluginInterfaceto the iframe - createSidebarHost — Optional one-shot setup: create/mount iframe, optional load-failed overlay, and
SidebarAdapter(or iframe-only when your UI and document logic run in separate contexts) - Host shell helpers —
ensureSidebarHostShell/buildSidebarHostHtmlDocumentso you don’t duplicate container markup and layout CSS - MIME helpers —
MimeTypeenum plus arbitrary MIME strings forContentInfo - Errors —
TextLookupErrorand guards for editor lookup failures - Auth —
SidebarConfig.authfor declaring the Auth0 login strategy ("mediation" | "popup") and optionalPluginInterface.openAuthUrlfor routing the authorize URL through a host-owned external-browser primitive (see Authentication) - Cleanup —
destroy()/ teardown functions on adapters and hosts
Host integration checklist
Use this if you are embedding Markup AI’s sidebar in your product for the first time.
- Get the sidebar URL from Markup AI — Base URL plus any required hash route. For the default agentic experience at the hash root, both the plain base URL (for example,
https://…/) and#/are equivalent withHashRouter;#/only makes the root route explicit. Use#/originalor#/previousfor the legacy Original layout, or#/agenticfor explicit agentic (same as default). The exact URL and routing are part of your integration agreement. - Add an iframe on your host page whose
srcis that URL (or letcreateSidebarHostcreate the iframe for you). - Implement
PluginInterface— Async functions that return document text, apply replacements, open/close dialogs, etc. The sidebar calls these overpostMessage; your code performs the real work in your editor model. - Create a
SidebarAdapter(or callcreateSidebarHost) with:- your
PluginInterfaceimplementation, and adapterOptions.targetOriginset tosidebarPostMessageTargetOrigin(sidebarUrl)so messages are scoped to the sidebar origin (safer than"*").
- your
- Test the basics — After load, the sidebar will request
getInitConfigandgetContent. Confirm your app returns sensible values before testing edits and dialogs. - Tear down on exit — Call
destroy()on the host returned bycreateSidebarHost, or on yourSidebarAdapter, when the user leaves the screen or you remove the iframe.
The sidebar runs the agentic experience. Its optional PluginInterface hooks and SidebarInterface RPCs let a host mirror the sidebar's issues in its own UI — useful for tracking issues and for context-menu flows such as applying a suggestion inline or activating an issue's card:
- Receive streaming results — implement
onAgenticAgentResultsandonAgenticStreamCompleteto get issues (each with its suggestions) as they stream plus a completion signal, so you can draw your own underlines or build a context menu (see PluginInterface). - Track issue state — implement
onAgenticIssueStatusChanged; the sidebar emits it after every Open / Resolved / Dismissed transition so you can keep annotations or other host UI in sync. - Drive the sidebar — call
activateAgenticIssueto expand and scroll to a card (e.g. when the user clicks an underline or picks the issue from a context menu), andmarkAgenticIssueResolved/restoreAgenticIssue/invalidateAgenticIssueto resolve / restore / dismiss a card — for example after applying a suggestion inline from your own context menu.
How the pieces fit together
┌────────────────────────────── Host page (your app) ──────────────────────────────┐
│ Your editor / viewer logic │
│ ▲ │
│ │ PluginInterface (getContent, replaceContent, …) │
│ │ │
│ SidebarAdapter ◄──── postMessage ────► PluginAdapter (inside iframe) │
│ │ │
│ │ ┌──────────────────────────┐ │
│ └──────────────│ <iframe src="sidebar URL"> │ │
│ │ Markup AI sidebar UI │ │
│ └──────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────┘SidebarAdapter+PluginInterfacelive on the host. They answer requests from the iframe.PluginAdapterlives inside the sidebar (Markup’s app or a compatible build). It is what calls your host. You only import it if you maintain sidebar code.
Installation
npm install @markupai/sidebar-adapterArchitecture options
| Integration style | Where the sidebar iframe runs | Where your document/editor code runs | Typical approach |
| ----------------------------- | ------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Same window (most common) | Same browser tab as your host UI | Same tab / same JavaScript context | Implement PluginInterface in that page; use createSidebarHost (or SidebarAdapter + your own iframe) and optional ensureSidebarHostShell. |
| Split contexts (advanced) | Often a separate UI surface that only hosts the iframe | Another realm (worker, native bridge, backend) | Use createSidebarHost without plugin, then forward raw postMessage events between the iframe and the realm where you implement PluginInterface. That realm can use IPCCore and related exports from this package to decode/encode the same protocol. |
Split-context setups are uncommon; most integrations use same-window (host UI and document logic together in one page).
Usage
PluginAdapter (Sidebar → Editor)
Use PluginAdapter inside the sidebar application (the code running in the iframe) to call into the host page. Host-only integrators can skip this section.
import { PluginAdapter, type ContentInfo } from "@markupai/sidebar-adapter";
// Create the adapter (no interface needed - using PULL pattern)
const adapter = new PluginAdapter();
// Get initialization config from the editor
const config = await adapter.getInitConfig();
console.log("Sidebar initialized", config);
// Get content from the editor
// Integration provides content in its preferred format
const content = await adapter.getContent();
console.log("Received content:", content.content);
console.log("Document reference:", content.documentReference); // Required from integration
console.log("MIME type:", content.mimeType); // Optional from integration
// Get selected content from the editor
// No MIME type parameter - integration decides the format
const selectedContent = await adapter.getSelectedContent();
console.log("Selected content:", selectedContent.content);
console.log("Document reference:", selectedContent.documentReference);
// Select content in the editor (half-open range [0, 11))
await adapter.selectContent({ start: 0, end: 11 });
// Replace content in the editor (apply suggestion to [start, end))
await adapter.replaceContent("Hello universe", { start: 0, end: 11 });
// Replace multiple contents in the editor
await adapter.replaceMultipleContents([
{ suggestion: "Hello universe", range: { start: 0, end: 11 } },
{ suggestion: "bar", range: { start: 20, end: 23 } },
]);
// Show a dialog
adapter.showDialog("<div>Custom dialog content</div>", 400, 300, "My Dialog");
// Request initialization
adapter.requestInit();
// Clean up when done
adapter.destroy();SidebarAdapter (Editor → Sidebar)
Use SidebarAdapter on the host page (parent of the iframe) to connect your PluginInterface implementation to the sidebar:
import {
SidebarAdapter,
MimeType,
type PluginInterface,
type ContentRange,
type ContentReplacement,
type ContentInfo,
} from "@markupai/sidebar-adapter";
// Implement the PluginInterface
const pluginInterface: PluginInterface = {
// Get current document content
// Return content in your preferred format with required documentReference
getContent: async (): Promise<ContentInfo> => {
const content = getContentFromEditor();
return {
content: content,
documentReference: "my-document.md", // Required: unique identifier for the document
mimeType: MimeType.TEXT_MARKDOWN, // Optional: can use MimeType enum or any string
};
},
// Get selected content
// Return selected content with required documentReference
getSelectedContent: async (): Promise<ContentInfo> => {
const selectedText = getSelectedContentFromEditor();
return {
content: selectedText,
documentReference: "selection.txt", // Required
mimeType: "text/plain", // Optional: can be omitted if not applicable
};
},
// Get initialization configuration
getInitConfig: async () => {
return {
integrationName: "My Editor",
integrationVersion: "1.0.0",
integrationId: "my-editor",
useCheckPreviewDialog: true,
supportCheckSelection: true,
pluginOrigin: globalThis.location.origin,
// Optional. Declare the Auth0 login strategy the sidebar should run.
// Omit to let the sidebar pick heuristically (legacy behavior).
// See the "Authentication" section below.
auth: { type: "mediation" },
};
},
// Select content in the editor
selectContent: async (range: ContentRange) => {
console.log("Select content range:", range);
selectContentInEditor(range);
},
// Replace content in the editor
replaceContent: async (suggestion: string, range: ContentRange) => {
console.log("Replace content span:", range, "with:", suggestion);
replaceContentInEditor(suggestion, range);
},
// Replace multiple contents in the editor
replaceMultipleContents: async (replacements: ContentReplacement[]) => {
console.log("Replace multiple contents:", replacements);
replaceMultipleContentsInEditor(replacements);
},
// Show a dialog (optional — omit on hosts without dialog support)
showDialog: async (dialogHtml: string, width?: number, height?: number, title?: string) => {
console.log("Show dialog:", title);
showDialogInEditor(dialogHtml, width, height, title);
},
// Close the dialog (optional — omit on hosts without dialog support)
closeDialog: async () => {
console.log("Close dialog");
closeDialogInEditor();
},
};
// Create the adapter with an iframe reference
const iframe = document.getElementById("sidebar-iframe") as HTMLIFrameElement;
const sidebarAdapter = new SidebarAdapter(pluginInterface, iframe);
// The adapter listens to sidebar requests and routes them to your plugin interface
// Your plugin interface methods (getContent, getSelectedContent, etc.) return the data
// Clean up when done
sidebarAdapter.destroy();
integrationNamevsintegrationId— two distinct concerns:
integrationName(required) is the human-readable name of the integration, display only (e.g. shown on the sidebar's About page). Example:"Madcap Flare Desktop". Never derive a machine identifier from it.integrationId(optional) is the stable, machine-readable slug (lowercase, hyphenated, no spaces) used for everything non-display: the Auth0 mediation login provider, PostHog analytics keys, thex-integration-idAPI header, and per-integration settings storage. Example:"madcap-flare-desktop". Keep it stable across releases. When omitted it falls back toDEFAULT_SIDEBAR_CONFIG.integrationId("markupai-sidebar-app"); mediation-login hosts must set it to match their Auth0 relay connection name (e.g. Figma →"figma"), since the sidebar feeds it straight to the login provider.
SidebarLoadOverlay (Editor UI)
Use setupSidebarLoadOverlay when you manage the iframe yourself and only want the failed-to-load / retry UI. Overlay styles are injected into container.ownerDocument (usually the same as the global document when the container lives in your host page). That keeps styles aligned with the document that actually contains the overlay—important if the container is in another window (e.g. undocked popup) or frame.
The overlay listens for the first qualifying message from iframe.contentWindow to treat the sidebar as loaded. By default it attaches to the global window. If the iframe’s parent is not that window (popup, separate host document), pass messageListenWindow in options so it matches the iframe’s parent—otherwise the timeout may fire even when the sidebar is healthy.

import { setupSidebarLoadOverlay } from "@markupai/sidebar-adapter";
const container = document.getElementById("sidebar-root") as HTMLElement;
const iframe = document.getElementById("sidebar-iframe") as HTMLIFrameElement;
const teardownLoadOverlay = setupSidebarLoadOverlay(container, iframe, {
timeoutMs: 8000,
retryIntervalMs: 12000,
});
// Later:
teardownLoadOverlay();SidebarLoadOverlayOptions (all optional beyond defaults): sidebarUrl, targetOrigin, timeoutMs, retryIntervalMs, messageListenWindow (see above).
injectSidebarLoadOverlayStyles(targetDocument?) — injects the overlay CSS once per document. Omit targetDocument to use the global document (backward-compatible). You rarely need to call this yourself; setupSidebarLoadOverlay handles it via container.ownerDocument.
Prefer createSidebarHost if you also want SidebarAdapter and/or iframeMount in one place. Pass loadOverlayOptions (same shape as the third argument to setupSidebarLoadOverlay) when using loadOverlayContainer.
createSidebarHost
Creates the sidebar iframe (or uses yours), optionally mounts the load overlay, and optionally constructs SidebarAdapter.
Iframe: pass exactly one of iframe or iframeMount (container, src, optional allow). Adapter-created iframes use a fixed accessible title (SIDEBAR_IFRAME_DEFAULT_TITLE) and set allow to SIDEBAR_IFRAME_DEFAULT_ALLOW (clipboard-read; clipboard-write) unless you pass allow: "" to omit it or override the string. destroy() removes a mounted iframe from the DOM.
Return type: createSidebarHost returns { iframe, adapter, destroy }. When plugin is set, adapter is a SidebarAdapter; when plugin is omitted (split-context integration), adapter is null and only the overlay runs (if loadOverlayContainer is set).
Shared helpers
SIDEBAR_DEFAULT_PRODUCTION_BASE_URL— canonical prod sidebar base URL (https://sidebar.markup.ai/); pass togetSidebarBaseUrlWithOverridewhen you have no env-specific default.SIDEBAR_URL_OVERRIDE_STORAGE_KEY—localStoragekey for the override; usegetSidebarBaseUrlWithOverride(defaultUrl)to resolve the base URL (reads override when set, otherwisedefaultUrl; safe whenlocalStorageis missing or throws).sidebarPostMessageTargetOrigin(sidebarUrl, options?)— derivesadapterOptions.targetOriginfrom the sidebar URL (falls back to"*"; optionalonInvalidUrlcallback).assertSidebarHostAdapter(host, message?)— type-narrowshostaftercreateSidebarHostwhen you passedplugin(throws ifadapterisnull).AUTH_CONFIG_OVERRIDE_STORAGE_KEY—localStoragekey for forcing anAuthConfigat runtime (dev/test only); used together withgetAuthConfigWithOverride(hostConfig, allowOverride)which honors the override only whenallowOverrideistrue. See Authentication.
import {
assertSidebarHostAdapter,
createSidebarHost,
sidebarPostMessageTargetOrigin,
type PluginInterface,
} from "@markupai/sidebar-adapter";
const container = document.getElementById("sidebar-container") as HTMLElement;
// Use the URL Markup AI provides. Default Agentic: base URL (equivalent to #/). Explicit overrides: #/original, #/previous, #/next-ui, or #/agentic.
const sidebarUrl = "https://your-sidebar-host/";
// `myPlugin` is your PluginInterface implementation (see SidebarAdapter example above).
const host = createSidebarHost({
iframeMount: {
container,
src: sidebarUrl,
},
plugin: myPlugin,
loadOverlayContainer: container,
loadOverlayOptions: { timeoutMs: 8000, retryIntervalMs: 12000 },
adapterOptions: { targetOrigin: sidebarPostMessageTargetOrigin(sidebarUrl) },
});
assertSidebarHostAdapter(host, "Expected adapter when plugin is set");
// Use host.iframe for extra wiring (e.g. message forwarders). Later:
host.destroy();- If you already have an iframe, pass
iframeand omitiframeMount. - If you omit
loadOverlayContainer, no overlay is mounted (same as usingnew SidebarAdapteronly, plus optional iframe creation). loadOverlayOptions: forwarded tosetupSidebarLoadOverlay. UsemessageListenWindowwhen the iframe parent is not the hostwindow(e.g. sidebar iframe inside a popup; plugin /SidebarAdaptermay useadapterOptions.winfor the same window).- Split-context integration: omit
plugin, setloadOverlayContainer+iframeMount(oriframe), then forwardpostMessagetraffic betweenhost.iframeand the context where yourPluginInterfaceruns (see Architecture options).
Authentication (optional)
The sidebar runs an Auth0 login flow when the user signs in. You can tell it which strategy to use from the host via SidebarConfig.auth, and — for sandboxed hosts where globalThis.open is blocked — route the Auth0 authorizeUrl through your own external-browser primitive via the optional PluginInterface.openAuthUrl hook.
AuthConfig
type AuthConfig = { type: "mediation" } | { type: "popup" };"mediation"— backend-mediated authorization code flow. The sidebar asks the backend to start an OAuth handshake, opens the returnedauthorizeUrlin an external browser, and polls the backend for the code + token exchange. Required for hosts embedded in iframes with restricted origins (Figma plugin UI, Office task pane)."popup"— Auth0 SPAloginWithPopup. Requires a secure origin and Web Crypto.
Declare it in your PluginInterface.getInitConfig return value:
getInitConfig: async () => ({
// ...other fields...
auth: { type: "mediation" },
});For mediation, you may also set provider — the Auth0 relay-connection slug the sidebar sends to start the OAuth handshake:
auth: { type: "mediation", provider: "microsoft-365" }Set provider when one login provider must serve several integrationIds — e.g. a single add-in spanning multiple editors that each report a distinct integrationId (for analytics / the x-integration-id header / settings storage) but share one Auth0 relay connection. When omitted, the sidebar derives the provider from integrationId.
When auth is omitted, the sidebar falls back to legacy heuristic selection for backwards compatibility.
openAuthUrl (sandboxed hosts)
Mediation needs to open the Auth0 authorizeUrl in an external browser. By default the sidebar uses globalThis.open(url, "_blank", "noopener,noreferrer"). In hosts where that's blocked — notably M365 / Office task panes — implement the optional openAuthUrl method on PluginInterface:
import type { PluginInterface } from "@markupai/sidebar-adapter";
const plugin: PluginInterface = {
// ...other methods...
openAuthUrl: async (url) => {
// In an Office add-in:
Office.context.ui.openBrowserWindow(url);
// If the host cannot open the URL, throw so the sidebar fails the
// mediation login fast (instead of polling to a 120 s timeout).
},
};When the plugin does not implement openAuthUrl, the sidebar detects the absence via the standard "No receiver registered for: openAuthUrl" IPC error and falls back to its own globalThis.open. Any other error thrown by your handler (e.g. popup blocked) is re-raised to the user unchanged.
Dev/test override
In dev builds you can force an AuthConfig without changing host code by writing a JSON string to localStorage under the key exported as AUTH_CONFIG_OVERRIDE_STORAGE_KEY. The sidebar applies the override only when the consumer passes allowOverride: true to getAuthConfigWithOverride(hostConfig, allowOverride) — for example, gated on import.meta.env.DEV:
// Force mediation in a local dev tab:
localStorage.setItem("markupai.sidebarNextGen.authOverride", '{"type":"mediation"}');Invalid JSON or unknown type values are logged and ignored; the host's declared auth is used instead.
Sidebar host shell (shared HTML/DOM)
Avoid copying the same #sidebarContainer + full-height layout CSS into every host HTML file.
Runtime (most hosts)
import {
createSidebarHost,
ensureSidebarHostShell,
sidebarPostMessageTargetOrigin,
} from "@markupai/sidebar-adapter";
const container = ensureSidebarHostShell({ root: document });
createSidebarHost({
iframeMount: { container, src: sidebarUrl },
plugin: myPlugin,
loadOverlayContainer: container,
adapterOptions: { targetOrigin: sidebarPostMessageTargetOrigin(sidebarUrl) },
});Build time (single emitted HTML file) — when your bundler produces one static HTML file for the host UI:
import { buildSidebarHostHtmlDocument } from "@markupai/sidebar-adapter";
const html = buildSidebarHostHtmlDocument({
title: "My app — Markup AI sidebar host",
includePluginHostHead: true,
bodyScriptsHtml: `<script type="module" src="./host-entry.js"></script>`,
});Use includePluginHostHead: true for a sensible viewport <meta> block, or buildSidebarPluginHostHeadInnerHtml() if you only need that fragment for a template you control. Point bodyScriptsHtml at the script URL your build tool emits for the host page.
Also exported: SIDEBAR_HOST_CONTAINER_ID (default sidebarContainer), buildSidebarHostShellCss, SIDEBAR_HOST_SHELL_CSS (default id), SIDEBAR_DEFAULT_PRODUCTION_BASE_URL, getSidebarBaseUrlWithOverride, SIDEBAR_URL_OVERRIDE_STORAGE_KEY, sidebarPostMessageTargetOrigin, assertSidebarHostAdapter, SIDEBAR_IFRAME_DEFAULT_TITLE, SIDEBAR_IFRAME_DEFAULT_ALLOW (default iframeMount clipboard policy).
API Reference
Types
MimeType
Convenience enum for common MIME types. Integrations are not limited to these values and can use any valid MIME type string.
enum MimeType {
TEXT_PLAIN = "text/plain",
TEXT_MARKDOWN = "text/markdown",
TEXT_HTML = "text/html",
APPLICATION_DITA_XML = "application/dita+xml",
}ContentInfo
Content information returned by integration methods. The integration must provide content and documentReference, while mimeType is optional.
interface ContentInfo {
content: string | number[]; // Text content or binary content as byte array
documentReference: string; // Required: Unique identifier for the document (e.g., filename, path, ID)
mimeType?: string; // Optional: Any valid MIME type string (not limited to MimeType enum)
}ContentReplacement
Structure for batch content replacement operations.
type ContentReplacement = {
suggestion: string;
range: ContentRange;
};
type ContentRange = {
start: number;
end: number;
};For agentic streaming callbacks, issue objects match the exported AgenticIssuePayload shape (stable id plus fields the sidebar sends per issue).
PluginInterface
Implement these on the host as async functions. The sidebar calls them over IPC and waits on the returned promises (pull style):
getInitConfig(): Promise<SidebarConfig>— Integration display name (integrationName), stable machine id (integrationId), version, feature flags, dialog closing script, etc. See theintegrationNamevsintegrationIdnote above.getContent(): Promise<ContentInfo>— Full document (or main buffer) text; must includedocumentReference;mimeTypeoptionalgetSelectedContent(): Promise<ContentInfo>— Current selection; sameContentInforules asgetContentselectContent(range: ContentRange): Promise<void>— Focus/highlight the document span[range.start, range.end)replaceContent(suggestion: string, range: ContentRange): Promise<void>— Apply a single edit to[range.start, range.end)replaceMultipleContents(replacements: ContentReplacement[]): Promise<void>— Batch editsshowDialog?(dialogHtml: string, width?: number, height?: number, title?: string): Promise<void>— Host-rendered dialog chrome around HTML from the sidebar. Optional — omit on hosts without dialog support (also leaveuseCheckPreviewDialog: false).closeDialog?(): Promise<void>— Dismiss that dialog. Optional, pairs withshowDialog.
Optional (agentic / streaming flows):
onAgenticAgentResults?(agentName: string, issues: AgenticIssuePayload[]): Promise<void>— Per-agent results as they streamonAgenticStreamComplete?(): Promise<void>— Stream finished (success or terminal state)onAgenticIssueStatusChanged?(issueId: string, status: "active" | "resolved" | "dismissed", source: "user" | "host"): Promise<void>— Fired after every successful per-issue status transition.sourceis"user"for sidebar-card-driven transitions and"host"for transitions driven by the inboundSidebarInterfaceRPCs (invalidateAgenticIssue/markAgenticIssueResolved/restoreAgenticIssue). Best-effort: an absent or throwing handler MUST NOT block sidebar UI transitions. No-op RPC calls do not re-emit this notification.
Optional (authentication):
openAuthUrl?(url: string): Promise<void>— Open the Auth0authorizeUrlin whatever external-browser primitive the host provides. Implement this in sandboxed hosts whereglobalThis.openis blocked or unavailable (e.g. Office task panes should callOffice.context.ui.openBrowserWindow(url)). Throw if the window cannot be opened so the mediation flow fails fast instead of polling to timeout. When omitted, the sidebar falls back toglobalThis.open(url, "_blank", "noopener,noreferrer").
Optional (clipboard):
copyToClipboard?(text: string): Promise<void>— Write text to the system clipboard on the sidebar's behalf. Implement this in hosts where the sidebar iframe cannot use the async clipboard API itself — notably VS Code webviews, whose Electron permission handler deniesclipboard-writeto any frame that is not on thevscode-webview://origin (the VS Code extension callsvscode.env.clipboard.writeText(text)in the extension host). Reject to signal the write failed. When omitted, the sidebar detects the absence via the standard"No receiver registered for: copyToClipboard"IPC error and falls back tonavigator.clipboard.writeText, thendocument.execCommand("copy").
Error control flows
When implementing selectContent or replaceContent, throw TextLookupError from this package when text lookup (e.g. locating the selection or target range) fails. The sidebar will treat it as a selection failure and can invalidate the issue card with the error message.
import { TextLookupError } from "@markupai/sidebar-adapter";
// In selectContent or replaceContent:
if (!found) {
throw new TextLookupError("Check text again to locate this issue.");
}Errors sent over IPC are reconstructed on the sidebar, so you can type-check with instanceof or the isTextLookupError guard:
import { isTextLookupError, type TextLookupError } from "@markupai/sidebar-adapter";
try {
await adapter.selectContent({ start: 0, end: 5 });
} catch (error) {
if (isTextLookupError(error)) {
// error is TextLookupError; e.g. invalidate issue card with error.message
invalidateIssueWithError(issueId, error.message);
} else {
throw error;
}
}Note: We document thrown errors via JSDoc @throws on the plugin interface and export union types SelectContentError and ReplaceContentError for control-flow typing when catching.
Message Format
The adapter uses a promise-based IPC system with automatic request/response correlation:
// Call message (sidebar → editor)
{
type: 'call';
id: number;
name: string; // Method name (e.g., 'getContent')
data: any[]; // Method arguments
}
// Response message (editor → sidebar)
{
type: 'response';
id: number; // Matches the call id
name: string;
data: any; // Return value
}
// Error message
{
type: 'error';
id: number;
name: string;
error: string;
}Agentic issue lifecycle
The agentic layout surfaces an audit trail for every scan — each issue moves between three states: Open (status: "active"), Resolved, and Dismissed. Sidebar users transition issues with card buttons; hosts can mirror or drive the same transitions through the adapter.
Listening for transitions (sidebar → host)
Implement onAgenticIssueStatusChanged on your PluginInterface to react to every successful transition — including user actions in the sidebar and transitions caused by your own RPC calls. Use it to clear or update host-side annotations, refresh your own state, or feed analytics.
import type { PluginInterface } from "@markupai/sidebar-adapter";
const plugin: PluginInterface = {
// ...other methods omitted...
onAgenticIssueStatusChanged: async (issueId, status, source) => {
// status: "active" | "resolved" | "dismissed"
// source: "user" — sidebar card click | "host" — your own RPC
switch (status) {
case "resolved":
case "dismissed":
clearAnnotationForIssue(issueId);
break;
case "active":
// The issue was un-resolved or un-dismissed and is back in the working set.
repaintAnnotationForIssue(issueId);
break;
}
},
};Notes:
- The hook is best-effort: an absent or throwing implementation does not block the sidebar's own UI transition. Errors thrown from the handler are logged and swallowed.
- No-op RPCs (e.g. marking an already-resolved issue, restoring an open issue) MUST NOT emit a duplicate notification.
- Applied-suggestion resolves are reported the same way as manual ones (
status: "resolved"), but they are terminal — see "Restoring issues" below.
Driving transitions (host → sidebar)
SidebarAdapter exposes three host-driven RPCs, all idempotent and optional on the sidebar side:
await sidebarAdapter.invalidateAgenticIssue(issueId); // → status: "dismissed"
await sidebarAdapter.markAgenticIssueResolved(issueId); // → status: "resolved"
await sidebarAdapter.restoreAgenticIssue(issueId); // → status: "active"invalidateAgenticIssue(issueId)— Existing dismiss RPC. Use after the host clears the underlying range from its document, or to programmatically dismiss in response to host UI.markAgenticIssueResolved(issueId)— Marks an open issue resolved without applying a suggestion. The resulting issue is restorable viarestoreAgenticIssue. Silent no-op for ids that are already in a non-open state or unknown.restoreAgenticIssue(issueId)— Moves a manually-resolved or dismissed issue back to the open state. Silent no-op for ids that are already open, resolved via an applied suggestion (terminal — see below), or unknown.
Restoring issues — what is and isn't reversible
The sidebar treats reversibility asymmetrically:
| Prior state | Reversible by restoreAgenticIssue? | Why |
| -------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Dismissed (any reason) | Yes | No document edit was made. |
| Manually resolved | Yes | No document edit was made. |
| Resolved via applying a suggestion | No | The applied text has already been written into the host document — restoring would desync the UI from the document. |
A restoreAgenticIssue call on a non-restorable resolved issue is a silent no-op (no error, no notification). If your host needs to fully undo an applied suggestion, do it on the host side first (your own undo stack) and then call markAgenticIssueResolved/restoreAgenticIssue as appropriate to keep the sidebar in sync.
End-to-end example
import { SidebarAdapter, type PluginInterface } from "@markupai/sidebar-adapter";
const plugin: PluginInterface = {
// ... getContent, replaceContent, etc.
onAgenticIssueStatusChanged: async (issueId, status, source) => {
syncAnnotation(issueId, status);
if (source === "host") {
// Echo from our own RPC; skip downstream work that's already done.
return;
}
notifyTelemetry({ issueId, status });
},
};
const sidebarAdapter = new SidebarAdapter(plugin, iframe);
// Host UI button "Accept in document"
async function onHostAcceptClicked(issueId: string) {
await acceptInDocument(issueId);
await sidebarAdapter.markAgenticIssueResolved(issueId);
}
// Host UI button "Reopen"
async function onHostReopenClicked(issueId: string) {
await sidebarAdapter.restoreAgenticIssue(issueId);
}Integration settings
A host MAY declare a small schema of settings it wants the sidebar to render and persist on its behalf. The sidebar shows a section in its Settings surface, persists user choices per-integrationId in localStorage, and notifies the host on user edits. The host can also push values and per-field UI state (disabled, hidden, allowedValues) to enforce its own rules — for example, "annotations off ⇒ view mode locked to original".
This is opt-in and additive. Hosts that omit integrationSettings from their SidebarConfig and never call applyIntegrationSettings see no behavior change.
Declaring a schema
import type { SidebarConfig } from "@markupai/sidebar-adapter";
const config: SidebarConfig = {
integrationName: "My Integration",
integrationVersion: "1.0.0",
integrationId: "my-integration",
useCheckPreviewDialog: false,
integrationSettings: {
title: "My integration",
fields: [
{
id: "showAnnotations",
type: "boolean",
label: "Show annotations",
default: true,
},
{
id: "viewMode",
type: "enum",
label: "View mode",
default: "simple",
options: [
{ value: "simple", label: "Simple markup" },
{ value: "none", label: "No markup" },
{ value: "original", label: "Original" },
],
},
],
},
};Field ids are stable storage keys inside the integration's namespace — keep them constant across versions so persisted values continue to apply.
Reacting to user edits
Implement onIntegrationSettingsChange on your PluginInterface:
import type { PluginInterface } from "@markupai/sidebar-adapter";
const plugin: PluginInterface = {
// …existing methods…
onIntegrationSettingsChange: async (values, meta) => {
// meta.source is always "user". Host-driven changes (your own
// applyIntegrationSettings calls) never echo back, so this handler
// fires only for actual user edits.
if (values.showAnnotations === false) {
// Apply your business rule (e.g. hide annotations in the editor).
}
},
};Implementing the handler is optional; if you omit it, the sidebar simply does not call you.
Driving the form from the host
The SidebarAdapter exposes applyIntegrationSettings(patch) so the host can push values and constraints into the sidebar:
// Lock viewMode to "original" when annotations are off.
await sidebarAdapter.applyIntegrationSettings({
values: { viewMode: "original" },
fieldStates: {
viewMode: { allowedValues: ["original"], disabled: true },
},
});
// Later, when annotations are re-enabled, clear the constraint.
await sidebarAdapter.applyIntegrationSettings({
fieldStates: { viewMode: null },
});Merge semantics: keys present in the patch overwrite prior state; absent keys preserve it; passing null for a field's state clears it. Values pushed by the host are persisted; field states are session-only — the host re-asserts them on init or in response to its own state changes.
Reading current state from the host
When your host UI re-mounts mid-session (for example, an Office task pane that gets unloaded and reloaded), call getIntegrationSettings() to read the sidebar's current snapshot before deciding whether to push your own state — the sidebar is the source of truth for persisted user values, and unconditional applyIntegrationSettings calls on every init would silently overwrite the user's choice.
const { values, fieldStates } = await sidebarAdapter.getIntegrationSettings();
// Reconcile with whatever the host believes about its own state, then call
// applyIntegrationSettings only for the deltas you actually want to enforce.When the host has not declared integrationSettings in its SidebarConfig, the snapshot is { values: {}, fieldStates: {} }.
Lifecycle and persistence
- The sidebar persists values under the
localStoragekeymarkupai:integrationSettings:<integrationId>. DifferentintegrationIdvalues are isolated. - Schema validation is lenient: fields whose
typethe current sidebar does not understand are dropped (with a singleconsole.warn). The remaining known fields still render — a host that ships a schema using a newer field type degrades gracefully on older sidebars without an explicit version handshake. - Top-level malformed schemas (e.g.
fieldsis not an array) are treated as absent — the sidebar logs aconsole.warnand never throws. - Host-driven value changes do not echo back via
onIntegrationSettingsChange. The host already knows what it pushed.
Race semantics — last-write-wins per field
Updates are applied per-field with no coordination between user edits and host pushes:
- A user toggle and a near-simultaneous
applyIntegrationSettingsboth target the same field — whichever lands second wins for that field. - This means a reactive host that calls
applyIntegrationSettingsfrom insideonIntegrationSettingsChange(e.g. "if annotations are off, lock viewMode to original") can briefly visually revert a user click if the IPC round-trip lands after the user's own state change has rendered. This is a feature, not a bug — the host's business rule is meant to override — but make sure to apply your rules narrowly so unrelated user edits aren't silently undone. - Use
getIntegrationSettings()rather than caching last-known values when you need to be sure you're reconciling against the sidebar's actual current state.
Development
The commands below are for contributors working on this npm package’s source. If you only consume @markupai/sidebar-adapter from your app, you do not need to run them.
Building
npm run buildTesting
npm test
npm run test:watch
npm run test:coverageFormatting
npm run format:fix
npm run format:checkIPC contract (AsyncAPI)
The sidebar↔host IPC protocol is published as a machine-readable AsyncAPI 3.0 document, generated from this package's TypeScript types. Other-language ports (e.g. a C# host embedding the sidebar) use it for codegen and contract testing.
Get it three ways:
- npm resource — it ships in the tarball:
@markupai/sidebar-adapter/asyncapi.json. require.resolve—require.resolve("@markupai/sidebar-adapter/asyncapi.json").- Served URL — per environment, e.g.
https://sidebar.dev.markup.ai/asyncapi.json(alsosidebar.stg.markup.ai,sidebar.markup.ai), with permissive CORS.
info.version equals this package's version, so a consumer always knows which
contract revision it is testing against. The document is generated — do not
hand-edit asyncapi.json. Regenerate with npm run build:contract.
See docs/contract/consuming-the-ipc-contract.md
for the consumer workflow.
