npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@markupai/sidebar-adapter

v2.3.0

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

  1. Host integration checklist
  2. How the pieces fit together
  3. Installation
  4. Architecture options
  5. PluginAdapter (inside the sidebar app → calls the host)
  6. SidebarAdapter (host page → serves the sidebar)
  7. Host shell & HTML helpers
  8. createSidebarHost (iframe + overlay + adapter in one step)
  9. Authentication (declaring an Auth0 login strategy)
  10. Sidebar load overlay (standalone)
  11. Agentic issue lifecycle · Document references
  12. Multi-document hosts
  13. Sidebar build reporting (which sidebar build is running)
  14. 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 PluginInterface to 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 / buildSidebarHostHtmlDocument so you don’t duplicate container markup and layout CSS
  • MIME helpers — MimeType enum plus arbitrary MIME strings for ContentInfo
  • Errors — TextLookupError / ReplaceBatchError and guards for editor lookup failures
  • Auth — SidebarConfig.auth for declaring the Auth0 login strategy ("mediation" | "popup") and optional PluginInterface.openAuthUrl for 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.

  1. 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 with HashRouter; #/ only makes the root route explicit. Use #/original or #/previous for the legacy Original layout, or #/agentic for explicit agentic (same as default). The exact URL and routing are part of your integration agreement.
  2. Add an iframe on your host page whose src is that URL (or let createSidebarHost create the iframe for you).
  3. Implement PluginInterface — Async functions that return document text, apply replacements, open/close dialogs, etc. The sidebar calls these over postMessage; your code performs the real work in your editor model.
  4. Create a SidebarAdapter (or call createSidebarHost) with:
    • your PluginInterface implementation, and
    • adapterOptions.targetOrigin set to sidebarPostMessageTargetOrigin(sidebarUrl) so messages are scoped to the sidebar origin (safer than "*").
  5. Test the basics — After load, the sidebar will request getInitConfig and getContent. Confirm your app returns sensible values before testing edits and dialogs.
  6. Tear down on exit — Call destroy() on the host returned by createSidebarHost, or on your SidebarAdapter, 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 onAgenticAgentResults and onAgenticStreamComplete to 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.
  • Know when a document's check is discarded — implement onAgenticIssuesCleared; the sidebar emits it when the user clears a document's data, so you can drop highlights instead of pointing at suggestions that no longer exist.
  • Drive the sidebar — call activateAgenticIssue to expand and scroll to a card (e.g. when the user clicks an underline or picks the issue from a context menu), and markAgenticIssueResolved / restoreAgenticIssue / invalidateAgenticIssue to resolve / restore / dismiss a card — for example after applying a suggestion inline from your own context menu.

See Agentic issue lifecycle.

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 + PluginInterface live on the host. They answer requests from the iframe.
  • PluginAdapter lives 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-adapter

Architecture 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 });

// Multi-document hosts: pass the range's owning documentReference as the
// trailing argument so the host applies it to the right document even if a
// different one is currently active. Omit (or pass null) for "whichever
// document the host currently considers active" — the single-document
// default. See "Multi-document hosts" below.
await adapter.selectContent({ start: 0, end: 11 }, content.documentReference);
await adapter.replaceContent("Hello universe", { start: 0, end: 11 }, content.documentReference);

// Replace multiple contents in the editor
await adapter.replaceMultipleContents([
  { suggestion: "Hello universe", range: { start: 0, end: 11 } },
  { suggestion: "bar", range: { start: 20, end: 23 } },
]);

// 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,
  buildDocumentReference,
} 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,
      // Required. "<prefix>:<id>" so the same document correlates across Markup surfaces.
      // Pick the prefix once for your platform and never change it.
      documentReference: buildDocumentReference("my-host", getStableDocumentId()),
      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",
      supportCheckSelection: true,
      // Optional. Allow the sidebar to run several agents in one check, each
      // cancellable on its own. Defaults to false (Style agent only). The
      // agents actually offered are the intersection of this flag and the
      // organization's entitlement.
      multiAgent: false,
      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.
  // documentReference identifies which document `range` belongs to. In a
  // single-document host, or when this integration hasn't opted into
  // multiDocument, it's always null — ignore it. In a multi-document host,
  // omitted/null means "whichever document I currently consider active";
  // otherwise resolve it to the target document first. See
  // "Multi-document hosts" below.
  selectContent: async (range: ContentRange, documentReference: string | null) => {
    console.log("Select content range:", range, "in document:", documentReference);
    selectContentInEditor(range, documentReference);
  },

  // Replace content in the editor. Same documentReference rule as selectContent.
  replaceContent: async (
    suggestion: string,
    range: ContentRange,
    documentReference: string | null,
  ) => {
    console.log("Replace content span:", range, "with:", suggestion, "in:", documentReference);
    replaceContentInEditor(suggestion, range, documentReference);
  },

  // Replace multiple contents in the editor
  replaceMultipleContents: async (replacements: ContentReplacement[]) => {
    console.log("Replace multiple contents:", replacements);
    replaceMultipleContentsInEditor(replacements);
  },
};

// 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();

integrationName vs integrationId — 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, the x-integration-id API header, and per-integration settings storage. Example: "madcap-flare-desktop". Keep it stable across releases. When omitted it falls back to DEFAULT_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.

Still relevant after the sidebar’s service worker. The sidebar now registers a service worker that precaches its app shell, so once a host has opened it successfully once, later opens paint from the local cache with no network round-trip — the load timeout and auto-retry then almost never fire. It is not redundant, though: the overlay is still the recovery path for the first-ever open in a host (cold, nothing cached yet — exactly when a slow or flaky host-machine network hurts most), a host where the service worker cannot register (restricted iframe sandbox, non-secure context), and a genuine origin outage or misconfiguration. The "loaded" signal is unchanged — a cached shell still boots the app and posts the same first message. Keep setupSidebarLoadOverlay wired up; you may relax timeoutMs / retryIntervalMs now that warm opens are fast, but don't remove it.

Sidebar failed to load overlay

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 to getSidebarBaseUrlWithOverride when you have no env-specific default.
  • SIDEBAR_URL_OVERRIDE_STORAGE_KEY — localStorage key for the override; use getSidebarBaseUrlWithOverride(defaultUrl) to resolve the base URL (reads override when set, otherwise defaultUrl; safe when localStorage is missing or throws).
  • sidebarPostMessageTargetOrigin(sidebarUrl, options?) — derives adapterOptions.targetOrigin from the sidebar URL (falls back to "*"; optional onInvalidUrl callback).
  • assertSidebarHostAdapter(host, message?) — type-narrows host after createSidebarHost when you passed plugin (throws if adapter is null).
  • AUTH_CONFIG_OVERRIDE_STORAGE_KEY — localStorage key for forcing an AuthConfig at runtime (dev/test only); used together with getAuthConfigWithOverride(hostConfig, allowOverride) which honors the override only when allowOverride is true. 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 iframe and omit iframeMount.
  • If you omit loadOverlayContainer, no overlay is mounted (same as using new SidebarAdapter only, plus optional iframe creation).
  • loadOverlayOptions: forwarded to setupSidebarLoadOverlay. Use messageListenWindow when the iframe parent is not the host window (e.g. sidebar iframe inside a popup; plugin / SidebarAdapter may use adapterOptions.win for the same window).
  • Split-context integration: omit plugin, set loadOverlayContainer + iframeMount (or iframe), then forward postMessage traffic between host.iframe and the context where your PluginInterface runs (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 returned authorizeUrl in 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 SPA loginWithPopup. 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(sidebarBuildInfo?: SidebarBuildInfo): Promise<SidebarConfig> — Integration display name (integrationName), stable machine id (integrationId), version, feature flags, etc. See the integrationName vs integrationId note above. Called once per sidebar load; sidebarBuildInfo is the sidebar build that is executing (see Sidebar build reporting) — declaring the parameter is optional. On the wire the reply is an InitConfigResponse (your SidebarConfig plus a hostAdapterVersion the adapter stamps itself, see Host adapter version, sidebar side).
  • getContent(): Promise<ContentInfo> — Full document (or main buffer) text; must include documentReference; mimeType optional
  • getSelectedContent(): Promise<ContentInfo> — Current selection; same ContentInfo rules as getContent
  • selectContent(range: ContentRange, documentReference?: string | null): Promise<void> — Focus/highlight the document span [range.start, range.end). documentReference names the target document for multi-document hosts; omitted/null means "whichever document is currently active" (the single-document default — see Multi-document hosts)
  • replaceContent(suggestion: string, range: ContentRange, documentReference?: string | null): Promise<void> — Apply a single edit to [range.start, range.end). Same documentReference rule as selectContent
  • replaceMultipleContents(replacements: ContentReplacement[]): Promise<void> — Batch edits, always against the currently active document (no per-item documentReference)

Optional (agentic / streaming flows):

  • onAgenticAgentResults?(agentName: string, issues: AgenticIssuePayload[], documentReference: string | null): Promise<void> — Per-agent results as they stream. documentReference is the document the check ran against — the run's originating document, not necessarily whichever one is active when the callback fires, since a check keeps streaming in the background after the active document changes (see Multi-document hosts). null when multiDocument is not enabled
  • onAgenticStreamComplete?(documentReference: string | null): Promise<void> — Stream finished (success or terminal state). Same documentReference rule as onAgenticAgentResults
  • onAgenticIssueStatusChanged?(issueId: string, status: "active" | "resolved" | "dismissed", source: "user" | "host"): Promise<void> — Fired after every successful per-issue status transition. source is "user" for sidebar-card-driven transitions and "host" for transitions driven by the inbound SidebarInterface RPCs (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.
  • onAgenticIssuesCleared?(documentReference: string | null): Promise<void> — Fired when the sidebar discards a document's check entirely, with nothing replacing it: the user cleared the document's data. Drop your highlights and any mirrored issue state for that document. documentReference names the cleared document (null when multiDocument is not enabled). Not fired for a re-check, where a fresh result stream replaces the previous set — rebuild from onAgenticAgentResults as usual there. Best-effort: an absent or throwing handler does not prevent or roll back the sidebar's own clear.

Optional (authentication):

  • openAuthUrl?(url: string): Promise<void> — Open the Auth0 authorizeUrl in whatever external-browser primitive the host provides. Implement this in sandboxed hosts where globalThis.open is blocked or unavailable (e.g. Office task panes should call Office.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 to globalThis.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 denies clipboard-write to any frame that is not on the vscode-webview:// origin (the VS Code extension calls vscode.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 to navigator.clipboard.writeText, then document.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;
  }
}

For replaceMultipleContents, throw ReplaceBatchError when the batch could not be applied in full and you know which ranges landed. It extends TextLookupError and carries applied (ContentRange[]) and failed (ReplaceBatchFailedRange[]), the ranges as they were sent; a range in neither list was left untouched. The sidebar resolves the applied occurrences, invalidates the failed ones, and leaves the rest active. The error's message describes the batch, so it is shown only on the card the user applied from. Give a failed range its own message to explain that one occurrence on its card; without one, the sidebar shows its own "text could not be found" message. A host that verifies every range before editing throws it with an empty applied; a plain TextLookupError is still accepted and treated as a failure of the whole batch.

import { ReplaceBatchError } from "@markupai/sidebar-adapter";

// In replaceMultipleContents, after verifying every range against the document:
if (unverifiable.length > 0) {
  throw new ReplaceBatchError(
    `None of the ${replacements.length} occurrences were replaced because one of them could not be verified in the document.`,
    {
      applied: [],
      failed: unverifiable.map((r) => ({
        ...r.range,
        message: "This text was edited since the last check.",
      })),
    },
  );
}

Non-JS ports serialize it as { name: "ReplaceBatchError", message, applied, failed } — see ReplaceBatchErrorPayload in asyncapi.json.

Note: We document thrown errors via JSDoc @throws on the plugin interface and export union types SelectContentError, ReplaceContentError and ReplaceMultipleContentsError 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 four host-driven RPCs, all idempotent and optional on the sidebar side:

await sidebarAdapter.activateAgenticIssue(issueId); // expand + scroll to the card
await sidebarAdapter.invalidateAgenticIssue(issueId); // → status: "dismissed"
await sidebarAdapter.markAgenticIssueResolved(issueId); // → status: "resolved"
await sidebarAdapter.restoreAgenticIssue(issueId); // → status: "active"
  • activateAgenticIssue(issueId, documentReference?) — Expands and scrolls to the card for issueId (e.g. the user clicked an underline in the document, or picked the issue from a context menu). In a multi-document host, pass the issue's owning documentReference as the second argument if it may not be the document the sidebar is currently displaying — the sidebar switches to it first, then activates. Omitted/null activates against whichever document the sidebar currently displays (the single-document default). See Multi-document hosts.
  • 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 via restoreAgenticIssue. 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);
}

Document references

ContentInfo.documentReference identifies a document across scans. The backend keys its document registry on it, and the Markup session id derives its document component from SHA-256(documentReference).

That only works if every Markup surface spells the same document the same way, so the format is <prefix>:<id> — a prefix naming your platform, then an id that is stable for the document. You supply both, because you know what platform you are and which identifier is stable there:

import { buildDocumentReference } from "@markupai/sidebar-adapter";

buildDocumentReference("word", fileGuid); // → "word:aabb"
buildDocumentReference("gdocs", `${docId}/tab:${tabId}`); // → "gdocs:1A2B3C/tab:t.0"
buildDocumentReference("url", "https://example.com/page"); // → "url:https://example.com/page"

Reuse an existing prefix when your platform already has one, so the same document seen from another Markup surface correlates: gdocs, word, heretto, madcapflare, ixiasoft, or url for anything addressed by a plain URL. If none fits, pick a new one — there is no fixed list, and adding a platform needs no adapter release.

What you are responsible for

  • Choose a prefix once and never change it. The prefix is part of the document's identity. Changing it re-keys every document you have ever checked and breaks continuity with your own history — coordinate a change with the data team rather than shipping one quietly.
  • Use an id that is stable for the document. Not the editor URL with its volatile query parameters, not a per-session handle — the thing that still identifies the document tomorrow.
  • Use the same reference for the whole document and for a selection. A selection is a view of one document, not a different document; varying the reference between the two splits one document into two everywhere downstream.

buildDocumentReference is idempotent for its own prefix, so a host that stores composed references can pass them straight back. It throws on an empty id or a prefix that is not a lowercase name, since both would yield a reference that silently fails to correlate. isDocumentReference(value) reports whether a value already carries the <prefix>:<id> shape.

A bare, unprefixed value is still accepted and behaves exactly as before; it simply cannot be correlated with the same document seen on another surface.

Multi-document hosts

Everything above assumes one document at a time — the sidebar's implicit "the document" is whatever your host's PluginInterface currently answers with. Tabbed editors (multiple open documents, one active at a time) need more: the sidebar has to retain each document's check results separately, keep background checks running when the user switches away, and let the host act on a document that isn't the one currently on screen. All of that is opt-in; a host that never sets multiDocument sees no behavior change from this section.

Enabling it

Set multiDocument: true in your getInitConfig response:

getInitConfig: async () => ({
  // ...other fields...
  multiDocument: true,
});

With it on, the sidebar retains check results and issue decisions per document — switching away from a document and back restores its issues, resolved/dismissed state, and tab selection exactly as the user left them. With it off (or omitted, the default), the sidebar holds one shared check state regardless of any notifyActiveDocumentChanged calls you make.

In-flight checks keep running in the background. Calling notifyActiveDocumentChanged — including with null, e.g. because the user closed the document's tab — does not stop a check already running against the outgoing document; it keeps streaming onAgenticAgentResults/onAgenticStreamComplete (tagged with its own documentReference) until it finishes, even after the document is no longer active or open in your host. There is currently no host-facing RPC to cancel it — cancellation is a sidebar-UI-only affordance (the Cancel button), not something SidebarAdapter exposes today. If your host needs to stop wasted work when a document closes mid-check, that isn't possible through this contract yet.

There is also no dedicated "check started" signal — nothing tells you a document is currently mid-check, or lets you distinguish "not yet checked" from "checking right now" from your own state alone. The closest thing is indirect: the sidebar calls your getContent/getSelectedContent implementation at the moment it starts a check, so seeing that call for a given documentReference implies a check just began for it — but that's an inference from a side effect, not a documented guarantee (nothing rules out getContent being called for other reasons in the future), and once a check is running there is nothing further until its first onAgenticAgentResults or its onAgenticStreamComplete arrives.

Reporting the initial document at init time

Recommended: report the document open at load via initialActiveDocumentReference in your getInitConfig response, then use notifyActiveDocumentChanged (below) only for changes after that. Without this, a fresh sidebar load can't tell "no document is open" apart from "not yet known" — it only learns the initially open document passively from the first getContent call, which doesn't happen until the user (or the sidebar) triggers one. If your host already knows the answer when it responds to getInitConfig, report it there instead of waiting:

getInitConfig: async () => ({
  // ...other fields...
  multiDocument: true,
  initialActiveDocumentReference: currentlyOpenDocumentReference, // or null if nothing is open/checkable
});

This is a one-time snapshot — getInitConfig is called once per session, so it does not replace notifyActiveDocumentChanged for subsequent switches; keep calling that as usual. Only meaningful alongside multiDocument: true; omit it (rather than guessing) if your host cannot answer the question synchronously at init — the sidebar falls back to passive detection exactly as before.

Telling the sidebar which document is active

Call notifyActiveDocumentChanged every time the user switches tabs in your host, and when the last tab closes:

await sidebarAdapter.notifyActiveDocumentChanged(newlyActiveDocumentReference);

// No checkable document is open (every tab closed, or the active tab is a
// content type the sidebar shouldn't try to check) — null is a deliberate
// signal, not "unknown":
await sidebarAdapter.notifyActiveDocumentChanged(null);

You do not need to call it for the very first document a user opens — the sidebar learns that one passively from the documentReference your first getContent/getSelectedContent response carries. Every switch after that needs an explicit call, or the sidebar keeps attributing checks to the stale document. An empty string is ignored as malformed (active document unchanged); null is accepted and treated identically to "every tab closed."

documentReference on document-scoped actions

Once multiDocument is on, several calls in both directions carry an explicit documentReference alongside their usual arguments, precisely because "the currently active document" stops being a safe assumption:

| Direction | Method | Why it needs to name a document | | -------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Sidebar → host | selectContent, replaceContent | The issue the user is acting on may belong to a document you checked earlier and the user has since switched away from — see below. | | Sidebar → host | onAgenticAgentResults, onAgenticStreamComplete | A check keeps streaming results in the background after the user switches to a different document; results must stay attributed to the document that was actually checked, not whichever one happens to be on screen when they arrive. | | Host → sidebar | activateAgenticIssue | The host may want to activate an issue belonging to a document that isn't the one the sidebar is currently displaying (e.g. a background check just finished elsewhere). |

In every case, omitted or null means "the currently active document" — the same behavior as before multiDocument existed. A non-null value names a document that may differ from whatever is currently active.

Maintaining your document ↔ documentReference lookup

This is the part that's on you: documentReference is an opaque string as far as the sidebar's transport is concerned, but it has to round-trip correctly through your code so a call that names a document actually resolves back to the right one in your editor. Concretely:

  1. Return the same documentReference for the same document, every time, for the life of the session. Build it once per document (typically with buildDocumentReference, keyed off whatever id is stable in your platform — a file GUID, a tab id) and reuse it across every getContent/getSelectedContent call for that document. If the string drifts, the sidebar's per-document retention and your own lookup both silently split into two "documents."

  2. Keep a lookup from documentReference back to your internal document/tab handle. A simple Map<string, YourDocumentHandle>, populated at the same place you mint each document's reference, is enough:

    const documentsByReference = new Map<string, YourDocumentHandle>();
    
    function registerDocument(handle: YourDocumentHandle): string {
      const reference = buildDocumentReference("my-host", handle.stableId);
      documentsByReference.set(reference, handle);
      return reference;
    }
  3. Resolve incoming documentReference arguments through that lookup — don't assume "whatever's on screen." When selectContent/replaceContent names a document other than your currently active tab, look it up and act on that document (switch your editor's visible tab if that's the only way you can apply an edit, or edit it in the background if your editor supports that):

    selectContent: async (range, documentReference) => {
      const handle = documentReference
        ? (documentsByReference.get(documentReference) ?? activeDocumentHandle)
        : activeDocumentHandle;
      selectContentInEditor(handle, range);
    },

    Silently applying the range to whichever document happens to be visible — ignoring a non-null documentReference — is the bug this parameter exists to prevent: it edits the wrong document as soon as the user has checked more than one.

  4. Evict lookup entries when a document is actually closed, not merely switched away from. The sidebar caps its own per-document retention at 20 documents internally (least-recently-used eviction, skipping any document with a check still running), so it doesn't expect you to remember a document forever either — but it also doesn't tell you when to forget one, since that's a host-side lifecycle event (tab closed, file closed) the sidebar has no visibility into. A host tracking more than ~20 concurrently open documents should not assume the sidebar retains state for all of them.

Single-document hosts (the default)

If you don't set multiDocument, none of the above applies: every documentReference argument on selectContent, replaceContent, and activateAgenticIssue is always null, and onAgenticAgentResults/onAgenticStreamComplete receive null too. You don't need a lookup — "the document" is implicit, exactly as it was before this feature existed. Most integrations should stay here unless they genuinely support multiple concurrently open documents.

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",
  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 localStorage key markupai:integrationSettings:<integrationId>. Different integrationId values are isolated.
  • Schema validation is lenient: fields whose type the current sidebar does not understand are dropped (with a single console.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. fields is not an array) are treated as absent — the sidebar logs a console.warn and 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 applyIntegrationSettings both target the same field — whichever lands second wins for that field.
  • This means a reactive host that calls applyIntegrationSettings from inside onIntegrationSettingsChange (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.

Sidebar build reporting

The sidebar caches its app shell in a service worker, so the build running in your iframe can be behind the build the sidebar server currently publishes — a warm load serves the cached bundle while a newer one waits to activate. To make logs and bug reports attributable to an exact build, the sidebar sends a SidebarBuildInfo with every getInitConfig request:

interface SidebarBuildInfo {
  version: string; // package version of the executing bundle, e.g. "2.0.2"
  commitHash: string; // short commit hash of the executing bundle, e.g. "abc1234"
  swControlled?: boolean; // true when served through the service-worker cache
  pwaUpdateMode?: "auto" | "prompt"; // how the sidebar activates a newer build
  adapterVersion?: string; // @markupai/sidebar-adapter version inside the sidebar
}

Log it yourself. The published adapter strips all of its own console output, so it does not log the build for you. Declare the parameter on your handler, or read the report later from the adapter:

import { ADAPTER_VERSION, type PluginInterface } from "@markupai/sidebar-adapter";

const pluginInterface: PluginInterface = {
  getInitConfig: async (sidebarBuildInfo) => {
    myLogger.info("Sidebar build", {
      sidebar: sidebarBuildInfo ?? "unknown (sidebar predates build reporting)",
      hostAdapterVersion: ADAPTER_VERSION, // compare with sidebarBuildInfo?.adapterVersion
    });
    telemetry.setTag("sidebar_version", sidebarBuildInfo?.version ?? "unknown");
    return { integrationName: "My Editor", integrationVersion: "1.0.0" };
  },
  // …
};

// Any time after the sidebar has initialized — e.g. when the user files a bug report:
const build = sidebarAdapter.getSidebarBuildInfo(); // SidebarBuildInfo | null
  • The report is sanitized before you see it: unknown fields are dropped, strings are capped at 64 characters, and a report without a usable version and commitHash is treated as absent (undefined in the handler, null from the getter).
  • It is replaced on every sidebar reload. When auto update mode activates a newer build, expect two getInitConfig requests in quick succession: the cached build, then the new one.
  • swControlled: true alone does not mean "stale" — compare version/commitHash with the server's /sidebar-info.json if you need that verdict.
  • Fully backward compatible: handlers declared without the parameter keep compiling, older adapters ignore the argument, and older sidebars simply don't send it.

Host adapter version, sidebar side

The report also flows the other way. The host-side adapter stamps its own compiled version onto every getInitConfig reply, so the sidebar can tell which @markupai/sidebar-adapter your integration bundles and attribute analytics (adapter adoption, sidebar-vs-host version skew) to it. You do nothing: your getInitConfig handler keeps returning a plain SidebarConfig, and the adapter adds the stamp on the wire — always with its own version, overwriting any hostAdapterVersion an integration might return.

// Wire shape of the reply (you never construct this yourself)
interface InitConfigResponse extends SidebarConfig {
  hostAdapterVersion?: string; // @markupai/sidebar-adapter version inside the host, e.g. "2.2.0"
}

On the sidebar side, PluginAdapter sanitizes the stamp (non-empty string, capped at 64 characters), strips it from the config it resolves, and exposes it separately:

const config = await pluginAdapter.getInitConfig(buildInfo); // plain SidebarConfig
const hostAdapter = pluginAdapter.getHostAdapterVersion(); // string | null

null means no valid stamp arrived — the host adapter predates 2.2.0. Non-JS host ports set hostAdapterVersion on their own reply; the field is described in asyncapi.json as the getInitConfig response payload.

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 build

Testing

npm test
npm run test:watch
npm run test:coverage

Formatting

npm run format:fix
npm run format:check

IPC 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