Architecture primer · host, contract, boundary

The Plugin Seam

Everything about building software that other people can extend without your permission — from the four lines of Python that make a registry, through how WordPress and Figma actually do it, to a full design for a point-of-sale platform where third-party code touches money.

host = teal plugin = orange Python & React ~45 min read
00

What to call this, and why the name matters

You asked whether the thing you have in mind is “plugins” or “composables”. They are two different axes, and conflating them is the first architectural mistake most people make.

Composability is about your own code: small pieces with clean interfaces that you assemble in different orders. A Unix pipeline is composable. React hooks are composable. itertools is composable. Composability is a property of a design, and the assembler is you, at build time, with a compiler checking your work.

Extensibility is about other people’s code: strangers add behaviour to a running system without editing it, without recompiling it, and often without you ever seeing what they wrote. That is what a plugin is. The assembler is the end user, at install time, and nothing is checking anyone’s work.

You want the second one. The first is a prerequisite: a system that is not composable internally cannot be made extensible externally, because there are no seams to hang anything on. But they are not the same problem, and extensibility brings a whole second set of concerns — trust, versioning, isolation, distribution, money — that composability never has to think about.

The one-sentence definition

The core idea

A plugin architecture is dependency inversion applied at a runtime boundary you deliberately publish. The host stops depending on concrete features and starts depending on an interface; features depend on that same interface from the other side; and something at runtime — not the compiler — connects the two.

That inversion is the whole trick. Everything else in this document is consequence: if code you did not write is going to be plugged into a socket at runtime, you now need a way to describe the socket (a contract), find what is plugged in (a registry), load it (a loader), call it at the right moments (extension points), and limit what it can do when you do (a boundary).

WITHOUT A SEAM Checkout Loyalty Tax Receipt imports Adding a feature means editing Checkout. Only you can add features. WITH A SEAM Checkout PricingRule the contract calls Loyalty Tax Receipt implements Adding a feature means shipping a new box. Anyone can add features. Checkout never changes.
The only structural difference is the direction of the arrows on the right-hand side. On the left, the host reaches out to features it knows by name. On the right, features reach in to a contract the host published — so the set of features becomes a runtime fact rather than a source-code fact.

The neighbouring words, disambiguated

These get used interchangeably in blog posts and they should not be. The distinctions are about who calls whom and when the binding happens.

TermDirection of callBound whenWritten byBlast radius of a bug
LibraryYou call itCompile / importAnyoneYours — you chose to call it
FrameworkIt calls youCompile / importAnyoneYours
Composable unitEither, by designCompileYouYours
PluginHost calls it, it calls host backInstall / activate, at runtimeA strangerThe host’s unless you isolate it
Service / appOver the network, both waysRuntime, per requestA strangerContained by the network boundary
MiddlewareWraps a call, chainedStartup, orderedEitherWhole request path
The name for what you’re describing

In the literature this pattern is the microkernel architecture (also “plug-in architecture”): a minimal core that does nothing interesting on its own, plus a set of plug-in modules that carry all the domain behaviour. Erlang/OTP, Eclipse, VS Code, WordPress, Figma, Shopify, Home Assistant and pytest are all microkernels. A POS is an unusually good fit, because the “core” — take items, take money, print a receipt — is genuinely small, and everything that varies between two shops is a plugin.

01

Anatomy: the five parts every plugin system has

Whether it is 40 lines of Python or Figma’s entire runtime, every plugin system is built from the same five parts. If you can name all five in your design, you have a design. If you cannot, you have a global list of callbacks.

plugin bundle manifest.json code signature 1 · CONTRACT loader verify · import 3 · LOADER registry name → impl 2 · REGISTRY reads fills host core emit("sale.finalize") 4 · EXTENSION POINTS looks up 5 · CAPABILITY BOUNDARY ctx.orders ctx.http(allowlist) ctx.log database network hardware hands plugin a scoped ctx real resources plugin never touches directly
The parts and how they connect. Note where the boundary sits: not around the plugin file, but around every capability the plugin is handed. A plugin that is given ctx and nothing else can only do what ctx exposes, no matter what it imports.
1. The contract
The shape of a valid plugin. A Python Protocol, a TypeScript interface, a JSON Schema for the manifest, or all three. It defines what a plugin declares about itself and what functions it must expose.
2. The registry
The in-memory map of what is currently loaded, keyed by extension point. Usually dict[str, list[Handler]]. Its persistent cousin — which plugins this tenant has installed, at which versions, with which settings — lives in your database and is a different thing; don’t merge them.
3. The loader
Turns a bundle on disk (or a URL, or a WASM blob) into a live object in the registry. This is where verification, version compatibility checks and error containment happen. A plugin that throws on load must not take down the host.
4. Extension points
The named moments in the host’s own flow where it stops and asks the registry “anyone want this?”. Naming and freezing these is 80% of the design work.
5. The capability boundary
What the plugin can reach. Enforced either by construction (a sandbox with no ambient access) or by convention (nothing stops it, please be nice). WordPress chose convention; Figma chose construction. That single choice determines almost everything else about your platform.

The sixth thing: lifecycle

Not a part so much as a state machine, but you will build it whether you plan to or not, so plan to. Every plugin moves through: discovered → installed → activated → running → deactivated → uninstalled, with upgraded and disabled-by-host as edges you will regret not having.

The two that people forget: uninstall must be able to clean up (WordPress has a dedicated uninstall.php for exactly this, run when the plugin’s code is otherwise gone), and the host must be able to disable a plugin unilaterally, without the plugin’s cooperation, ideally remotely. That second one is your kill switch, and the day you need it you will need it in minutes, not in a release cycle.

02

The seven extension mechanisms

There are only about seven ways to let outside code participate in your flow. Every platform is a particular mix of them. Picking the wrong one for a given extension point is the source of most plugin-API pain, because the mechanism decides whether ordering matters, whether a plugin can veto you, and what happens when one of them is slow.

EVENT · FAN-OUT · NO RETURN host order.paid send receipt email award loyalty pts sync to accounting Order is arbitrary. Any can fail alone. Host does not wait, does not care. FILTER · PIPELINE · VALUE RETURNED host bulk disc. prio 10 coupon prio 20 VAT prio 90 4000 3600 3400 final value 3944 returns to host Order is load-bearing. One failure poisons the chain unless you skip-and-continue.
Events and filters look similar in code and behave completely differently in production. The moment a plugin’s return value is threaded into the next plugin’s input, ordering becomes part of your public API and one bad plugin can corrupt a number the customer pays.
Mechanism 1
Event / hook

Host announces something happened. Listeners run; return values discarded. Cannot change host behaviour, cannot veto.

Use for: notifications, sync, analytics, anything you would be willing to do asynchronously.

Mechanism 2
Filter / pipeline

Host passes a value in and takes a value out; plugins run in priority order, each seeing the previous result.

Use for: transformations — prices, rendered HTML, query modifiers. Requires a strict schema and a deterministic order.

Mechanism 3
Provider registry

Exactly one implementation wins per key. tax.provider = "avalara". Selection is configuration, not ordering.

Use for: payment gateways, tax engines, storage backends, printer drivers — anywhere “two of them ran” is nonsense.

Mechanism 4
Slot / mount point

The UI equivalent of a hook: a named region the host renders, into which plugins contribute components.

Use for: settings pages, extra buttons, panels, receipt sections, dashboard widgets.

Mechanism 5
Declarative contribution

The manifest, not the code, declares what exists — commands, menus, settings, keybindings. Code is loaded lazily only when one is triggered.

Use for: keeping startup fast with hundreds of plugins installed. VS Code’s core insight.

Mechanism 6
Middleware / interceptor

Wraps a call: sees the input, decides whether to call the next thing, sees the output, can short-circuit entirely.

Use for: auth, logging, caching, rate limits. Powerful and therefore dangerous — a middleware can silently swallow your whole request.

Mechanism 7
Out-of-process RPC

Plugin runs somewhere else — another process, another sandbox, another machine — and speaks a wire protocol. LSP, Figma’s QuickJS realm, Shopify’s webhook apps.

Use for: untrusted code, other languages, crash isolation. Costs latency and forces everything through serialisation.

Anti-mechanism
“Just monkey-patch it”

No declared extension point at all; plugins reach in and replace host internals. Zero design cost today, infinite cost forever after: every internal becomes a contract you cannot change.

This is how a lot of ecosystems actually start. It is also why they can never ship a v2.

Choosing between them

MechanismChanges data?Can veto?Order matters?Sane failure modeSafe on a hot path?
EventNoNoNoLog it, carry onyes, if async
FilterYesNoYesSkip that plugin’s contributiononly with a budget
ProviderYesYesN/A — one winsFall back to built-in, or refusedepends on the provider
SlotNo (visual)NoSometimesError boundary, render nothingyes
DeclarativeNoNoNoReject at install timeyes
MiddlewareYesYesYesHard — it owns the callrarely
RPCYesYesConfigurableTimeout → default answerno, latency
The rule that saves you later

Default every new extension point to event. Promote it to a filter only when you have a concrete case that cannot be served by an event, and to middleware essentially never. You can always add power to an extension point in a later version. You can never take it away, because ten thousand plugins are already using it.

03

Python: a plugin host in five stages

Built up in the order you should actually build it. Each stage is useful on its own and none of them requires the next. The domain is a shopping cart, so the POS design in §11 has somewhere to land.

Stage 1 — The contract

Start with the data, not the callbacks. A plugin API is mostly a set of types that cross the boundary, and the single most valuable decision you will make is that those types are frozen, plain, serialisable DTOs and not your live ORM objects. Hand a plugin a SQLAlchemy model and you have just published your database schema as a public API.

pythonpos/contract.py
from dataclasses import dataclass
from typing import Protocol, runtime_checkable

@dataclass(frozen=True, slots=True)
class Line:
    sku: str
    qty: int
    unit_cents: int          # integers. never floats for money.

@dataclass(frozen=True, slots=True)
class Cart:
    lines: tuple[Line, ...]
    subtotal_cents: int
    currency: str = "KES"

@dataclass(frozen=True, slots=True)
class Adjustment:
    """What a pricing plugin is allowed to produce: one labelled,
    signed, attributable line. Not a new total."""
    plugin_id: str
    label: str               # shown to the cashier and printed
    amount_cents: int        # negative = discount
    reason_code: str

@runtime_checkable
class PricingRule(Protocol):
    id: str
    def evaluate(self, cart: Cart, ctx: "Ctx") -> list[Adjustment]: ...
The plugin returns contributions, not a total. The host still owns the arithmetic. This one decision — described in more detail in §11 — is what lets you audit, explain and reverse every discount on a receipt.

Stage 2 — The registry and the bus

Forty lines. Note the three production concerns baked in from the start: attribution (every handler knows which plugin it came from), containment (one plugin’s exception does not end the loop), and a time budget (a slow plugin gets named in your logs before it gets named in a support ticket).

pythonpos/kernel.py
import logging, time
from collections import defaultdict

log = logging.getLogger("pos.kernel")

class Kernel:
    def __init__(self):
        self._hooks: dict[str, list] = defaultdict(list)
        self._providers: dict[str, tuple[str, object]] = {}

    # --- registration -------------------------------------------------
    def on(self, point, fn, *, priority=50, plugin_id="core"):
        self._hooks[point].append((priority, plugin_id, fn))
        # sort by priority, then plugin_id: deterministic across restarts
        self._hooks[point].sort(key=lambda h: (h[0], h[1]))

    def provide(self, slot, impl, *, plugin_id):
        if slot in self._providers:
            prev = self._providers[slot][0]
            raise RuntimeError(f"{slot} already provided by {prev}")
        self._providers[slot] = (plugin_id, impl)

    # --- invocation ---------------------------------------------------
    def emit(self, point, payload, ctx):
        """Fire-and-forget. Return values ignored. Failures isolated."""
        for _, pid, fn in self._hooks[point]:
            try:
                fn(payload, ctx.for_plugin(pid))
            except Exception:
                log.exception("plugin=%s point=%s failed", pid, point)

    def collect(self, point, payload, ctx, *, budget_ms=15):
        """Gather contributions from every plugin. Host merges them."""
        out = []
        for _, pid, fn in self._hooks[point]:
            t0 = time.perf_counter()
            try:
                out.extend(fn(payload, ctx.for_plugin(pid)) or [])
            except Exception:
                log.exception("plugin=%s point=%s skipped", pid, point)
                continue                      # fail open: keep selling
            ms = (time.perf_counter() - t0) * 1000
            if ms > budget_ms:
                log.warning("plugin=%s point=%s slow %.1fms", pid, point, ms)
        return out
collect() rather than a WordPress-style filter() that threads one value through the chain. Collecting is order-insensitive, so a plugin cannot break another plugin by running first, and the host can validate every contribution independently before applying any of them.

Stage 3 — The capability boundary

The critical move is that a plugin never imports anything of yours. It receives a ctx, and ctx is scoped to that plugin’s declared grants. This is capability-based security: no ambient authority, no globals, nothing reachable that was not handed over.

pythonpos/capabilities.py
import logging
from dataclasses import dataclass, replace

log = logging.getLogger("pos.plugin")

class Denied(Exception): pass

@dataclass(frozen=True)
class Ctx:
    plugin_id: str
    grants: frozenset[str]
    _services: dict          # capability name -> live object
    _grants_by_plugin: dict  # plugin_id -> frozenset[str]

    def for_plugin(self, pid):
        return replace(self, plugin_id=pid,
                       grants=self._grants_by_plugin.get(pid, frozenset()))

    def use(self, capability):
        if capability not in self.grants:
            raise Denied(f"{self.plugin_id} did not declare {capability!r}")
        return self._services[capability]

    # cheap, always-granted things
    def log(self, msg, **kw):
        log.info("[%s] %s %r", self.plugin_id, msg, kw)

# in a plugin:
#   products = ctx.use("catalog:read")          # ok, declared
#   ctx.use("payments:capture").capture(...)    # Denied at runtime
In-process Python cannot truly enforce this — a determined plugin can still import your database module. That is the WordPress trade-off, and it is fine for a trusted-first-party or reviewed-plugin ecosystem. §08 covers what to do when the code is genuinely untrusted.

Stage 4 — Discovery

Do not invent a plugin directory and scan it with importlib.util.spec_from_file_location. Python already has a standard, tooling-supported answer: entry points. A plugin is just a package that declares itself in a named group; pip install is the installer; the environment is the registry.

tomlpos-happy-hour/pyproject.toml  ·  the plugin declares itself
[project]
name = "pos-happy-hour"
version = "1.2.0"
dependencies = ["pos-sdk>=2.3,<3.0"]     # host API compat range

[project.entry-points."pos.plugins.v2"]
happy_hour = "pos_happy_hour:register"
pythonpos/loader.py  ·  the host finds and activates them
from importlib.metadata import entry_points, version

HOST_API = "pos.plugins.v2"     # the group name IS the major version

def load_all(kernel, enabled: set[str]):
    loaded = {}
    for ep in entry_points(group=HOST_API):
        if ep.name not in enabled:          # tenant opted in?
            continue
        try:
            register = ep.load()            # imports the module
            api = PluginApi(kernel, plugin_id=ep.name)
            register(api)                   # plugin wires itself up
            loaded[ep.name] = version(ep.dist.name)
        except Exception:
            log.exception("plugin=%s failed to load; skipping", ep.name)
    return loaded
Bumping the group name from pos.plugins.v2 to ...v3 is a clean, total break: v2 plugins simply stop being discovered instead of being loaded and then crashing. You can run both groups at once during a migration window.

Stage 5 — A plugin, in full

pythonpos_happy_hour/__init__.py
from pos_sdk import Adjustment

MANIFEST = {
    "id": "happy_hour",
    "api": "2.x",
    "grants": ["catalog:read", "clock:read"],
    "settings": {
        "start_hour": {"type": "int", "default": 16},
        "percent":    {"type": "int", "default": 10},
    },
}

def register(api):
    api.collect("pricing.evaluate", evaluate, priority=20)
    api.on("shift.closed", report, priority=50)

def evaluate(cart, ctx):
    cfg  = ctx.settings()                      # tenant's saved values
    hour = ctx.use("clock:read").now().hour
    if not (cfg["start_hour"] <= hour < cfg["start_hour"] + 2):
        return []
    drinks = {l.sku for l in cart.lines
              if ctx.use("catalog:read").category(l.sku) == "beverage"}
    if not drinks:
        return []
    base = sum(l.qty * l.unit_cents for l in cart.lines if l.sku in drinks)
    return [Adjustment(
        plugin_id="happy_hour",
        label=f"Happy hour −{cfg['percent']}%",
        amount_cents=-(base * cfg["percent"]) // 100,
        reason_code="HAPPY_HOUR",
    )]

def report(shift, ctx):
    ctx.log("happy hour ran", shift=shift.id)
Everything the plugin touches came in through an argument. It imports one thing — the SDK’s data types — and reaches for the clock through ctx, which is also what makes it testable and what makes the same code runnable inside a sandbox later.
Don’t write this yourself if you don’t have to

pluggy is the plugin system behind pytest, tox and devpi — battle-tested, ~800 lines, and it gives you hook specifications (the host declares the signature with @hookspec), implementations (@hookimpl), ordering (tryfirst/trylast/wrapper), and validation that an implementation actually matches the spec. If your plugins are trusted Python running in-process, start there. Build your own kernel when you need budgets, capabilities, per-tenant enablement and sandboxing — which is to say, when you are building a platform rather than a tool.

Beyond in-process: the same host, over a pipe

When plugins stop being trusted, the kernel above barely changes — only the thing behind the handler does. This is the shape of the Language Server Protocol, of Figma’s two realms, and of every “plugin runs in a sandbox” system:

pythonpos/remote.py  ·  a plugin behind a process boundary
import json, subprocess
from dataclasses import asdict

class RemotePlugin:
    """Same interface as an in-process plugin. Different address space."""
    def __init__(self, plugin_id, argv, timeout=0.05):   # 50ms budget
        self.id, self.timeout = plugin_id, timeout
        self.proc = subprocess.Popen(
            argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            text=True, bufsize=1,
            env={},                       # no inherited secrets
        )

    def evaluate(self, cart, ctx):
        req = {"point": "pricing.evaluate", "cart": asdict(cart)}
        self.proc.stdin.write(json.dumps(req) + "\n")
        line = _read_with_timeout(self.proc.stdout, self.timeout)
        if line is None:
            ctx.log("timed out, contribution dropped")
            return []                     # fail open, on time, every time
        return [Adjustment(**a) for a in json.loads(line)["adjustments"]]
Three things become possible the moment the boundary is a pipe: the plugin can be written in any language, it cannot read your memory, and a hung plugin costs you a timeout rather than a hung till. Three things become harder: latency, shared state, and debugging.
04

React: extending a running UI

On the backend a plugin contributes behaviour. On the frontend it contributes rendered output inside a layout it does not own, which introduces three problems the backend never has: someone else’s component can crash your tree, block your main thread, or read your DOM.

The slot registry

Keep the registry outside React. Plugins register at module-evaluation time, before any render happens; React just reads. A Map in a module is the whole thing.

javascriptsdk/slots.js
const registry = new Map();   // slotName -> Contribution[]

export function contribute(slot, { id, component, priority = 50, when }) {
  const list = registry.get(slot) ?? [];
  list.push({ id, component, priority, when });
  list.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
  registry.set(slot, list);
}

export function contributionsFor(slot) {
  return registry.get(slot) ?? [];
}

// The host declares its slots as data, so they can be documented,
// diffed, and frozen as a public API surface.
export const SLOTS = {
  "sale.actions":     { props: ["cart", "actions"] },
  "sale.line.badge":  { props: ["line"] },
  "receipt.footer":   { props: ["sale"] },
  "settings.page":    { props: ["pluginId"] },
};

The slot component

Three non-negotiables: an error boundary per contribution (not per slot — one bad plugin must not blank out the other three), a stable key, and props that are DTOs. Never pass a setState function, a store instance, or anything from which the plugin could reach the rest of your app.

javascripthost/PluginSlot.jsx
import React from "react";
import { contributionsFor } from "../sdk/slots";

class Boundary extends React.Component {
  state = { dead: false };
  static getDerivedStateFromError() { return { dead: true }; }
  componentDidCatch(err, info) {
    telemetry.pluginCrash(this.props.pluginId, err, info);  // attribute it
  }
  render() { return this.state.dead ? null : this.props.children; }
}

export function PluginSlot({ name, ...props }) {
  const items = contributionsFor(name);
  if (!items.length) return null;
  return (
    <>
      {items.map(({ id, component: C, when }) =>
        when && !when(props) ? null : (
          <Boundary key={id} pluginId={id}>
            <C {...props} />
          </Boundary>
        )
      )}
    </>
  );
}

// in the host's own UI:
// <PluginSlot name="sale.actions" cart={cartDto} actions={safeActions} />
Because contributions are sorted by (priority, id) rather than registration order, two stores with the same plugins installed get the same button order — and load order stops being an invisible part of your API.

A plugin, and what it is allowed to do

javascriptplugins/loyalty/ui.jsx
import { contribute } from "pos-sdk/slots";
import { useHostQuery } from "pos-sdk/data";

function LoyaltyBadge({ line }) {
  // useHostQuery goes through the SDK, which enforces the plugin's
  // declared scopes and rate limits. There is no bare fetch() here.
  const { data } = useHostQuery("loyalty.points", { sku: line.sku });
  if (!data?.points) return null;
  return <span className="pos-badge">+{data.points} pts</span>;
}

contribute("sale.line.badge", {
  id: "loyalty.badge",
  component: LoyaltyBadge,
  priority: 30,
  when: ({ line }) => line.qty > 0,
});

Loading plugin code that wasn’t in your bundle

This is where frontend plugin systems get genuinely hard. Your bundler resolved imports at build time; plugin code arrives at run time. The three real options:

ApproachHowShares React?IsolationGood for
Native dynamic importawait import(/* @vite-ignore */ url) of an ESM bundle; host libs exposed via an import map or a global SDKYes, one copynoneFirst-party or reviewed plugins
Module federationWebpack/Rspack shared scope negotiates a single React across remotesYes, negotiatednoneMicro-frontends inside one org
Sandboxed iframePlugin UI in <iframe sandbox> on a different origin, talking over postMessageNo — its ownstrongUntrusted third parties. What Figma does.
Custom elementPlugin ships a Web Component; host renders <pos-loyalty-badge>NopartialFramework independence, not security
The two-Reacts trap

If a plugin bundle contains its own copy of React, hooks break with errors that look like nothing else in your life (“Invalid hook call”, context returning undefined, state resetting). React’s internals are module-level singletons. Either guarantee exactly one copy — mark React external in every plugin build and hand it over through an import map or a global — or guarantee zero sharing by putting the plugin in an iframe or a custom element. The middle ground does not exist, and “it worked in dev” is how you find out.

The iframe bridge, in miniature

When the plugin is untrusted, this is the pattern. The child gets a fixed message vocabulary and no reference to anything in the parent; every call is validated on arrival as if it came from the internet, because effectively it did.

javascripthost/PluginFrame.jsx  ·  parent side
const ALLOWED = new Set(["ui.resize", "data.query", "cart.addLine"]);

export function PluginFrame({ pluginId, src, grants, height }) {
  const ref = React.useRef(null);

  React.useEffect(() => {
    function onMessage(e) {
      // 1. Is it even from our frame?
      if (e.source !== ref.current?.contentWindow) return;
      // 2. Is it from the origin we sandboxed it to?
      if (e.origin !== new URL(src).origin) return;
      const { type, id, payload } = e.data ?? {};
      // 3. Is it a verb this plugin declared and we granted?
      if (!ALLOWED.has(type) || !grants.includes(type)) {
        return reply(id, { error: "denied" });
      }
      handle(pluginId, type, payload).then(r => reply(id, r));
    }
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, [pluginId, src, grants]);

  return (
    <iframe
      ref={ref}
      src={src}                       // separate origin: plugins.pos.app
      title={pluginId}
      height={height}
      sandbox="allow-scripts"         // NOT allow-same-origin
      referrerPolicy="no-referrer"
      csp="default-src 'self'; connect-src https://api.pos.app"
    />
  );
}
The subtle line is sandbox="allow-scripts" without allow-same-origin. Together they cancel the sandbox out, because the frame can then reach into the parent document and remove its own sandbox attribute. Serving plugin UI from a separate origin also means a plugin that finds an XSS in its own page has not found one in yours.
05

WordPress: maximum extensibility, zero isolation

WordPress runs a very large share of the web on a plugin architecture designed in 2004 that has essentially never changed. It is worth studying precisely because it took one position — total openness — to its absolute limit, and the consequences of that position are now all visible at once.

The whole mechanism is one global array

Every hook in WordPress lives in a single global, $wp_filter, keyed by hook name then by priority. add_action() is literally a call to add_filter(); do_action() is apply_filters() with the return value thrown away. There is one mechanism, and actions are the degenerate case of it.

phpthe shape of it, simplified from wp-includes/plugin.php
$wp_filter = [
  'the_content' => [
     10 => [ 'wpautop' => [...], 'do_shortcode' => [...] ],
     20 => [ 'my_plugin_add_cta' => [...] ],
  ],
];

function apply_filters( $tag, $value, ...$args ) {
    foreach ( $wp_filter[$tag] as $priority => $callbacks ) {
        foreach ( $callbacks as $cb ) {
            $value = call_user_func_array( $cb['function'],
                       array_slice( [$value, ...$args], 0, $cb['accepted_args'] ) );
        }
    }
    return $value;        // do_action() is this, ignoring $value
}
No type checking, no schema, no budget, no isolation, no error containment — a fatal error in any callback ends the request. And no registry of what hooks exist: a hook comes into being the moment someone calls do_action() with a new string.
phpa complete, valid WordPress plugin
<?php
/**
 * Plugin Name: Coffee Upsell
 * Version:     1.4.0
 * Requires PHP: 7.4
 */

// 1. FILTER — transform a value the host is about to use
add_filter( 'the_content', function ( $content ) {
    return $content . '<p class="upsell">Try our new roast.</p>';
}, 20 );                       // priority 20: after wpautop at 10

// 2. ACTION — react to a moment, return value ignored
add_action( 'woocommerce_order_status_completed', function ( $order_id ) {
    my_upsell_send_followup( $order_id );
}, 10, 1 );

// 3. LIFECYCLE — one-time setup, and its matching teardown
register_activation_hook( __FILE__,   'my_upsell_create_tables' );
register_deactivation_hook( __FILE__, 'my_upsell_clear_cron' );
// uninstall.php in the plugin root runs when the plugin is deleted
The manifest is a PHP comment. That is the single biggest reason the ecosystem got so large: the barrier to publishing a plugin in 2005 was a text file with a comment at the top, and every subsequent design decision was downstream of not wanting to raise it.

What WordPress gets right

  • Hook everything, by default. Core WordPress calls apply_filters() on almost every value it computes before using it. The culture is that if you did not make it filterable, that is a bug. This is why the ecosystem can do things core never imagined.
  • Priority as a first-class concept. An integer, defaulting to 10, low runs first, with room to insert on either side. Crude, obvious, and it works.
  • Fanatical backward compatibility. Plugins written for WordPress 2.x still load. Deprecated functions are kept as shims for a decade. This is expensive and it is the reason the ecosystem exists.
  • Lifecycle hooks including uninstall, run in a context where the plugin’s own code has already been removed — a detail most systems forget.

What it gets wrong, and what that costs

  • No capability boundary whatsoever. A plugin is PHP running in your process with your database credentials, your filesystem and your network. It can read every password hash, modify any other plugin’s behaviour, and rewrite core functions. “Least privilege” is not expressible.
  • Therefore: the ecosystem is the attack surface. The overwhelming majority of WordPress compromises come through plugins — usually an abandoned one, often through a supply-chain takeover of a popular free plugin. There is no sandbox to fall back on, so the mitigation is entirely social: reviews, scanners, and hoping people update.
  • Hook order is an undocumented public API. Because filters thread a value through a chain, plugin A’s output is plugin B’s input, and priority collisions produce bugs no one owns.
  • No versioned API surface. There is no apiVersion. Compatibility is discovered by installing it and seeing whether the site goes white.
The second WordPress

Gutenberg — the block editor — is quietly a completely different plugin architecture bolted alongside the first: block.json is a real declarative manifest, registerBlockType() is a typed provider registry rather than a hook, blocks have explicit attributes schemas, and register_rest_route() gives a versioned HTTP surface. Modern WordPress is a 2004 imperative hook system and a 2018 declarative registry running in the same process, and the friction between them is most of what WordPress developers argue about.

Distribution and money

The wordpress.org plugin directory is free-only: no payment rails, no revenue share, no billing API. The result is an entire economy that lives outside the marketplace — a free plugin in the directory acts as distribution, and monetisation happens through a license key that phones home to the vendor’s own server for updates and pro features. This works, but it means the platform has no leverage over quality, no ability to enforce refunds, and no visibility into what the paid version of a plugin actually does after it self-updates from a third-party URL.

06

Figma: isolation by construction

Figma had the opposite constraint. WordPress plugins run on a server the site owner controls; Figma plugins run in your browser tab, inside a design tool holding other people’s confidential work, and they are installed with one click from a public marketplace. Convention was never going to be enough.

FIGMA APP · MAIN THREAD scene graph your document every other file auth session QUICKJS → WASM SANDBOX plugin main code a JS engine inside JS no DOM · no fetch no window · no timers* figma.* node handles SANDBOXED IFRAME plugin UI (HTML) real DOM · real fetch no scene graph at all figma.ui.postMessage pluginMessage internet allowlisted domains only the path that does not exist * timers and async APIs are provided by the host into the sandbox rather than inherited from the browser
Two realms, neither of which is a normal browser context. The half with document access has no network and no DOM; the half with network and DOM has no document access. Anything a plugin wants to do with both has to cross a postMessage gap the host owns.

Why QuickJS, and the lesson in the history

Figma’s first attempt used Realms — a proposal for creating a fresh JavaScript global object inside the same engine, with dangerous globals removed. It was found to be insecure quickly: sharing one heap and one engine with untrusted code means a single missed prototype path or an engine bug is a full escape. Soft isolation inside the same JS heap is a code-organisation tool, not a security boundary.

The replacement is more radical: compile QuickJS, a small complete JavaScript interpreter written in C, to WebAssembly, and run the plugin’s JavaScript inside that interpreter. The plugin’s code and Figma’s code now execute in genuinely different machines. The plugin cannot reach anything that was not explicitly marshalled across the boundary, because there is nothing to reach — its entire universe is the WASM linear memory.

The bill for this

Two costs, and they are real. Debuggability: errors from inside QuickJS are far less informative than native JS errors, so plugin authors do more guessing. Performance: an interpreter inside a VM inside a browser is slower than the native engine, and every scene-graph read crosses a marshalling boundary — which is why Figma has been steadily moving its API to async, dynamically loaded forms (getNodeByIdAsync, loadFontAsync) so the host can batch and defer instead of paying per-property.

jsonmanifest.json  ·  capability declaration, checked by the platform
{
  "name": "Colour Auditor",
  "id": "1234567890",
  "api": "1.0.0",
  "main": "dist/code.js",
  "ui":   "dist/ui.html",
  "editorType": ["figma", "figjam"],
  "permissions": ["currentuser"],
  "networkAccess": {
    "allowedDomains": ["https://api.mycompany.com"],
    "reasoning": "Fetches the shared brand palette."
  },
  "documentAccess": "dynamic-page"
}
The network allowlist is the part most worth copying. A plugin declares every domain it will ever contact, with a human-readable reason shown at install; anything else is blocked by the platform, not by policy. That converts “this plugin might exfiltrate my designs” from a trust question into an enforced one.
javascriptcode.ts (sandbox) and ui.html (iframe)
// ---- code.ts : runs in QuickJS. Sees the document, not the network.
figma.showUI(__html__, { width: 300, height: 400 });

figma.ui.onmessage = async (msg) => {
  if (msg.type === "recolour") {
    for (const node of figma.currentPage.selection) {
      if ("fills" in node) node.fills = [{ type: "SOLID", color: msg.rgb }];
    }
    figma.notify(`Recoloured ${figma.currentPage.selection.length} layers`);
  }
};

// hand data OUT to the UI so the UI can do the networking
figma.ui.postMessage({ type: "selection", count: figma.currentPage.selection.length });

// ---- ui.html : runs in the iframe. Sees the network, not the document.
window.onmessage = async (e) => {
  const msg = e.data.pluginMessage;                 // always wrapped
  if (msg.type === "selection") render(msg.count);
};
const palette = await fetch("https://api.mycompany.com/palette").then(r => r.json());
parent.postMessage({ pluginMessage: { type: "recolour", rgb: palette.brand } }, "*");

What to steal from Figma

  • Split the plugin by capability, not by convenience. Logic realm and UI realm exist because they need different powers. If you find yourself wanting to give one component both document access and arbitrary network access, that is the design telling you something.
  • Make the boundary structural. A boundary enforced by “we removed the dangerous globals” will be defeated. A boundary enforced by “there is no shared address space” will not.
  • Declare network egress in the manifest. Cheap to implement, enormous reduction in the realistic threat model, and it gives your review process something concrete to review.
  • Design the API for batching from day one. Every cross-boundary call has a fixed cost; an API of fine-grained synchronous property reads will be slow forever and you will not be able to change it later.
07

Hosting: where plugin code lives, and how it gets there

Two separate questions that people merge and shouldn’t. Where does the code execute? is an isolation and latency question. Where does the code come from? is a supply-chain and operations question. You need an answer to both, and for a POS you may need different answers for different kinds of plugin.

Five execution topologies

Ordered from least to most isolated. Every real platform picks two or three of these and routes different extension points to different ones.

TopologyIsolationCall latencyLanguagesWho pays to run itReal example
A. In-process, native
import and call
none~0.001 msYours onlyYouWordPress, pytest/pluggy, Django apps
B. In-process, sandboxed VM
WASM / QuickJS / V8 isolate
strong~0.01–1 msAnything → WASMYouFigma, Shopify Functions, Envoy/Proxy-WASM
C. Separate process, same box
pipe or socket RPC
strong~0.5–5 msAnyYouLSP servers, VS Code extension host, Terraform providers
D. Your cloud, their code
container / isolate you schedule
strong~5–200 msAnyYou (metered)Cloudflare Workers for Platforms, Deno Subhosting, Supabase Edge
E. Their cloud, their code
webhooks + your REST API
total50–2000 msAnyThemShopify apps, Slack apps, Stripe apps, GitHub Apps
How to choose, in one line each

E is the cheapest platform to build and the only one that scales to a hundred thousand developers — but it cannot participate in anything synchronous or offline. B is the only option that is both safe and fast enough to sit on a critical path. A is what you should use for first-party modules and nothing else. Start with E, add B when something has to be on the hot path, and never let A be available to strangers.

The publish-to-run pipeline

This is the part that separates a plugin API from a plugin platform. Most of the engineering is here, and none of it is glamorous.

PUBLISH DISTRIBUTE RUN dev submits bundle manifest + code + tests CI: schema valid? host contract suite passes? automated scan perm diff · secrets · malware human review tiered by blast radius sign + content-address sha256 is the identity registry index versions · compat ranges CDN / edge cache immutable artifacts tenant installs consent: scopes shown pinned version record per store, in your DB host loads that hash verify sig · check revoked kill switch: a signed revocation list flows back down the same path and is checked on every load
The two properties worth defending: artifacts are immutable and content-addressed, so “version 1.4.0” can never quietly become different bytes; and installs are pinned per tenant, so an update is a decision someone made rather than something that happened.

The registry service, concretely

manifest schema
A versioned JSON Schema, published, with a CLI that validates against it before submission. Reject unknown fields loudly — forward compatibility is not free and pretending otherwise creates accidental API.
artifact store
Object storage keyed by content hash, never by version name. Immutable, cheap to CDN, trivially deduplicated, and it makes “did this change?” a byte comparison.
version index
Which versions exist, which host API ranges each supports, which are yanked. This is the only mutable part, and it is a small, boring, extremely available service.
signing
You sign on publish; the host verifies on load. Publisher keys are separate from platform keys, so you can revoke one developer without invalidating everything.
install records
Per tenant: plugin id, pinned version, granted scopes, settings blob, enabled flag, installed-by. This is the table your support team will live in.
revocation feed
Signed, short-TTL, cached everywhere, checked on every plugin load. This is your kill switch and it must work when your API is down.

Multi-tenancy and the noisy plugin

Once plugins run in your infrastructure, one tenant’s bad plugin is everyone’s latency. The controls, roughly in order of how quickly you will need them:

  • A per-invocation budget, enforced by the runtime, not requested politely. WASM engines give you fuel (an instruction counter) and epoch interruption; processes give you timeouts and SIGKILL.
  • A circuit breaker per plugin per tenant. Five timeouts in a minute and the plugin is skipped for the next ten, with a visible notice. Do not make a human decide this at 2am.
  • Attribution in every trace and every log line. plugin_id and plugin_version as first-class span attributes. Without this you cannot tell a plugin problem from a platform problem, and you will spend a quarter learning that lesson.
  • Concurrency caps and instance pooling. WASM instances are cheap to create from a pre-compiled module; keep the compiled module cached and instantiate per call so plugins cannot accumulate state between invocations.
  • A staged rollout for plugin updates, the same as for your own code: 1% of tenants, then 10%, then all, with automatic rollback on error-rate regression.
The constraint most platforms never face

Everything above assumes the host can reach the registry. A point-of-sale terminal cannot assume that. Plugin distribution for a POS has to look like firmware, not like npm: bundles are pulled ahead of time, pinned, verified, cached on the device, and usable for weeks with no connectivity — and the revocation list has to be something the device already has a signed, timestamped copy of. §11 comes back to this, because it changes the design more than anything else on this page.

08

Sandboxing and security

The security question is not “is this plugin malicious?” It is “what is the worst thing that happens if it is?” — and the honest answer for an in-process plugin system is everything you can do, it can do. Sandboxing is the work of making that answer smaller.

Four adversaries, not one

ThreatLooks likeFrequencyWhat actually stops it
Malicious pluginPublished to steal data or skim moneyRareReview + sandbox + egress allowlist
Compromised pluginA popular good plugin whose maintainer account or build was taken over; the malicious version ships as an updateMost common serious incidentSigning, permission-diff on update, staged rollout, kill switch
Buggy pluginInfinite loop, 4-second hook, memory leak, wrong taxConstantBudgets, circuit breakers, contract tests, fail-open design
Hostile inputA fine plugin fed a crafted product name or barcodeCommonValidate at the boundary in both directions; treat plugin output as untrusted too

Note where the weight sits. Almost everyone designs against the first row and gets hurt by the second and third. The second row is why the permission diff between version N and N+1 is the single most valuable automated check you can build: a loyalty plugin that suddenly requests payment scopes should stop the release, automatically, at 3am, without a human.

The root cause: ambient authority

In most languages, any code can reach anything the process can reach: open files, make sockets, read environment variables, import your database module. That is ambient authority — power that comes from being in the room rather than from being handed something. Capability security is the inverse: a plugin can only act on objects it was given, and there is nothing to get hold of otherwise.

Every real sandbox is a way of removing ambient authority. They differ in how convincingly.

TechniqueEscape resistanceCold startMeteringNotes
Nothing (convention)n/a0NoWordPress. Viable only if every plugin is reviewed or first-party.
Language-level (RestrictedPython, Realms, vm2)weak<1 msPartialSame heap, same engine. Historically broken repeatedly. Use as a guardrail, never as a boundary.
V8 isolategood~1–5 msYesCloudflare Workers model. Excellent for JS; relies on V8 having no bugs.
QuickJS compiled to WASMstrong~1–10 msYesFigma’s answer. JS-only, slow-ish, but a genuine machine boundary.
WASM + WASI (wasmtime / wasmer)strong<1 ms*Yes, preciselyAny source language. Deterministic. *from a pre-compiled module. Best fit for hot paths.
Process + seccomp / pledgestrong~10–50 msCoarseOS-level, well understood, heavy per instance.
Container (gVisor)strong~100–500 msYesFine for async jobs, far too slow for a hook in a checkout.
microVM (Firecracker)very strong~125 ms+YesWhat you use when the plugin is a whole application.
Someone else’s server (webhook)totalnetworkN/APerfect isolation, zero synchrony, and they pay the bill.

Metering, concretely

The property that makes WASM the right answer for a critical path: you can bound execution in instructions, not just seconds, so a plugin’s cost is deterministic and reproducible rather than dependent on how loaded the machine is.

pythonhost/sandbox.py  ·  wasmtime-py, exact API names vary by version
from wasmtime import Engine, Store, Module, Linker, Config, FuncType, ValType

cfg = Config()
cfg.consume_fuel = True            # bound work in instructions
cfg.epoch_interruption = True      # and in wall-clock, as a backstop
engine = Engine(cfg)

# Compile ONCE, at install time. Cache the artifact.
module = Module(engine, wasm_bytes)

def run(module, cart_json, *, fuel=5_000_000, mem_mb=16):
    store = Store(engine)
    store.set_fuel(fuel)
    store.set_limits(memory_size=mem_mb * 1024 * 1024)

    linker = Linker(engine)
    # The plugin's ENTIRE universe is what we define here.
    # No WASI, so: no filesystem, no clock, no sockets, no randomness.
    linker.define_func("host", "log", FuncType([ValType.i32(),
                                                ValType.i32()], []), _log)
    instance = linker.instantiate(store, module)

    try:
        return instance.exports(store)["evaluate"](store, cart_json)
    except Exception:                # includes fuel exhaustion + traps
        metrics.incr("plugin.trapped")
        return []                    # fail open. the sale continues.
Not linking WASI is the whole point. The plugin has no clock, so it cannot behave differently on Tuesdays without being told the date; no randomness, so it is reproducible; no sockets, so it cannot exfiltrate. Every capability it has, you deliberately defined on the line above.

The rest of the checklist

Credentials
Never hand over a real key

A plugin gets a short-lived token scoped to its declared permissions, its tenant and ideally the single invocation. If a plugin can hold a credential that outlives the call, a leak of that credential is a breach of your platform, not of the plugin.

Egress
Allowlist domains in the manifest

Figma’s networkAccess.allowedDomains with a human-readable reason, enforced by the host. Turns exfiltration from a possibility into a review item, and gives users something meaningful on the consent screen.

Data
Minimise what crosses the boundary

A discount hook needs SKUs, quantities and prices. It does not need the customer’s name, phone number or card token. Build the DTO for the hook, not from your domain model — the lazy version is how PII ends up in a third party’s logs.

Output
Validate what comes back

A plugin returning amount_cents = -999999999 or a receipt line containing ESC/POS control codes is your bug, not theirs. Schema-check, range-check and sanitise every return value before it reaches a total or a printer.

Supply chain
Sign, pin, and diff

Signed artifacts, content-addressed storage, per-tenant version pinning, build provenance, and an automated permission diff on every update. Publish a lockfile-equivalent so “what was running in store 12 last Tuesday?” has an exact answer.

Response
A kill switch you have tested

Remote disable of one plugin, one version, or one publisher, propagating in minutes, working from cache when your control plane is down. Run the drill quarterly. An untested kill switch is a comment in a design doc.

When plugins touch money

Non-negotiables for a POS

1. Card data never enters plugin memory. The PAN goes from the terminal to the acquirer; plugins see a token and the last four digits. This is not just prudence — it is what keeps third-party code out of your PCI-DSS scope, and putting it in scope would make an open ecosystem economically impossible.

2. No plugin can set a total. Plugins produce labelled, signed, individually-valid contributions; the host does the arithmetic and enforces the invariants (nothing below zero, no discount above a configured ceiling without an override, no adjustment without a reason code).

3. Every contribution is recorded. The sale record stores which plugin produced which adjustment, at which version, for what stated reason. That log is what settles a chargeback, a tax audit and an argument with a franchisee.

4. Money-path plugins are a separate trust tier — contract-reviewed, code-reviewed, signed by you, not self-publishable. Most of your ecosystem should be nowhere near this tier.

09

Versioning: shipping v2 without breaking 10,000 plugins

The moment a third party depends on your extension points, your internal refactoring freedom is gone. This is the tax on having an ecosystem, and the entire discipline of versioning is about paying it in instalments rather than all at once.

Version the API, not the product

Your product ships continuously at whatever version marketing likes. Your plugin API is a separate, slow-moving, semantically versioned artifact with its own changelog. Figma has "api": "1.0.0" in every manifest; Python entry-point groups can carry it in the group name; VS Code has engines.vscode. Every plugin declares the range it works against, and the host refuses to load anything outside it — loudly, at install time, with a message the user can act on.

jsoncompatibility declared on both sides
// plugin manifest
{ "id": "happy_hour", "version": "1.4.0", "hostApi": "^2.3" }

// host advertises, at runtime
{ "hostApi": "2.7.1",
  "supported": ["1.x (deprecated, removal 2027-01-01)", "2.x"],
  "features": ["pricing.evaluate", "receipt.blocks", "offline.queue.v2"] }
Note features alongside the version. Feature detection beats version sniffing: a plugin that asks “does this host support receipt.blocks?” keeps working across hosts that gained the feature at different versions, including forks and older on-prem installs.

What counts as a breaking change (it is more than you think)

ChangeBreaking?Why
Removing a hook or a fieldmajorObvious
Adding a required manifest fieldmajorEvery existing manifest becomes invalid
Adding an optional field to a payloadusually minorBreaks plugins that validate strictly or exhaustively match. Say in writing that payloads grow.
Making a sync hook asyncmajorChanges every call site’s control flow
Changing hook order or timingmajor in practiceUndeclared, but plugins depend on it. See Hyrum below.
Tightening validation on plugin outputmajorPreviously-accepted plugins start failing
Fixing a bug plugins worked aroundit dependsThe workaround is now the bug. Ship behind a flag keyed to declared API version.
Making an operation fasteroccasionally!Race conditions that never fired now fire. Yes, really.
Hyrum’s Law

With a sufficient number of users, every observable behaviour of your system will be depended on by somebody — regardless of what you documented. The practical countermeasure is to make the things you have not promised visibly unreliable: randomise iteration order within a priority band, jitter timings, vary non-significant whitespace. If plugins cannot depend on it accidentally, you stay free to change it. Do this from day one; it is impossible to introduce later.

The mechanics that make migration survivable

  • Additive-only within a major. New hooks, new optional fields, new capabilities. Never a changed meaning for an existing name — introduce pricing.evaluate.v2 rather than redefining pricing.evaluate.
  • Expand–migrate–contract. Ship the new hook, run both for a deprecation window with the old one adapted onto the new one, publish usage numbers, then remove. The adapter layer is where all the ugliness lives, and it should be host code that you can delete in one commit.
  • Runtime deprecation warnings, attributed. When a plugin calls something deprecated, log plugin_id, version and the removal date — and surface it in the developer’s dashboard, not only in your logs. Most plugin authors do not read your changelog; they do read the email that says their plugin breaks in 90 days.
  • A published contract test suite. Ship pytest --plugin-conformance that a developer runs in their own CI against a fake host. It is your API spec in executable form, it catches breakage before you do, and it makes “we tested against the new version” a checkbox rather than a promise.
  • Canary against the real ecosystem. Before a host release, run the top 200 installed plugins against it in a harness. This is the single highest-value piece of platform infrastructure after the kill switch, and almost nobody builds it early enough.
  • A stated support policy. “Two major API versions, minimum 12 months of overlap, 90 days’ notice for deprecations.” Write it down and publish it. Developers will invest in a platform whose breakage schedule is predictable and will not invest in one whose isn’t.
10

Marketplace and money

A plugin API is a technical artifact. A plugin ecosystem is a two-sided market, and it fails for market reasons far more often than for technical ones — usually because nobody could make a living in it.

The cold-start problem, and the only reliable answer

Developers build for platforms with users; users come to platforms with plugins. The way out is to build your own features on the public plugin API and ship them as plugins. This does three things at once: it proves the API is powerful enough to build real things, it forces you to feel every ergonomic paper cut before strangers do, and it populates the catalogue on day one. If your own team needs private hooks to build the core features, external developers will hit that wall on their second afternoon and leave.

The corollary: publish the source of a handful of your first-party plugins. The most common first act of a plugin developer is copying an existing plugin that does something similar.

Discovery, install, consent

  • Ranking is policy, and everyone knows it. Installs, retention, review score, support responsiveness, and recency of update — weight them explicitly and publish the weights. An opaque ranking algorithm in a marketplace with paid apps generates suspicion that costs you more than the flexibility gains.
  • The consent screen is a security control. It should list scopes in the user’s language (“read your product catalogue”, not catalog:read), name the network domains, and highlight anything new when a plugin update requests more than it had. Most users click through, so the value is in what it forces developers to justify, and in what it gives you to point at afterwards.
  • Make uninstall genuinely clean. Data deleted or exported, tokens revoked, webhooks removed, scheduled jobs cancelled. Users install more freely on platforms where uninstall is trustworthy.

Who charges what

PlatformMarketplace billingPlatform takeConsequence for the ecosystem
Shopify Full billing API; merchant pays Shopify, Shopify pays developer 0% on the first $1M lifetime, then 15% — plus a 2.9% processing fee. Developers over $20M/yr pay a flat 15%. Genuinely viable businesses; a large professional app economy; strong platform leverage over quality
WordPress.org None. Free plugins only. 0% Enormous catalogue, freemium-with-license-key economy off-platform, weak quality leverage, updates flow from vendor servers
VS Code None 0% Huge free catalogue; monetisation happens by selling the backend service the extension talks to
Figma Community Paid plugins and creator payouts Platform fee on paid resources A middle path: mostly free, with a professional tier that can charge

Shopify’s terms are the published 2026 schedule; the others change less often but confirm before quoting any of them in a business plan.

Why you want to own billing

Letting developers charge through their own Stripe account is much less work and it is usually a mistake for a business tool. When the platform owns billing you get: one invoice for the merchant (a real purchase-decision unlock for a shop owner with fourteen line items already), trials and proration handled once and correctly, refunds and chargebacks you can actually enforce, dunning that does not silently disable a store’s tax plugin mid-shift, and a usage-based billing primitive that lets developers charge per transaction — which for a POS is the natural pricing model and is very hard for a small developer to build alone.

Pricing models worth supporting from the start: free, flat subscription, tiered by store count or volume, and usage-based. One-time purchase looks attractive and ages badly for software that must be maintained against a moving API.

The tension nobody escapes

Sherlocking

Every successful plugin category is a signal about a missing product feature, and shipping that feature natively kills the businesses built on it. Platforms that do this casually lose developer trust permanently — and developer trust is the input to the ecosystem you are trying to build. The workable posture: state in advance which areas are core (payments, tax, receipts, sync — the things every merchant needs and you must guarantee) and which are ecosystem territory, give real notice before entering an occupied space, and where you do, offer acquisition or a migration path rather than an announcement. You will not make everyone happy. You can at least be predictable.

11

Case study: a POS anyone can write plugins for

Now the actual question. A point-of-sale system is an unusually demanding host: it is a microkernel by nature, it handles money, it must keep selling when the network dies, it drives physical hardware, and in many countries the receipt it prints is a legally regulated document. Every one of those constraints changes the plugin design.

POS constraintNot a problem for a CMSWhat it forces on the plugin architecture
Must sell offlineA website that is down is just downPlugin code has to be pre-staged on the device and runnable with no network. Rules out webhook-only extensibility for anything on the sale path.
Handles moneyA wrong font is embarrassingDeterminism, auditability, and a host that owns all arithmetic. Plugins contribute; they never compute the total.
Two devices, one truthOne server, one answerThe same cart with the same plugin versions must produce byte-identical totals on any till, or offline sync becomes unresolvable.
Cheap hardwareScale the server upHard CPU and memory budgets per hook. A 200ms discount rule is a visible stall at the till with a queue behind it.
Physical peripheralsA separate, tighter plugin track for drivers: printers, drawers, scales, scanners, card terminals.
Fiscal regulationIn Kenya (KRA eTIMS), Germany (KassenSichV/TSE), Italy, Brazil, Poland and many others, receipts must be signed by certified software. That path cannot be plugin-overridable, ever.
The cashier is not an adminSite owner is technical-ishFailures must degrade into “the sale still completes”, not into a dialog nobody at the counter can answer.

The shape: three planes, two plugin runtimes

CONTROL PLANE · YOUR SAAS plugin catalog · signing · revocation list per-store version pinning · billing small, boring, extremely available pre-staged signed bundles same catalog, same pins THE TILL · OFFLINE-FIRST POS core owns all arithmetic WASM runtime fuel-metered, no I/O local event log the source of truth until it syncs driver bridge signed native, tier 3 UI slots React, error-bounded printer · drawer scale · card terminal CLOUD · PER TENANT API + event bus durable, ordered per store cloud runtime isolates / containers webhook dispatcher retry · backoff · dead-letter queue third-party apps, on their own servers accounting · ecommerce · loyalty · BI queued sync idempotency keys replayable
Two plugin runtimes because there are two kinds of extension. Anything that must run during a sale runs on the till, sandboxed and offline-capable. Anything that can happen afterwards runs in the cloud or on the developer’s own servers, where latency and failure are cheap. Deciding which side a feature belongs on is the main design conversation you will have with plugin authors.
Runtime A · till
WASM, fuel-metered

Any language compiled to WASM. No WASI: no clock, no files, no sockets, no randomness. Everything it needs arrives as arguments. Budget measured in instructions, so the same plugin costs the same on a slow Android till as on a fast one.

Hosts: pricing, discounts, validation, receipt content, custom line logic.

Runtime B · cloud
Isolates, or their own servers

Async, at-least-once, retried, with a dead-letter queue. Latency and failure here never reach the counter. Start with plain webhooks — they cost you nothing to run and let developers use any stack.

Hosts: accounting sync, loyalty ledgers, ecommerce, reporting, notifications.

Runtime C · drivers
Signed native, tightly held

Hardware needs real OS access, so this cannot be sandboxed the same way. Keep it a small, closed, contract-reviewed track with device-specific certification — and give it a narrow interface (print(doc), openDrawer(), weigh()) so most integrations never need it.

The invariant that everything else hangs off

The money rule

Pricing is a pure function of (cart, plugin set, plugin settings, timestamp). Plugins return labelled, signed contributions. The host validates each one against its ceilings and invariants, then does the arithmetic itself, in integer minor units, in a fixed order. Nothing that can perform I/O is permitted to influence a total.

This buys you four things at once: the same cart totals identically on every till (so offline sync can merge), every discount on a receipt is attributable to a named plugin and reason code (so disputes and audits resolve), a plugin that hangs or crashes costs you one dropped contribution rather than a wrong total, and you can replay a historical sale against the exact plugin versions that produced it.

DETERMINISTIC · SANDBOXED · NO I/O · WORKS OFFLINE CERTIFIED TIER · MAY QUEUE ASYNC cart.line .added event pricing .evaluate collect tax .calculate provider HOST freezes the total no plugins here tender .authorize provider fiscal .sign mandatory sale .completed fan-out 3 ms 15 ms total 10 ms 30 s 2 s / queue no budget fail open fail open fall back n/a fail closed fail closed retry budget on failure A dropped contribution costs a discount. A dropped signature costs a criminal offence. They are not the same failure.
Every extension point carries a budget and a failure policy, decided up front and enforced by the host. The asymmetry in the bottom row is the design: on the left the store keeps selling, on the right it must not.

The extension point catalogue

This table is the actual product. Write it before you write any code, review it like a schema migration, and treat every row as a promise you will be keeping in five years. Six columns, and none of them are optional: a point without a declared budget and failure policy is a future incident.

Extension pointMechanismRuns onBudgetOn failureScope required
cart.line.addedeventtill3 msskipcart:read
cart.validatecollect → warningstill5 msskipcart:read
pricing.evaluatecollect → adjustmentstill, WASM15 ms alldrop contributioncart:read catalog:read
coupon.resolveprovidertill, cache-first200 ms“can’t verify”, allow manualcoupons
tax.calculateprovider, one per jurisdictiontill, WASM10 msbuilt-in tabletax
tender.methodprovider registrytillinstall-timemethod hiddentender
tender.authorizeprovidertill + network30 sfail closedtender:authorize
fiscal.signcertified providertill, offline queue2 sfail closedfiscal (tier 3)
receipt.blockscollect → document blockstill8 msomit blockreceipt
printer.driverprovidernative bridge5 soffer reprinthardware:printer
sale.finalizecollect → may vetotill10 msskip (verified tier may veto)cart:read
inventory.reserveprovidertill, then cloud50 msallow oversell, flag itinventory
customer.lookupprovidertill, cached300 msanonymous salecustomers:read
shift.opened / .closedeventtill20 msskipshift
sale.completedevent, at-least-oncecloudretry, then DLQwebhooks
report.sectioncollect → UIcloud2 somit sectionreports
ui.slot.*slottill, Reactone frameerror boundaryui
settings.pageslotcloud + tillerror boundaryui

A plugin, end to end

jsonplugin.json  ·  everything the platform needs to decide before running a line of code
{
  "id": "co.dukalink.tuesday-coffee",
  "name": "Tuesday Coffee Club",
  "version": "1.4.0",
  "hostApi": "^2.3",
  "tier": "verified",

  "runtimes": {
    "till":  { "module": "dist/rules.wasm", "sha256": "9f2c...", "fuel": 2000000 },
    "ui":    { "entry":  "dist/ui.js" },
    "cloud": { "webhook": "https://api.dukalink.co/pos/events" }
  },

  "contributes": {
    "pricing.evaluate": { "priority": 20, "runtime": "till" },
    "receipt.blocks":   { "priority": 60, "runtime": "till" },
    "sale.completed":   { "runtime": "cloud" },
    "ui.slot.sale.line.badge": { "runtime": "ui" },
    "settings.page":    { "runtime": "ui" }
  },

  "scopes": ["cart:read", "catalog:read", "receipt", "ui", "webhooks"],
  "networkAccess": {
    "allowedDomains": ["https://api.dukalink.co"],
    "reasoning": "Posts completed sales to the loyalty ledger."
  },

  "settingsSchema": {
    "day":      { "type": "enum", "values": ["mon","tue","wed"], "default": "tue" },
    "percent":  { "type": "int",  "min": 1, "max": 30, "default": 10 },
    "category": { "type": "categoryRef", "default": null }
  },

  "limits": { "maxDiscountPercent": 30, "maxAdjustmentsPerSale": 3 },
  "offline": { "required": true, "maxCatalogStalenessDays": 30 }
}
Three things to notice. The plugin is one product spanning three runtimes, declared separately. limits are enforced by the host, so the review question becomes “is 30% reasonable?” rather than “did they write a bug?”. And settingsSchema means the host renders the settings form — most plugins then need no custom UI at all.
pythonrules.py  ·  compiled to WASM. No clock, no network, no filesystem.
from pos_sdk import Adjustment, ReceiptBlock, hook

@hook("pricing.evaluate")
def evaluate(cart, env):
    """env carries everything the sandbox cannot fetch for itself:
       env.local_date, env.settings, env.catalog (a frozen snapshot)."""
    cfg = env.settings
    if env.local_date.weekday_name != cfg["day"]:
        return []

    eligible = [l for l in cart.lines
                if env.catalog.category_of(l.sku) == cfg["category"]]
    if not eligible:
        return []

    base = sum(l.qty * l.unit_cents for l in eligible)
    return [Adjustment(
        label       = f"{cfg['day'].title()} club −{cfg['percent']}%",
        amount_cents= -(base * cfg["percent"]) // 100,   # int maths only
        reason_code = "TUESDAY_CLUB",
        applies_to  = [l.id for l in eligible],
    )]

@hook("receipt.blocks")
def receipt(sale, env):
    if not any(a.reason_code == "TUESDAY_CLUB" for a in sale.adjustments):
        return []
    return [ReceiptBlock.text("You saved with Tuesday Club ☕",
                              align="center", size="small")]
The date arrives as data. That is not pedantry: it is what makes the function replayable, testable, identical across tills whose clocks disagree, and unable to behave differently under review than in production — a classic trick of malicious plugins.
javascriptui.jsx  ·  the same plugin’s contribution to the till screen
import { contribute, useSaleContext } from "pos-sdk/ui";

function ClubBadge({ line }) {
  const { adjustments } = useSaleContext();          // DTO, read-only
  const hit = adjustments.find(
    a => a.reason_code === "TUESDAY_CLUB" && a.applies_to.includes(line.id)
  );
  if (!hit) return null;
  return <span className="pos-badge pos-badge--save">club</span>;
}

contribute("ui.slot.sale.line.badge", {
  id: "tuesday-coffee.badge",
  component: ClubBadge,
  priority: 40,
});
The UI reads the result of the pricing rule rather than recomputing it. If a plugin’s screen and its WASM module can disagree about the discount, they eventually will, and the cashier will be the one explaining it.

Offline is the architecture, not a feature

  • Bundles are pre-staged like firmware. The till holds the signed WASM modules and UI bundles for every enabled plugin, pinned by content hash, verified on load, and usable for weeks with no connectivity. Updates download in the background and take effect at the next shift open — never mid-sale.
  • The sale record pins the plugin set. Each completed sale stores {plugin_id, version, sha256} for every contribution it received. Two months later you can replay that exact cart through those exact modules and reproduce the total to the cent. This is the audit story, the dispute story and the bug-report story all at once.
  • Sync is an idempotent event log. The till appends immutable events with a client-generated ULID; the cloud is a replay of them. Cloud-side plugins therefore see an ordered, at-least-once stream and must be idempotent — say this loudly in your developer docs, because it is the single most common cause of duplicated loyalty points and double-posted invoices.
  • Revocation works offline, carefully. The device carries a signed catalogue snapshot with an issue timestamp. If a plugin is revoked, it stops loading everywhere as the snapshot propagates. If the snapshot itself goes stale past maxCatalogStalenessDays, disable non-certified plugins and keep selling — a stale revocation list must never be able to close a shop.
  • Conflicts are business rules, not merge rules. Inventory that went negative because two tills sold the last item offline is not a data conflict to resolve automatically; it is an oversell to surface to a human, with both sales intact.

Trust tiers

TierMay reachRuntimeReviewPublishing
1 · Community Cloud events, reports, read-only UI. Nothing on the sale path. Cloud / webhook only Automated scan Self-publish, instant
2 · Verified Pricing, receipts, cart validation, till UI slots WASM on the till Human review + contract suite + identity check Self-publish after first approval; permission changes re-trigger review
3 · Certified Payments, fiscal signing, hardware drivers Native / certified providers Code review, commercial contract, per-jurisdiction certification, security audit You sign every release. No self-publish, ever.

Almost all of your ecosystem should be tier 1, and that is a success, not a limitation. Tier 1 costs you nothing to run, carries no risk to a sale, and is where the long tail of integrations lives. Tier 3 should have perhaps a dozen members and each one should have a phone number you can call.

What to build, in what order

Resist building “a plugin system”. Build the three extension points you have a named customer for, and let the system emerge from the second and third.

  1. Cloud events + a REST API. sale.completed, shift.closed, inventory.changed as signed, retried webhooks. Zero runtime risk, zero infrastructure to run, unblocks accounting and ecommerce integrations immediately — which is what shops ask for first.
  2. UI slots. React contributions with error boundaries. Visible, satisfying for developers, and it forces you to define your DTOs.
  3. The settings-schema-driven admin. Host-rendered forms. Removes most of the reason a plugin would need custom UI at all.
  4. The WASM pricing runtime. Only now, and only for pricing.evaluate. This is where you earn your architecture — the money invariant, contributions, budgets, replay.
  5. Provider registries for tax and tender, with your own built-ins written against the same public interfaces.
  6. The certified tier for fiscal and hardware, once a real regulatory requirement forces it. Not before.

Ship the kill switch, the plugin-attributed tracing, and the contract test suite alongside step 1, not step 4. Those three are the difference between a platform you can operate and a platform that operates you.

12

The checklist, and the pitfalls

Before you publish an extension point

Six questions. If you cannot answer all six, the point is not ready.

1
Name & signature

What data goes in, what comes back, and both as frozen DTOs with a published schema.

2
Timing

Exactly when in the host’s flow it fires, and what state is guaranteed to exist at that moment.

3
Ordering

Is order significant? If yes, how is it determined, and is it deterministic across restarts?

4
Failure policy

Throws, times out, returns nonsense — what does the host do for each, and does the user see anything?

5
Budget

Time, memory, and call count, enforced by the runtime and visible to the developer in their dashboard.

6
Permission

Which declared scope gates it, and what the consent screen says about it in plain language.

Principles worth holding to

  • Start with three hooks, not thirty. Additions are cheap forever; removals are impossible after the first thousand installs.
  • Build your own features on the public API. If your team needs a private hook, your API is not finished.
  • Data in, data out. Frozen DTOs across the boundary. Never an ORM object, a store instance, a live connection, or a callback into your internals.
  • Declarative for the boring, imperative for the interesting. Manifests for settings, menus, permissions and metadata; code only where behaviour genuinely varies. Declarative contributions can be validated, diffed, rendered and reviewed without running anything.
  • Attribute everything. Every log line, span, error and side effect carries plugin_id and version. Cheap on day one, impossible to retrofit.
  • Design the failure before the feature. For each point, what happens when the plugin is slow, wrong, or gone.
  • Version from the very first release, even when there is exactly one plugin and you wrote it.

The pitfalls, in the order people hit them

MistakeHow it feels at the timeWhat it costs later
The god object — passing app or db to pluginsFlexible, fast to shipYour entire internal surface is now public API and you can never refactor
Ambient authority — plugins import whatever they likeNormal Python/PHPNo meaningful permission model is possible afterwards
Synchronous hooks on a hot pathSimple, obviousOne slow plugin becomes your p99, at the till, in front of a queue
Unbounded hooks — no timeout, no fuelNothing has gone wrong yetAn infinite loop in someone else’s code takes down a shop
Order-dependent filtersWordPress does itPlugin interactions become a support category with no owner
No version negotiationThere is only one versionEvery future change is a flag day
No kill switchYou’d just tell people to uninstallYou cannot, at 2am, when the plugin is what’s stopping them logging in
Marketplace before ecosystemFeels like the finish lineAn empty catalogue with billing infrastructure and no developers
13

Glossary

Ambient authority
Power a piece of code has merely by existing in the process — open a file, make a socket, import a module. The thing capability security removes.
Capability
An unforgeable handle that both names a resource and grants the right to use it. If you weren’t handed one, you cannot act on it.
Content addressing
Storing an artifact under the hash of its bytes, so a name can never come to mean different content.
Extension point
A named, documented moment in the host’s flow at which plugin code is invited to participate.
Fuel / epoch interruption
WASM mechanisms that bound execution in instructions and in wall-clock time, so a plugin’s cost is deterministic rather than machine-dependent.
Hyrum’s Law
With enough users, every observable behaviour of your system is depended on by somebody, whatever you documented.
Idempotency key
A client-generated identifier that lets a retried operation be recognised as the same operation. The backbone of offline sync.
Manifest
Declarative metadata describing a plugin: identity, version, compatibility, contributions, permissions, settings schema.
Microkernel architecture
A minimal core plus plug-in modules carrying the domain behaviour. The formal name for what this document is about.
Sherlocking
A platform shipping natively what a successful third-party plugin already did, ending that plugin’s business.
Slot
A named UI region into which plugins contribute components.
Trust tier
A class of plugin with a defined review bar, runtime and set of reachable capabilities.