DEV Community

Tony Nguyen
Tony Nguyen

Posted on • Originally published at ai-kit.net

How We Built a Plugin Marketplace That Sells Itself: Embed Sales Architecture in EmDash

Every integration is a storefront. When a third-party platform embeds an EmDash plugin, their entire user base gains a one-click path to discover us — turning each integration partner into an always-on sales channel with zero marginal cost. Here's how we built the architecture that makes this work, and the tradeoffs we made along the way.

The Architecture at a Glance

The embed sales channel rests on three systems: the Plugin API (what plugin authors build against), the Embed Registry (how we map plugins to third-party surfaces), and the Discovery Layer (the user-facing badge that drives conversion). Each is independently versioned, and we treat backward compatibility as a first-class concern.

Plugin API: The Contract

Every plugin starts as a manifest file that declares its identity and capabilities. We chose a declarative manifest over a purely programmatic API because it lets the Embed Registry reason about compatibility at install time rather than runtime.

// plugin-manifest.json
{
  "apiVersion": "v2",
  "id": "emDash-ai-form-automation",
  "name": "AI Form Automation",
  "version": "2.1.0",
  "targetPlatform": "gravity-forms",
  "minHostVersion": "2.5.0",
  "capabilities": [
    "embed:toolbar-button",
    "embed:admin-panel",
    "webhook:form-submit",
    "webhook:ai-response"
  ],
  "permissions": [
    "read:form-schemas",
    "write:form-entries",
    "read:user-context"
  ],
  "runtime": {
    "sandbox": "shadow-dom",
    "maxPayloadBytes": 524288
  }
}
Enter fullscreen mode Exit fullscreen mode

Why Declarative?

We started with a purely imperative API — registerPlugin({...}) — and quickly hit versioning nightmares. Plugin authors would call APIs that didn't exist yet in older hosts, or rely on side effects we'd removed. By moving to a manifest-first model, the Embed Registry can:

  • Reject incompatible plugins pre-install: If minHostVersion exceeds the host's current version, the user gets a clear message instead of a runtime crash.
  • Selectively enable capabilities: If a host only supports embed:toolbar-button but the plugin requests embed:admin-panel, the registry grants the subset that works.
  • Audit security boundaries: permissions are declared upfront and checked against the host's security policy.

Tradeoff: The manifest adds friction for simple plugins. A plugin that just needs a single toolbar button still has to declare capabilities, permissions, and runtime. We experimented with sensible defaults (if you omit capabilities, we assume embed:toolbar-button) but found that explicit declarations caught too many misconfigurations to drop them.

Embed Registry: The Mapping Layer

When a user installs a plugin targeting Gravity Forms, the Embed Registry generates an embed manifest — a JSON document that tells the host exactly how to surface the plugin.

{
  "embedManifest": {
    "plugin": "emDash-ai-form-automation",
    "target": "gravity-forms",
    "entryPoint": "https://em.dash/embed/gf-automation.js",
    "integrity": "sha384-abc123",
    "mountStrategy": "shadow-dom",
    "styles": {
      "injection": "scoped",
      "theme": "inherit"
    },
    "lifecycle": {
      "onMount": "load",
      "onUnmount": "destroy",
      "idleTimeoutMs": 300000
    },
    "discoveryBadge": {
      "position": "toolbar",
      "variant": "minimal",
      "label": "Automated by EmDash"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The integrity hash is non-negotiable — every embed script is subresource-integrity-checked. We learned this the hard way after an early prototype didn't hash its embeds and a compromised CDN could have injected malicious badges. Shadow DOM isolation (mountStrategy: shadow-dom) ensures plugin styles can't leak into or out of the host page, which is critical when you're embedding into production admin panels.

Version Negotiation

This is where it gets tricky. The host (say, Gravity Forms v3.0) might support a different set of embed features than the plugin expects. We handle this with a version negotiation handshake:

// Simplified negotiation flow
async function negotiateEmbed(plugin: Plugin, host: HostContext): Promise<EmbedManifest> {
  const hostCapabilities = await host.getCapabilities();
  // Intersect plugin capabilities with host capabilities
  const supported = plugin.capabilities.filter(c => hostCapabilities.includes(c));

  if (supported.length === 0) {
    // Graceful degradation — show a fallback link
    return createFallbackManifest(plugin, host);
  }

  // Pick the richest supported capability
  const bestCapability = prioritizeByRichness(supported);
  return generateManifest(plugin, bestCapability);
}
Enter fullscreen mode Exit fullscreen mode

If no capabilities are supported, we fall back to a simple link embed — the plugin still shows up, just as a text link instead of a rich interactive panel. This graceful degradation over hard failure policy has been our most important reliability decision.

Security Model

Third-party embeds are a security minefield. Here's our threat model and how we address each vector:

Threat Mitigation
XSS via plugin script Subresource integrity + Content Security Policy nonce
Style leakage Shadow DOM isolation (scoped styles)
Data exfiltration Permission system enforced at registry level, not just plugin side
Clickjacking frame-ancestors CSP directive + X-Frame-Options: SAMEORIGIN on all non-embed endpoints
Supply chain (compromised plugin update) Signed manifests + version pinning in host config

Tradeoff: The permission system is coarse. A plugin that requests read:form-schemas gets access to all form schemas, not just the ones it needs. We considered a more granular model (per-field scoping, regex-based selectors) but it added enormous complexity to both the registry and the plugin API. For v1, coarse permissions with manual review for sensitive plugins was the right call.

The Embed SDK

Partners integrate our embeds via a tiny SDK (~3KB gzipped). The SDK handles authentication, lifecycle, and analytics so plugin authors don't have to.

// Partner integration — one import
import('https://em.dash/sdk/embed.js').then(({ createEmbed }) => {
  createEmbed({
    target: 'my-platform',
    plugins: ['emDash-automation', 'emDash-content-gen'],
    auth: {
      flow: 'oauth-device',
      // No tokens stored in localStorage — we use session cookies via iframe
    },
    placement: 'admin-toolbar'
  });
});
Enter fullscreen mode Exit fullscreen mode

Key decision: no iframes for the primary embed. We prototyped with iframes (easy isolation, trivial to implement) but they break keyboard navigation, create focus-trapping issues, and make responsive layouts painful. Shadow DOM with scoped styles gives us the same isolation without the UX debt.

Lessons Learned

1. Version everything, and version it explicitly. We shipped apiVersion: v1 without a minHostVersion field. Within two releases, plugin authors were calling methods that didn't exist on older hosts, and users got cryptic errors. Adding explicit version constraints felt bureaucratic but eliminated an entire class of support tickets.

2. Graceful degradation is a feature, not a fallback. Early on, if a host didn't support a plugin's required capability, we'd refuse to install. This meant users on slightly older versions of a platform couldn't use any EmDash plugins at all. The fallback-to-link behavior — while less rich — means zero users get a hard not compatible message.

3. Shadow DOM is not free. Each shadow root adds memory overhead, and deeply nested shadow trees (plugin inside host panel inside admin dashboard) can impact paint performance. We cap nesting at one level and recommend hosts mount embeds at the document root rather than inside existing shadow trees.

4. Attribution is harder than it looks. The ref=embed parameter is straightforward, but cross-origin attribution (user sees badge on Platform A, clicks, then signs up via Platform B's embed later) requires either first-party cookies (privacy-hostile) or probabilistic matching (noisy). We settled on anonymous impression IDs stored in session storage, which means we lose attribution on browser restarts. It's a deliberate tradeoff for privacy.

What's Next

We're working on hot-reloadable plugins (replace a plugin's runtime without requiring re-installation) and a sandboxed WebAssembly runtime for plugins that need CPU-intensive operations without blocking the host thread. The manifest format will get a runtime.wasm field alongside the existing runtime.sandbox options.

If you're building a plugin marketplace, the architecture described above — declarative manifests, capability intersection, graceful degradation, and a permission-based security model — has held up well across 12 integration partners. Start with the top 10 platforms your users already use, build deep integrations with graceful fallbacks, and treat your Plugin API as the product it is.

Top comments (0)