Skip to content

Plugins and modules

A plugin is metadata plus a set of modules, and a PLEX app is the plugins it loads.

A plugin’s metadata names it: a key that no other plugin uses, a display name, a description and an icon. Its modules do the work. Each module activates on an event, runs once, and contributes capabilities: typed values, such as a surface component or an operation handler, that other modules look up.

Plugin.define takes the metadata, Plugin.addModule adds each module, and Plugin.make returns a function that the app’s plugin manager calls to create the plugin.

import * as
import Plugin
Plugin
from '@dxos/app-framework/Plugin';
export const
const HelloPlugin: Plugin.PluginFactory<void>
HelloPlugin
=
import Plugin
Plugin
.
const define: <void>(meta: Plugin.Meta) => Plugin.PluginBuilder<void>

Creates a new PluginBuilder to start building a plugin.

define
(
const meta: {
readonly profile: {
readonly key: string;
readonly name: string;
readonly description?: string | undefined;
readonly homePage?: string | undefined;
readonly source?: string | undefined;
readonly tags?: readonly string[] | undefined;
readonly screenshots?: readonly {
readonly light?: string | undefined;
readonly dark?: string | undefined;
}[] | undefined;
readonly icon?: {
readonly key: string;
readonly hue?: string | undefined;
} | undefined;
readonly dependsOn?: readonly string[] | undefined;
readonly spec?: string | undefined;
readonly createdAt?: string | undefined;
readonly author?: string | undefined;
};
readonly release?: {
...;
} | undefined;
}
meta
).
Pipeable.pipe<Plugin.PluginBuilder<void>, Plugin.PluginBuilder<void>, Plugin.PluginFactory<void>>(this: Plugin.PluginBuilder<void>, ab: (_: Plugin.PluginBuilder<void>) => Plugin.PluginBuilder<void>, bc: (_: Plugin.PluginBuilder<void>) => Plugin.PluginFactory<void>): Plugin.PluginFactory<void> (+21 overloads)
pipe
(
import Plugin
Plugin
.
function addModule<void>(module: Capability.Module<void> | undefined, options?: {
id?: string;
}): (builder: Plugin.PluginBuilder<void>) => Plugin.PluginBuilder<void> (+3 overloads)

Adds a module to a plugin builder. Supports both pipeline and direct call styles.

Accepts either:

  • A spec-carrying module (

Capability.lazyModule

/

Capability.inlineModule

): requires/provides/activatesOn/props all come from the module's own spec (declared where it is authored), so the call needs only an optional id override.

  • An inline authoring record (or a function receiving plugin options): declares provides/requires (dependency mode) or activatesOn + requires/provides (runtime-event mode); the activate effect's environment is constrained to the declared requires and its return must cover the declared provides.

addModule
(
const GreetingModule: Capability.Module<void>
GreetingModule
),
import Plugin
Plugin
.
function make<T>(builder: Plugin.PluginBuilder<T>): Plugin.PluginFactory<T>

Creates a Plugin from a builder. Supports both pipeline and direct call styles. Always returns a factory function (options: T) => Plugin. When T is void, the function takes no arguments: () => Plugin.

make
,
);

A plugin you publish describes itself in a dx.config.ts manifest instead. Plugin.config takes the same metadata, plus registry fields such as tags, and the list of modules. It returns the plugin’s meta and its make function, so the registry can read the plugin without loading its code.

dx.config.ts
import * as
import Plugin
Plugin
from '@dxos/app-framework/Plugin';
import * as
import AppCapability
AppCapability
from '@dxos/app-toolkit/AppCapability';
import * as
import AppSurface
AppSurface
from '@dxos/app-toolkit/AppSurface';
import * as
import SpaceCapability
SpaceCapability
from '@dxos/plugin-space/SpaceCapability';
import {
const StashNamespace: {
key: "com.example.plugin.stash";
make: <T>() => <const N extends string>(name: N) => MultiTag<T, `com.example.plugin.stash.capability.${N}`>;
makeSingleton: <T>() => <const N extends string>(name: N) => Tag<T, `com.example.plugin.stash.capability.${N}`>;
}
StashNamespace
} from './src/namespace.ts';
const
const config: Plugin.PluginConfig<void>
config
:
import Plugin
Plugin
.
type PluginConfig<T = void> = {
readonly plugin: {
readonly key: string;
readonly name: string;
readonly description?: string | undefined;
readonly author?: string | undefined;
readonly homePage?: string | undefined;
readonly source?: string | undefined;
readonly screenshots?: readonly {
readonly light?: string | undefined;
readonly dark?: string | undefined;
}[] | undefined;
readonly tags?: readonly string[] | undefined;
readonly icon?: {
readonly key: string;
readonly hue?: string | undefined;
} | undefined;
readonly spec?: string | undefined;
readonly dependsOn?: readonly string[] | undefined;
};
readonly publish?: {
...;
} | undefined;
} & {
...;
}

A plugin's dx.config.ts default export: its config data, runtime

Meta

and factory.

PluginConfig
=
import Plugin
Plugin
.
const config: <void>({ modules, ...data }: Plugin.ConfigOptions) => Plugin.PluginConfig<void>

Defines a plugin entirely from its dx.config.ts.

@example

// dx.config.ts
export default Plugin.config({
plugin: { key: 'org.dxos.plugin.markdown', name: 'Markdown' },
modules: [AppCapability.schema(() => import('./src/capabilities/schema.ts'))],
});

config
({
plugin: {
readonly key: string;
readonly name: string;
readonly description?: string | undefined;
readonly author?: string | undefined;
readonly homePage?: string | undefined;
readonly source?: string | undefined;
readonly screenshots?: readonly {
readonly light?: string | undefined;
readonly dark?: string | undefined;
}[] | undefined;
readonly tags?: readonly string[] | undefined;
readonly icon?: {
readonly key: string;
readonly hue?: string | undefined;
} | undefined;
readonly spec?: string | undefined;
readonly dependsOn?: readonly string[] | undefined;
}
plugin
: {
key: string

Reverse-domain NSID — the plugin's globally-unique key (e.g. org.dxos.plugin.excalidraw).

key
:
const StashNamespace: {
key: "com.example.plugin.stash";
make: <T>() => <const N extends string>(name: N) => MultiTag<T, `com.example.plugin.stash.capability.${N}`>;
makeSingleton: <T>() => <const N extends string>(name: N) => Tag<T, `com.example.plugin.stash.capability.${N}`>;
}
StashNamespace
.
key: "com.example.plugin.stash"
key
,
name: string
name
: 'Stash',
author?: string | undefined

Author or organization name. Only used for bundled plugins. For plugins published to the registry this field is ignored — the verified publisher (handle ?? did) is used instead.

author
: 'Example',
description?: string | undefined
description
:
'A reading list: save links with a note, and mark them as read.',
icon?: {
readonly key: string;
readonly hue?: string | undefined;
} | undefined
icon
: {
key: string
key
: 'ph--tray-arrow-down--regular',
hue?: string | undefined
hue
: 'indigo' },
tags?: readonly string[] | undefined
tags
: ['labs'],
},
modules: readonly (Module<any> | undefined)[]

Module specs only; each body is a dynamic import, so reading the config loads no plugin code.

modules
: [
import AppCapability
AppCapability
.
const translations: (resources: Resource | Resource[] | (() => Promise<{
translations: Resource | Resource[];
}>), options?: {
name?: string;
environments?: readonly Environment[];
}) => Module<void>

Module contributing translations.

translations
(
() => import('./src/translations.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' } }),
),
import AppCapability
AppCapability
.
const schema: (types: ReadonlyArray<AnyEntity> | (() => Promise<{
default: ReadonlyArray<AnyEntity>;
}>), options?: {
name?: string;
environments?: readonly Environment[];
}) => Module<void>

Module contributing schemas. Prefer the loader form — schema objects ride whatever barrels declare them, so an inline list drags those barrels into the plugin definition's static closure (the boot evaluation floor); a loader keeps them in the module body chunk.

schema
(
() =>
import('./src/capabilities/schema.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' },
}),
),
import AppCapability
AppCapability
.
const operationHandler: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<OperationHandlerSet, "org.dxos.app-framework.capability.operationHandler">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

Module maker contributing operation handlers. Handler sets register eagerly by default: a keyed set is a definition→loader map with a thin closure (definitions carry no implementations), so the operation registry is complete at boot while each handler's BODY still loads per invocation. The deferral axis is the operation, not the module. A feature plugin whose operations cannot be invoked before the app is interactive declares activatesOn: ActivationEvents.Idle to register in that wave instead of on the startup pass; the boot path (deck, layout, space, client) invokes operations during startup and stays eager.

Stated explicitly rather than inherited: omitting activatesOn now normalizes to Idle, which would leave the registry incomplete for exactly those boot-path invocations.

operationHandler
(
() =>
import('./src/capabilities/operation-handler.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' },
}),
),
import SpaceCapability
SpaceCapability
.
const createObject: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<Readonly<{
id: string;
createObject: import("@dxos/plugin-space/SpaceSchema").CreateObject;
inputSchema?: import("effect/Schema").Codec<any, any>;
customPanel?: import("react").ComponentType<CreateObjectCustomPanelProps>;
}>, "org.dxos.plugin.space.capability.createObject">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

Module maker contributing a typed create-object entry. Gated by default on the create flow opening (all consumers read the entries reactively); declare activatesOn to override.

createObject
(
() =>
import('./src/capabilities/create-object.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' },
}),
),
import AppCapability
AppCapability
.
const appGraphBuilder: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<BuilderExtensions, "org.dxos.app-framework.capability.appGraphBuilder">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

Module maker contributing app-graph node builders. Gated by default on the host's idle event rather than the contributing plugin's own start: a builder is what puts a plugin's items in the navtree, so gating it on the plugin's own start (fired when its surface renders) is a deadlock — the item never appears, so it can never be opened. Declare activatesOn to override.

appGraphBuilder
(
() =>
import('./src/capabilities/app-graph-builder.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' },
}),
),
import AppCapability
AppCapability
.
const surface: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<any, "org.dxos.app-framework.capability.reactSurface">]>, options?: (MakerOptions<readonly [], readonly [], void, any> & {
roles?: readonly Role<any>[];
}) | undefined) => Module<any>

Module maker contributing React surfaces. Declaring roles (the role tokens the module's surfaces bind, e.g. AppSurface.Article or a plugin's <Plugin>Surface tokens) gates the module on those roles' demand events (

ActivationEvents.SurfacesRequested

) — it loads when a Surface for one of its roles first renders instead of at startup. Modules without declared roles stay eager; an explicit activatesOn wins over the derived gate.

surface
(
() =>
import('./src/capabilities/react-surface.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' },
}),
{
roles?: readonly Role<any>[] | undefined
roles
: [
import AppSurface
AppSurface
.
const Article: Role<AppSurface.ArticleData<any, {}, unknown>>

Role token for the article role. Subject defaults to any until narrowed via

object

,

literal

, or

subject

. Extra passthrough props are permitted.

Article
],
},
),
import AppCapability
AppCapability
.
const skillDefinition: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<Definition, "org.dxos.app-framework.capability.skillDefinition">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

Module maker contributing skill definitions. Gated by default on the assistant plugin's start event — a skill belongs to the assistant feature regardless of which plugin contributes it (skills register into a shared registry whose consumers are reactive); declare activatesOn to override.

skillDefinition
(
() =>
import('./src/capabilities/skill-definition.ts', {
ImportCallOptions.with?: ImportAttributes | undefined
with
: {
type: string
type
: 'module-url' },
}),
),
],
});
export const {
const meta: {
readonly profile: {
readonly key: string;
readonly name: string;
readonly description?: string | undefined;
readonly author?: string | undefined;
readonly homePage?: string | undefined;
readonly source?: string | undefined;
readonly screenshots?: readonly {
readonly light?: string | undefined;
readonly dark?: string | undefined;
}[] | undefined;
readonly tags?: readonly string[] | undefined;
readonly icon?: {
readonly key: string;
readonly hue?: string | undefined;
} | undefined;
readonly spec?: string | undefined;
readonly dependsOn?: readonly string[] | undefined;
readonly createdAt?: string | undefined;
};
readonly release?: {
...;
} | undefined;
}
meta
,
const make: Plugin.PluginFactory<void>
make
} =
const config: Plugin.PluginConfig<void>
config
;
export default
const config: Plugin.PluginConfig<void>
config
;

The Stash plugin from the learning path has seven modules. Each waits on an event and contributes one capability. Five of them:

Startup activates the translations, schema and operation-handler modules; Article requested activates react-surface; Idle activates app-graph-builder. Each module contributes the capability of the same name.Startup activates the translations, schema and operation-handler modules; Article requested activates react-surface; Idle activates app-graph-builder. Each module contributes the capability of the same name.
  • A module is inline or lazy. Capability.inlineModule carries its body with it. Capability.lazyModule names a loader, and the plugin manager imports the body only when the module’s event fires. A module whose event never fires never loads, so a plugin costs nothing until it is used.
import * as
import Effect
Effect
from 'effect/Effect';
import * as
import ActivationEvents
ActivationEvents
from '@dxos/app-framework/ActivationEvents';
import * as
import Capability
Capability
from '@dxos/app-framework/Capability';
// The module body loads only when its event fires. The loader's default export
// is checked against `provides`.
const
const LazyGreetingModule: Capability.Module<void>
LazyGreetingModule
=
import Capability
Capability
.
const lazyModule: <readonly [Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">], readonly [], void, void>(name: string, spec: ModuleSpec<readonly [Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">], readonly [], void, void>, loader: Capability.LoadModule<void, readonly [], readonly [Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">]>) => Capability.Module<void>

Helper to define a lazily loaded module body with an eager requires/provides spec. The spec is available without importing the chunk (dependency ordering happens before code-splitting resolves) and the loaded default export is type-checked against it. The returned

Module

is opaque with respect to requires/provides/props, parameterized only by Options — this is what eliminates TS2883 at every call site. The lazy pairing of

makeModule

.

@param ― name The export name (e.g., 'AppGraphBuilder') - used to auto-compute module IDs.

@param ― spec The requires/provides declaration, matching the module authoring site.

@param ― loader The lazy loader function.

lazyModule
(
'LazyGreeting',
{
provides: readonly [Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">]
provides
: [
const Greeting: Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">
Greeting
],
activatesOn?: Events | undefined

Activates when this runtime event fires instead of during the dependency pass.

activatesOn
:
import ActivationEvents
ActivationEvents
.
const Idle: ActivationEvent

Fired once by the host at main-thread idle after the app is interactive.

For REGISTRATION contributions only — graph builders, operation handler sets, settings: the app-wide indexes a user can reach from anywhere (the navtree, the operation registry, a settings read) and which therefore no single surface can gate. A feature's own modules ride its plugin start event, fired when its surface renders; putting a feature body here instead is what turned the former DeferredStartup into a second startup pass.

This is also what omitting activatesOn normalizes to, so a module that must run at boot has to say activatesOn: ActivationEvents.Startup explicitly. Defined in core; see

ActivationEvent$.Idle

.

Idle
},
async () => ({
default: (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">> | readonly Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>[], Error, Capability.Requirements<readonly []>>
default
:
import Capability
Capability
.
const makeModule: <void, Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>, never, never>(fn: (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>, never, Scope>) => (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>, never, Scope>

Helper to define a capability module with explicit typing. Wraps the default export function to provide better type inference and make the pattern explicit.

This helper provides explicit typing for the module activation function, making it clear that the function should:

  • Access declared requires via yield* (or the ambient Capability.Service/Plugin.Service)
  • Return an array of typed

Contribution

s (see

contribute

/

contributeAll

)

@example

// Module without options - single capability
export default Capability.makeModule(
Effect.fnUntraced(function* () {
const client = yield* ClientCapabilities.Client;
return Capability.contribute(Capabilities.SettingsStore, store);
})
);
// Module with multiple capabilities
export default Capability.makeModule(
Effect.fnUntraced(function* () {
return [
Capability.contribute(Capabilities.SettingsStore, store),
Capability.contribute(Capabilities.Translations, translations),
];
})
);
// Module with required options (context accessed via layer)
export default Capability.makeModule(
Effect.fnUntraced(function* (props: { observability: boolean }) {
const invoker = yield* Capabilities.OperationInvoker;
return Capability.contribute(Capabilities.OperationHandler, ...);
})
);
// Module with scoped resources (closed automatically on deactivation)
export default Capability.makeModule(
Effect.fnUntraced(function* () {
const scope = yield* Scope.Scope;
yield* Scope.addFinalizer(scope, Effect.sync(() => cleanup()));
return Capability.contribute(Capabilities.MyCapability, implementation);
})
);

makeModule
(() =>
import Effect
Effect
.
const succeed: <Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>>(value: Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">>, never, never>

Creates an Effect that always succeeds with a given value.

When to use

Use when an effect should complete successfully with a specific value without any errors or external dependencies.

Example (Creating a successful effect)

import { Effect } from "effect"
// Creating an effect that represents a successful scenario
//
// ┌─── Effect<number, never, never>
// ▼
const success = Effect.succeed(42)
Effect.runSync(success) // => 42

@see ― fail to create an effect that represents a failure.

@stability ― stable

@category ― constructors

@since ― 2.0.0

succeed
(
import Capability
Capability
.
const contribute: <Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">>(capability: Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">, implementation: string) => Capability.Contribution<Capability.CapabilityIdentifier<"com.example.plugin.hello.capability.greeting", "multi">> (+1 overload)
contribute
(
const Greeting: Capability.MultiTag<string, "com.example.plugin.hello.capability.greeting">
Greeting
, 'Hello, later')),
),
}),
);
  • The module makers in AppCapability, such as AppCapability.surface, fix a module’s capability and its event, so a manifest names only the file to load. The loader’s default export is checked against the capability at compile time.
  • Every module declares what it provides and what it requires. The plugin manager uses them to order activation; Activation covers the rules.
  • Plugins never import each other’s modules. A plugin that extends another contributes to a capability the other reads.
  • A plugin’s key is a reverse-domain name, and its last segment must be camelCase. Keep the key once the plugin has users: settings and enabled state are stored under it.
  • A module keeps no state between activations. What it needs to keep, it contributes or stores.