Skip to content

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.

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.

  1. Create the package. Make a directory for the plugin with a package.json and a vite.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 install

    The @dxos/* packages are pinned to the build these docs run, so the inspector on each page can load your plugin. composerPlugin serves the plugin from Vite’s dev server on port 3967, with a manifest that describes it at http://localhost:3967/manifest.json. Publishing a plugin pins them to a Composer release instead.

  2. 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 * as
    import Plugin
    Plugin
    from '@dxos/app-framework/Plugin';
    export const
    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
    =
    import Plugin
    Plugin
    .
    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.

    @example

    // src/namespace.ts
    export const MarkdownNamespace = Plugin.namespace('org.dxos.plugin.markdown');
    // src/types/MarkdownCapabilities.ts
    export const Settings = MarkdownNamespace.makeSingleton<Atom.Writable<Settings>>()('settings');
    // dx.config.ts
    Plugin.config({ plugin: { key: MarkdownNamespace.key, … }, modules: [ … ] });

    namespace
    ('com.example.plugin.stash');
  3. Write the manifest. Plugin.config takes the plugin’s registry metadata and its modules. It returns the plugin’s meta and a make function that the app’s plugin manager calls to create it. The file’s default export is what the dev server reads. modules starts empty; each step that follows adds one.

    dx.config.ts
    import * as
    import Plugin
    Plugin
    from '@dxos/app-framework/Plugin';
    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
    : [
    ],
    });
    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 tags field starts with a quality tier. A new plugin is labs.

  4. 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 * as
    import Theme
    Theme
    from '@dxos/react-ui/Theme';
    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 './namespace.ts';
    export const
    const translations: [{
    readonly 'en-US': {
    readonly "com.example.plugin.stash": {
    readonly 'plugin.name': "Stash";
    readonly 'mark-read.label': "Mark as read";
    };
    };
    }]
    translations
    = [
    {
    'en-US': {
    [
    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
    ]: {
    'plugin.name': 'Stash',
    'mark-read.label': 'Mark as read',
    },
    },
    },
    ] as
    type const = [{
    readonly 'en-US': {
    readonly "com.example.plugin.stash": {
    readonly 'plugin.name': "Stash";
    readonly 'mark-read.label': "Mark as read";
    };
    };
    }]
    const
    satisfies
    import Theme
    Theme
    .
    export Resource
    Resource
    [];

    Add it to the manifest’s modules. AppCapability.translations is 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 * as
    import AppCapability
    AppCapability
    from '@dxos/app-toolkit/AppCapability';
    16 collapsed lines
    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' } }),
    ),
  5. Load it in the inspector. Start the dev server in the plugin’s directory with the package’s dev script:

    Terminal window
    npm run dev

    Then 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.

  • 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.