Skip to content

Plugin surfaces

Director ships exactly one plugin-style integration today: it dynamically imports the conversation surface from @open-matrix/chat-component/surface. This page documents what's actually happening and the architectural constraint that drives the design.

The shipped path: dynamic-import lazy chunk

Source: src/components/DirectorChatSurfaceHost.ts. The component class extends MatrixActorHtmlElement. On first activation of the Chat tab:

ts
this.loadState = 'loading';
this.renderLoading();
try {
  await import('@open-matrix/chat-component/surface');
} catch (err) {
  this.loadState = 'unavailable';
  this.renderUnavailable();
  return;
}
if (!customElements.get('conversation-surface')) {
  this.loadState = 'unavailable';
  return;
}
this.loadState = 'ready';
this.renderSurface();

Vite resolves @open-matrix/chat-component/surface at build time and emits a separate JS chunk in Director's dist/. The browser fetches the chunk via a normal relative URL. No bare-specifier resolution failure, no import-map dependency.

Trade-offs (acknowledged in the source comment block):

  • Pro: core class identity is preserved (single Vite build graph dedupes).
  • Pro: initial-load JS in Director is unaffected; chunk is lazy.
  • Pro: works in real browsers without a platform-feature dependency.
  • Con: the chat-component surface code physically ships inside Director's dist. It is not "fetched from a registry at runtime". Truly external loading requires the import-map architecture below.

The genuine external-plugin path (target state)

WORKSTREAMS/package-structure/CORE-SOURCE-CONTAINMENT-HARVEST.md HARVEST-040 / EXPLOIT-M4-003 describe what real cross-package plugin loading needs:

  1. Host-served import map. The gateway publishes /apps/<package>/import-map.json describing the package's exports.
  2. Browser import map. The gateway's bootstrap injects an import map into the page so await import('/apps/chat-component/surface/register-surface.js') resolves at runtime instead of build time.
  3. Class-identity discipline. @open-matrix/core must ship as a singleton on the platform's import map so two packages loading it independently get the same class.

None of that ships today. Director's lazy-chunk approach was the pragmatic choice that keeps the Chat tab working without those platform features.

What this means for new plugin surfaces

If you're adding a third surface (say, a flowpad-component surface), you have two options today:

  • Bundle as lazy chunk. Add the package as a dependency of Director, import '<package>/surface' from a *SurfaceHost component, ship the resulting lazy chunk inside Director's dist. Same trade-offs as chat-component.
  • Wait for HARVEST-040. Don't add the dependency. The platform-level import-map work is the right-size fix.

Inline iframes are not a third option for Director surfaces — they break NATS context propagation and require their own bootstrap.

How chat-component/surface is structured

Source: projects/matrix-3/packages/chat-component/src/surface/register-surface.ts. It is intentionally narrow:

ts
import { ConversationSurfaceActor } from './ConversationSurfaceActor';
import { ConversationRenderer } from './ConversationRenderer';
import { ConversationInput } from './ConversationInput';
import { ConversationSessionList } from './ConversationSessionList';

export function registerConversationSurfaceElements(): void {
  customElements.define('conversation-surface', ConversationSurfaceActor);
  customElements.define('conversation-renderer', ConversationRenderer);
  customElements.define('conversation-input', ConversationInput);
  customElements.define('conversation-session-list', ConversationSessionList);
}
registerConversationSurfaceElements();   // side-effect on import

Side-effect registration on module load means Director's await import() is sufficient to register the four custom elements; no extra call needed.

The Chat package's matrix.json declares the surface explicitly:

json
"surfaces": {
  "conversation": {
    "tag": "conversation-surface",
    "module": "./dist/browser/surface/register-surface.js",
    "route": "/apps/chat-component/surface/register-surface.js",
    "description": "Embeddable conversation surface plugin loaded by host shells (Director, etc.) via dynamic import"
  }
}

The surfaces section is documentation/declaration for the future host-side import-map registry. It's not consumed by Vite directly; it's metadata for HARVEST-040.

Failure modes the host has to handle

DirectorChatSurfaceHost.ts handles three:

  1. Lazy chunk fetch fails (network, missing file): renders "unavailable".
  2. Module loads but <conversation-surface> did not register: renders "unavailable".
  3. surface.set-target { mount } arrives before the surface loads: queue the mount in pendingTargetMount, dispatch on ready.

That defensive code exists because the host cannot rely on chunk-loading semantics being uniform across browsers and dev/prod builds.

See also

Source: projects/matrix-3/packages/director/src/components/DirectorChatSurfaceHost.ts (full component, ~175 lines), projects/matrix-3/packages/chat-component/src/surface/register-surface.ts, and chat-component/matrix.json (surfaces block).