Create a plugin
By the end of this page the inspector on this page loads your plugin, named Stash, from your machine, with one module: its strings. The next four pages give it a type, a view, an action and a place in the navigation tree.
Background
Section titled “Background”A plugin is a set of modules. Each module activates on an event and contributes capabilities, which are typed values that other parts of the app look up. The PLEX plugin explains how they fit. In this tutorial the whole plugin is defined in one file, dx.config.ts, and each module’s code lives in its own file that loads only when the module activates.
-
Create the package. Make a directory for the plugin with a
package.jsonand avite.config.ts.package.json {"name": "stash","version": "0.1.0","private": true,"type": "module","scripts": {"dev": "vite"},"dependencies": {"@dxos/app-framework": "https://pkg.pr.new/dxos/dxos/@dxos/app-framework@20a14b9","@dxos/app-graph": "https://pkg.pr.new/dxos/dxos/@dxos/app-graph@20a14b9","@dxos/app-toolkit": "https://pkg.pr.new/dxos/dxos/@dxos/app-toolkit@20a14b9","@dxos/client": "https://pkg.pr.new/dxos/dxos/@dxos/client@20a14b9","@dxos/compute": "https://pkg.pr.new/dxos/dxos/@dxos/compute@20a14b9","@dxos/echo": "https://pkg.pr.new/dxos/dxos/@dxos/echo@20a14b9","@dxos/echo-react": "https://pkg.pr.new/dxos/dxos/@dxos/echo-react@20a14b9","@dxos/plugin-space": "https://pkg.pr.new/dxos/dxos/@dxos/plugin-space@20a14b9","@dxos/react-ui": "https://pkg.pr.new/dxos/dxos/@dxos/react-ui@20a14b9","@dxos/ui-theme": "https://pkg.pr.new/dxos/dxos/@dxos/ui-theme@20a14b9","effect": "4.0.4","react": "~19.3.1","react-dom": "~19.3.1"},"devDependencies": {"@types/react": "^19.2.0","@vitejs/plugin-react": "^6.1.1","typescript": "^5.9.0","vite": "^8.3.7"}}vite.config.ts import react from '@vitejs/plugin-react';import { defineConfig } from 'vite';import { composerPlugin } from '@dxos/app-framework/vite-plugin';export default defineConfig({plugins: [...composerPlugin(), react()],});Then install the dependencies in that directory:
Terminal window npm installThe
@dxos/*packages are pinned to the build these docs run, so the inspector on each page can load your plugin.composerPluginserves the plugin from Vite’s dev server on port 3967, with a manifest that describes it athttp://localhost:3967/manifest.json. Publishing a plugin pins them to a Composer release instead. -
Name the plugin. The plugin’s key is a reverse-domain name that no other plugin uses. Its last segment must be camelCase. Put it in a file of its own, so the manifest and any capability tags the plugin declares can both import it.
src/namespace.ts import * asPlugin from '@dxos/app-framework/Plugin';import Pluginexport constStashNamespace =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}`>;}Plugin.import Pluginnamespace('com.example.plugin.stash');const namespace: <"com.example.plugin.stash">(key: "com.example.plugin.stash") => {key: "com.example.plugin.stash";make: <T>() => <N>(name: N) => MultiTag<T, `com.example.plugin.stash.capability.${N}`>;makeSingleton: <T>() => <N>(name: N) => Tag<T, `com.example.plugin.stash.capability.${N}`>;}A plugin's identity as a leaf module: its config and its capability tags both derive from it, so the config can list the tags its modules provide without the tags importing the config.
-
Write the manifest.
Plugin.configtakes the plugin’s registry metadata and its modules. It returns the plugin’smetaand amakefunction that the app’s plugin manager calls to create it. The file’s default export is what the dev server reads.modulesstarts empty; each step that follows adds one.dx.config.ts import * asPlugin from '@dxos/app-framework/Plugin';import Pluginimport {StashNamespace } from './src/namespace.ts';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}`>;}constconfig:const config: Plugin.PluginConfig<void>Plugin.import PluginPluginConfig =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.tsdefault export: its config data, runtimeMeta
and factory.
Plugin.import Pluginconfig({const config: <void>({ modules, ...data }: Plugin.ConfigOptions) => Plugin.PluginConfig<void>Defines a plugin entirely from its
dx.config.ts.plugin: {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;}key:key: stringReverse-domain NSID — the plugin's globally-unique key (e.g.
org.dxos.plugin.excalidraw).StashNamespace.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}`>;}key,key: "com.example.plugin.stash"name: 'Stash',name: stringauthor: 'Example',author?: string | undefinedAuthor 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.
description:description?: string | undefined'A reading list: save links with a note, and mark them as read.',icon: {icon?: {readonly key: string;readonly hue?: string | undefined;} | undefinedkey: 'ph--tray-arrow-down--regular',key: stringhue: 'indigo' },hue?: string | undefinedtags: ['labs'],tags?: readonly string[] | undefined},modules: [modules: readonly (Module<any> | undefined)[]Module specs only; each body is a dynamic import, so reading the config loads no plugin code.
],});export const {meta,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;}make } =const make: Plugin.PluginFactory<void>config;const config: Plugin.PluginConfig<void>export defaultconfig;const config: Plugin.PluginConfig<void>The
tagsfield starts with a quality tier. A new plugin islabs. -
Add the first module. Translations give the plugin’s strings their text. The module lists the translation resource by namespace. For now it has one entry, for the plugin’s own strings; the next page adds one for its type.
src/translations.ts import type * asTheme from '@dxos/react-ui/Theme';import Themeimport {StashNamespace } from './namespace.ts';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}`>;}export consttranslations = [const translations: [{readonly 'en-US': {readonly "com.example.plugin.stash": {readonly 'plugin.name': "Stash";readonly 'mark-read.label': "Mark as read";};};}]{'en-US': {[StashNamespace.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}`>;}key]: {key: "com.example.plugin.stash"'plugin.name': 'Stash','mark-read.label': 'Mark as read',},},},] asconst satisfiestype const = [{readonly 'en-US': {readonly "com.example.plugin.stash": {readonly 'plugin.name': "Stash";readonly 'mark-read.label': "Mark as read";};};}]Theme.import ThemeResource[];export ResourceAdd it to the manifest’s
modules.AppCapability.translationsis a module maker: it fixes the capability the module contributes and the event it activates on, so the manifest only names the file to load.dx.config.ts import * asAppCapability from '@dxos/app-toolkit/AppCapability';import AppCapability16 collapsed linesimport * asAppSurface from '@dxos/app-toolkit/AppSurface';import AppSurfaceimport * asSpaceCapability from '@dxos/plugin-space/SpaceCapability';import SpaceCapabilityimport {StashNamespace } from './src/namespace.ts';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}`>;}constconfig:const config: Plugin.PluginConfig<void>Plugin.import PluginPluginConfig =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.tsdefault export: its config data, runtimeMeta
and factory.
Plugin.import Pluginconfig({const config: <void>({ modules, ...data }: Plugin.ConfigOptions) => Plugin.PluginConfig<void>Defines a plugin entirely from its
dx.config.ts.plugin: {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;}key:key: stringReverse-domain NSID — the plugin's globally-unique key (e.g.
org.dxos.plugin.excalidraw).StashNamespace.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}`>;}key,key: "com.example.plugin.stash"name: 'Stash',name: stringauthor: 'Example',author?: string | undefinedAuthor 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.
description:description?: string | undefined'A reading list: save links with a note, and mark them as read.',icon: {icon?: {readonly key: string;readonly hue?: string | undefined;} | undefinedkey: 'ph--tray-arrow-down--regular',key: stringhue: 'indigo' },hue?: string | undefinedtags: ['labs'],tags?: readonly string[] | undefined},modules: [modules: readonly (Module<any> | undefined)[]Module specs only; each body is a dynamic import, so reading the config loads no plugin code.
AppCapability.import AppCapabilitytranslations(const translations: (resources: Resource | Resource[] | (() => Promise<{translations: Resource | Resource[];}>), options?: {name?: string;environments?: readonly Environment[];}) => Module<void>Module contributing translations.
() => import('./src/translations.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' } }),type: string), -
Load it in the inspector. Start the dev server in the plugin’s directory with the package’s
devscript:Terminal window npm run devThen click Load dev plugin below. The browser asks to reach devices on your local network: allow it, since your plugin’s code comes from your machine. The inspector lists your plugin’s modules on the left, with Translations active, and shows its strings by namespace. This page adds turns to a check once the module loads.
Leave the dev server running for the rest of the path. After an edit, click Reload in the inspector.
What you can do now
Section titled “What you can do now”- Name a plugin with a namespace and describe it in
dx.config.ts. - Add a module with a module maker.
- See what a plugin contributes in the inspector.